Greasy Fork is available in English.
RisuRealm 카드와 상세 페이지에 위시리스트 버튼을 추가하고, 로컬 저장, 필터, 가져오기/내보내기, 일괄 작업을 지원합니다.
// ==UserScript==
// @name RisuRealm Wishlist
// @namespace https://realm.risuai.net/
// @version 1.4.10
// @license MIT
// @description 给 RisuRealm 卡片和详情页添加心愿单按钮,并支持本地保存、筛选、导入导出和批量操作。
// @description:en Add heart wishlist buttons to RisuRealm cards and detail pages, with local save, filters, import/export, and batch actions.
// @description:zh-CN 给 RisuRealm 卡片和详情页添加心愿单按钮,并支持本地保存、筛选、导入导出和批量操作。
// @description:ko RisuRealm 카드와 상세 페이지에 위시리스트 버튼을 추가하고, 로컬 저장, 필터, 가져오기/내보내기, 일괄 작업을 지원합니다.
// @description:ja RisuRealm のカードと詳細ページにウィッシュリストボタンを追加し、ローカル保存、絞り込み、インポート/エクスポート、一括操作に対応します。
// @match https://realm.risuai.net/*
// @icon https://realm.risuai.net/favicon.ico
// @run-at document-idle
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// ==/UserScript==
(function () {
'use strict';
const STORAGE_KEY = 'rrw:wishlist:v1';
const NON_COMMERCIAL_KEY = 'rrw:noncommercial:v1';
const CARD_SELECTOR = [
'a[href^="/character/"]',
'a[href^="/preset/"]',
'a[href^="/module/"]',
'a[href*="/character/"]',
'a[href*="/preset/"]',
'a[href*="/module/"]',
].join(',');
const DETAIL_PATH_RE = /^\/(character|preset|module)\//;
const css = `
.rrw-card,
.rrw-detail-card {
position: relative !important;
}
.rrw-heart {
align-items: center;
backdrop-filter: blur(8px);
background: rgba(17, 24, 39, 0.72);
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 999px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
color: #fff;
cursor: pointer;
display: inline-flex;
font-size: 19px;
font-weight: 700;
height: 36px;
justify-content: center;
line-height: 1;
position: absolute;
right: 10px;
top: 10px;
transition: transform 140ms ease, background 140ms ease, color 140ms ease, border-color 140ms ease;
user-select: none;
width: 36px;
z-index: 20;
}
.rrw-heart:hover {
transform: scale(1.08);
}
.rrw-heart[aria-pressed="true"] {
background: rgba(244, 63, 94, 0.95);
border-color: rgba(255, 255, 255, 0.65);
color: #fff;
}
.rrw-heart:focus-visible,
.rrw-detail-button:focus-visible,
.rrw-toolbar button:focus-visible,
.rrw-panel button:focus-visible,
.rrw-panel a:focus-visible {
outline: 2px solid #f43f5e;
outline-offset: 2px;
}
.rrw-card.rrw-hidden {
display: none !important;
}
html.rrw-filtering a[href^="/character/"]:not([data-rrw-saved="1"]),
html.rrw-filtering a[href^="/preset/"]:not([data-rrw-saved="1"]),
html.rrw-filtering a[href^="/module/"]:not([data-rrw-saved="1"]),
html.rrw-filtering a[href*="/character/"]:not([data-rrw-saved="1"]),
html.rrw-filtering a[href*="/preset/"]:not([data-rrw-saved="1"]),
html.rrw-filtering a[href*="/module/"]:not([data-rrw-saved="1"]) {
display: none !important;
}
.rrw-detail-button {
align-items: center;
appearance: none;
backdrop-filter: blur(8px);
background: rgba(17, 24, 39, 0.72);
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 999px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
color: #fff;
cursor: pointer;
display: inline-flex;
font-size: 19px;
font-weight: 700;
height: 36px;
justify-content: center;
line-height: 1;
padding: 0;
position: absolute;
right: 10px;
top: 10px;
transition: transform 140ms ease, background 140ms ease, color 140ms ease, border-color 140ms ease;
user-select: none;
width: 36px;
z-index: 20;
}
.rrw-detail-button:hover {
transform: scale(1.08);
}
.rrw-detail-button[aria-pressed="true"] {
background: rgba(244, 63, 94, 0.95);
border-color: rgba(255, 255, 255, 0.65);
color: #fff;
}
.rrw-toolbar {
align-items: center;
background: rgba(17, 24, 39, 0.9);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 999px;
bottom: 18px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35);
color: #fff;
display: flex;
gap: 6px;
padding: 7px;
position: fixed;
right: 18px;
z-index: 2147483646;
}
.rrw-toolbar button,
.rrw-panel button {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 999px;
color: inherit;
cursor: pointer;
font: 13px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
padding: 8px 11px;
}
.rrw-toolbar button:hover,
.rrw-panel button:hover {
background: rgba(255, 255, 255, 0.18);
}
.rrw-toolbar button[aria-pressed="true"],
.rrw-panel button[aria-pressed="true"] {
background: #f43f5e;
border-color: #fb7185;
}
.rrw-panel-backdrop {
background: rgba(0, 0, 0, 0.38);
inset: 0;
position: fixed;
z-index: 2147483646;
}
.rrw-panel {
background: #111827;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 14px;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
color: #fff;
display: flex;
flex-direction: column;
max-height: min(76vh, 680px);
max-width: min(92vw, 560px);
min-height: 260px;
padding: 14px;
position: fixed;
right: 18px;
top: 64px;
width: 560px;
z-index: 2147483647;
}
.rrw-panel-header {
align-items: center;
display: flex;
gap: 10px;
justify-content: space-between;
margin-bottom: 10px;
}
.rrw-panel-title {
font: 700 17px/1.3 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.rrw-panel-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
}
.rrw-tabs {
display: flex;
flex: 1 1 180px;
gap: 6px;
min-width: 180px;
}
.rrw-tab {
border-radius: 10px !important;
font-weight: 700 !important;
justify-content: center;
min-width: 0;
padding: 8px 9px !important;
width: 100%;
}
.rrw-tab[aria-pressed="true"] {
background: #f43f5e;
border-color: #fb7185;
color: #fff;
}
.rrw-filterbar {
display: grid;
gap: 8px;
margin-bottom: 10px;
}
.rrw-selectbar,
.rrw-tag-modes {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.rrw-tag-modes {
margin-bottom: 6px;
}
.rrw-selected-count {
color: rgba(255, 255, 255, 0.68);
font: 12px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
margin-left: auto;
}
.rrw-tag-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-height: 78px;
overflow: auto;
}
.rrw-tag {
border-radius: 999px !important;
font-size: 12px !important;
padding: 5px 8px !important;
}
.rrw-tag[aria-pressed="true"],
.rrw-tag-mode[aria-pressed="true"] {
background: #f43f5e;
border-color: #fb7185;
color: #fff;
}
.rrw-list {
display: grid;
gap: 8px;
overflow: auto;
padding-right: 3px;
}
.rrw-group {
display: grid;
gap: 8px;
}
.rrw-group + .rrw-group {
margin-top: 10px;
}
.rrw-group-title {
align-items: center;
color: rgba(255, 255, 255, 0.82);
display: flex;
font: 700 13px/1.3 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
justify-content: space-between;
padding: 2px 2px 0;
}
.rrw-group-items {
display: grid;
gap: 8px;
}
.rrw-empty {
align-items: center;
color: rgba(255, 255, 255, 0.68);
display: flex;
flex: 1;
font: 14px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
justify-content: center;
text-align: center;
}
.rrw-item {
align-items: center;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: inherit;
display: grid;
gap: 10px;
grid-template-columns: 24px 54px minmax(0, 1fr) auto;
padding: 8px;
text-decoration: none;
}
.rrw-item-check {
accent-color: #f43f5e;
height: 16px;
width: 16px;
}
.rrw-item img {
aspect-ratio: 1;
border-radius: 8px;
height: 54px;
object-fit: cover;
width: 54px;
}
.rrw-item-main {
min-width: 0;
}
.rrw-item-title,
.rrw-item-meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rrw-item-title {
font: 700 14px/1.3 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.rrw-item-meta {
color: rgba(255, 255, 255, 0.68);
font: 12px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
margin-top: 3px;
}
.rrw-item-tags {
color: rgba(255, 255, 255, 0.52);
font: 11px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
margin-top: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rrw-remove {
color: rgba(255, 255, 255, 0.72);
height: 34px;
padding: 0 !important;
width: 34px;
}
html.rrw-mobile .rrw-panel {
bottom: 72px;
box-sizing: border-box;
left: 10px;
max-height: 72vh;
max-height: calc(100dvh - 86px);
max-width: none;
right: auto;
top: auto;
width: calc(var(--rrw-mobile-width, 100vw) - 20px);
}
html.rrw-mobile .rrw-toolbar {
border-radius: 16px;
bottom: 12px;
box-sizing: border-box;
flex-wrap: wrap;
left: 12px;
max-width: calc(var(--rrw-mobile-width, 100vw) - 24px);
right: auto;
top: auto;
}
html.rrw-mobile .rrw-toolbar button {
flex: 1 1 auto;
min-width: 0;
}
html.rrw-mobile .rrw-panel-header {
align-items: flex-start;
flex-direction: column;
}
html.rrw-mobile .rrw-panel-actions {
justify-content: flex-start;
width: 100%;
}
html.rrw-mobile .rrw-item {
gap: 8px;
grid-template-columns: 24px 44px minmax(0, 1fr) 34px;
}
html.rrw-mobile .rrw-item img {
height: 44px;
width: 44px;
}
html.rrw-mobile .rrw-detail-button {
right: 10px;
top: 10px;
}
`;
addStyle(css);
let state = normalizeStore(readStore());
let filterEnabled = false;
let nonCommercialDownload = readBool(NON_COMMERCIAL_KEY);
let panelFilter = 'character';
let tagFilterMode = 'any';
let tagExpanded = false;
const selectedPaths = new Set();
const selectedTags = new Set();
let toolbar;
let panel;
let detailButton;
let observerScheduled = false;
bootstrap();
function bootstrap() {
syncMobileClass();
renderToolbar();
renderDetailButton();
scanCards();
observePage();
patchHistory();
window.addEventListener('resize', syncMobileClass, { passive: true });
window.addEventListener('popstate', scheduleScan);
}
function syncMobileClass() {
const screenMin = Math.min(
(window.screen && window.screen.width) || Infinity,
(window.screen && window.screen.height) || Infinity
);
const screenCssMin = screenMin / Math.max(1, window.devicePixelRatio || 1);
const screenWidth = screenMin <= 640 ? screenMin : screenCssMin;
const width = Math.min(
window.innerWidth || Infinity,
document.documentElement.clientWidth || Infinity,
screenWidth
);
document.documentElement.style.setProperty(
'--rrw-mobile-width',
`${Math.max(0, width === Infinity ? screenWidth : width)}px`
);
document.documentElement.classList.toggle('rrw-mobile', width <= 640);
}
function addStyle(text) {
if (typeof GM_addStyle === 'function') {
GM_addStyle(text);
return;
}
const style = document.createElement('style');
style.textContent = text;
document.head.append(style);
}
function readStore() {
try {
if (typeof GM_getValue === 'function') return GM_getValue(STORAGE_KEY, null);
} catch (_) {
// Fall through to localStorage.
}
try {
return localStorage.getItem(STORAGE_KEY);
} catch (_) {
return null;
}
}
function writeStore(nextState) {
state = normalizeStore(nextState);
persistStore();
refreshAll();
}
function persistStore() {
const raw = JSON.stringify(state);
try {
if (typeof GM_setValue === 'function') {
GM_setValue(STORAGE_KEY, raw);
} else {
localStorage.setItem(STORAGE_KEY, raw);
}
} catch (_) {
try {
localStorage.setItem(STORAGE_KEY, raw);
} catch (error) {
console.error('[RisuRealm Wishlist] Failed to save wishlist', error);
}
}
}
function readBool(key) {
try {
const value = typeof GM_getValue === 'function' ? GM_getValue(key, false) : localStorage.getItem(key);
return value === true || value === 'true';
} catch (_) {
return false;
}
}
function writeBool(key, value) {
try {
if (typeof GM_setValue === 'function') GM_setValue(key, Boolean(value));
else localStorage.setItem(key, String(Boolean(value)));
} catch (_) {
try {
localStorage.setItem(key, String(Boolean(value)));
} catch (error) {
console.error('[RisuRealm Wishlist] Failed to save setting', error);
}
}
}
function normalizeStore(raw) {
let parsed = raw;
if (typeof raw === 'string') {
try {
parsed = JSON.parse(raw);
} catch (_) {
parsed = null;
}
}
const rawItems = parsed && typeof parsed === 'object' && parsed.items && typeof parsed.items === 'object'
? parsed.items
: {};
const items = {};
Object.entries(rawItems).forEach(([path, item]) => {
if (!item || typeof item !== 'object') return;
items[path] = { ...item, tags: normalizeTags(item.tags || []) };
});
return { version: 1, items };
}
function normalizeTags(tags) {
const seen = new Set();
return (Array.isArray(tags) ? tags : [])
.map((tag) => String(tag || '').trim())
.filter((tag) => {
const key = tag.toLowerCase();
if (!tag || seen.has(key)) return false;
seen.add(key);
return true;
})
.slice(0, 60);
}
function tagKey(tag) {
return String(tag || '').trim().toLowerCase();
}
function canonicalPath(href) {
try {
const url = new URL(href, location.origin);
return url.pathname.replace(/\/$/, '');
} catch (_) {
return href;
}
}
function typeFromPath(path) {
const match = path.match(/^\/([^/]+)\//);
return match ? match[1] : 'item';
}
function idFromPath(path) {
const match = String(path || '').match(/\/(?:character|preset|module)\/([^/?#]+)/);
return match ? match[1] : '';
}
function currentDetailPath() {
const path = canonicalPath(location.href);
return DETAIL_PATH_RE.test(path) ? path : '';
}
function detectCurrentPageType(fallbackPath) {
const typeLabels = new Set(['character', 'preset', 'module', 'lorebook']);
const lines = (document.body.innerText || '')
.split('\n')
.map((line) => line.trim().toLowerCase())
.filter(Boolean);
const visibleType = lines.find((line) => typeLabels.has(line));
if (visibleType === 'lorebook') return 'module';
return visibleType || typeFromPath(fallbackPath);
}
function isCard(anchor) {
const path = canonicalPath(anchor.getAttribute('href') || anchor.href);
if (!DETAIL_PATH_RE.test(path)) return false;
const text = (anchor.innerText || '').trim();
const className = String(anchor.className || '');
const rect = anchor.getBoundingClientRect();
const hasCardClass = /\bborder\b/.test(className) && /\bflex\b/.test(className) && /\brounded-md\b/.test(className);
const hasCardContent = Boolean(anchor.querySelector('img')) && /\bBy\s+/i.test(text);
return hasCardClass && hasCardContent && rect.width >= 160 && rect.height >= 100;
}
function extractMeta(card) {
const path = canonicalPath(card.getAttribute('href') || card.href);
const lines = (card.innerText || '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
const img = card.querySelector('img');
const image = img ? img.currentSrc || img.src || img.getAttribute('src') || '' : '';
const alt = img ? (img.alt || '').trim() : '';
const title = alt || lines.find((line) => !/^By\s+/i.test(line)) || path.split('/').pop() || 'Untitled';
const authorLine = lines.find((line) => /^By\s+/i.test(line)) || '';
const author = authorLine.replace(/^By\s+/i, '').trim();
return {
addedAt: new Date().toISOString(),
author,
image,
path,
tags: extractTags(card, title, author),
title,
type: detectCurrentPageType(path),
url: new URL(path, location.origin).href,
};
}
function extractCurrentPageMeta() {
const path = currentDetailPath();
const lines = (document.body.innerText || '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
const titleFromDocument = document.title.replace(/^RisuRealm\s*-\s*/i, '').trim();
const title = titleFromDocument
|| lines.find((line) => !/^(Upload|Account|Download|Options|Help|RisuRealm|Click to preview|Comments are disabled)/i.test(line) && !/^By\s+/i.test(line))
|| path.split('/').pop()
|| 'Untitled';
const authorLine = lines.find((line) => /^By\s+/i.test(line)) || '';
const author = authorLine.replace(/^By\s+/i, '').trim();
const image = findBestPageImage(title);
const container = findDetailCardContainer();
const download = findDownloadLink(container || document);
return {
addedAt: state.items[path] ? state.items[path].addedAt : new Date().toISOString(),
author,
downloadName: download.name,
downloadType: download.type,
downloadUrl: download.url,
image,
path,
tags: extractTags(container || document.body, title, author),
title,
type: detectCurrentPageType(path),
url: new URL(path, location.origin).href,
};
}
function findDownloadLink(root) {
const link = root && root.querySelector ? root.querySelector('a[href*="/api/v1/download/"]') : null;
if (!link) return { name: '', type: '', url: '' };
const url = new URL(link.getAttribute('href'), location.origin).href;
return {
name: link.getAttribute('download') || '',
type: downloadTypeFromUrl(url),
url,
};
}
function downloadTypeFromUrl(url) {
if (url.includes('/charx-v3/')) return 'charx';
if (/\/(?:png|json)-v[23]\//.test(url)) return 'normal';
if (/\/preset-(?:risu-v1|st-chat)\//.test(url)) return 'preset';
if (/\/(?:module-v1|lorebook-v[23])\//.test(url)) return 'module';
return '';
}
function extractTags(root, title, author) {
const ignored = new Set([title, author, `By ${author}`, '♡', '♥', 'No creator comments'].filter(Boolean));
const lines = (root && root.innerText ? root.innerText : '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
return normalizeTags(lines.filter((line) => isTagLine(line, ignored)));
}
function isTagLine(line, ignored) {
if (ignored.has(line)) return false;
if (line.length > 40 || /\s/.test(line)) return false;
if (/^By\s+/i.test(line) || /^https?:\/\//i.test(line)) return false;
if (/^[\d.,kKmM]+$/.test(line)) return false;
if (/[()[\]{}<>`"“”]/.test(line)) return false;
return /^[\p{L}\p{N}_+#&.'-]+$/u.test(line);
}
function findBestPageImage(title) {
const images = Array.from(document.images)
.map((img) => ({
alt: (img.alt || '').trim(),
area: (img.naturalWidth || img.width || 0) * (img.naturalHeight || img.height || 0),
src: img.currentSrc || img.src || img.getAttribute('src') || '',
}))
.filter((img) => img.src && !img.src.startsWith('data:'));
const exactAlt = images.find((img) => img.alt && img.alt === title);
if (exactAlt) return exactAlt.src;
images.sort((a, b) => b.area - a.area);
return images[0] ? images[0].src : '';
}
function scanCards() {
document.querySelectorAll(CARD_SELECTOR).forEach((card) => {
if (!(card instanceof HTMLAnchorElement)) return;
if (!isCard(card)) {
resetCardButton(card);
return;
}
if (card.dataset.rrwReady === '1') {
updateCard(card);
return;
}
card.dataset.rrwReady = '1';
card.classList.add('rrw-card');
const heart = document.createElement('span');
heart.className = 'rrw-heart';
heart.setAttribute('role', 'button');
heart.setAttribute('tabindex', '0');
heart.addEventListener('pointerdown', stopCardOpen, true);
heart.addEventListener('click', (event) => {
stopCardOpen(event);
toggleCard(card);
}, true);
heart.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
stopCardOpen(event);
toggleCard(card);
}, true);
card.append(heart);
updateCard(card);
});
applyFilter();
updateToolbar();
}
function resetCardButton(card) {
Array.from(card.children).forEach((child) => {
if (child.classList && child.classList.contains('rrw-heart')) child.remove();
});
card.classList.remove('rrw-card', 'rrw-hidden');
delete card.dataset.rrwReady;
delete card.dataset.rrwSaved;
}
function stopCardOpen(event) {
event.preventDefault();
event.stopPropagation();
}
function toggleCard(card) {
const meta = extractMeta(card);
const next = { ...state, items: { ...state.items } };
if (next.items[meta.path]) {
delete next.items[meta.path];
} else {
next.items[meta.path] = meta;
}
writeStore(next);
}
function updateCard(card) {
const path = canonicalPath(card.getAttribute('href') || card.href);
const saved = Boolean(state.items[path]);
const heart = card.querySelector('.rrw-heart');
card.dataset.rrwSaved = saved ? '1' : '0';
if (saved) refreshSavedMeta(extractMeta(card));
if (heart) {
heart.textContent = saved ? '♥' : '♡';
heart.setAttribute('aria-label', saved ? '从心愿单移除' : '加入心愿单');
heart.setAttribute('title', saved ? '从心愿单移除' : '加入心愿单');
heart.setAttribute('aria-pressed', String(saved));
}
}
function refreshSavedMeta(meta) {
const current = state.items[meta.path];
if (!current) return;
const tags = normalizeTags([...(current.tags || []), ...(meta.tags || [])]);
const changed = tags.join('\n') !== itemTags(current).join('\n')
|| (!current.image && meta.image)
|| (!current.author && meta.author)
|| (!current.downloadUrl && meta.downloadUrl);
if (!changed) return;
state.items[meta.path] = {
...current,
author: current.author || meta.author,
downloadName: current.downloadName || meta.downloadName,
downloadType: current.downloadType || meta.downloadType,
downloadUrl: current.downloadUrl || meta.downloadUrl,
image: current.image || meta.image,
tags,
title: current.title || meta.title,
type: current.type || meta.type,
url: current.url || meta.url,
};
persistStore();
renderPanel();
}
function findDetailCardContainer() {
const path = currentDetailPath();
if (!path) return null;
const title = document.title.replace(/^RisuRealm\s*-\s*/i, '').trim();
const candidates = Array.from(document.querySelectorAll('main, article, section, div'))
.filter((el) => {
if (toolbar && toolbar.contains(el)) return false;
if (panel && panel.contains(el)) return false;
if (detailButton && detailButton.contains(el)) return false;
const rect = el.getBoundingClientRect();
if (rect.width < 160 || rect.height < 120) return false;
const text = (el.innerText || '').trim();
if (!text || text.length > 20000) return false;
if (title && !text.includes(title)) return false;
if (!/\bBy\s+/i.test(text) && !text.includes('By ')) return false;
const hasAction = /\bDownload\b|\bOptions\b|Click to/i.test(text);
const hasImage = Boolean(el.querySelector('img'));
return hasAction || hasImage;
})
.map((el) => {
const rect = el.getBoundingClientRect();
return { el, area: rect.width * rect.height, y: rect.top };
})
.sort((a, b) => a.area - b.area || a.y - b.y);
return candidates[0] ? candidates[0].el : null;
}
function renderDetailButton() {
const path = currentDetailPath();
const container = findDetailCardContainer();
if (!path || !container) {
if (detailButton) {
detailButton.remove();
detailButton = null;
}
return;
}
if (!detailButton) {
detailButton = document.createElement('button');
detailButton.className = 'rrw-detail-button';
detailButton.type = 'button';
detailButton.addEventListener('click', toggleCurrentPage);
}
container.classList.add('rrw-detail-card');
if (detailButton.parentElement !== container) container.append(detailButton);
updateDetailButton();
}
function toggleCurrentPage() {
const meta = extractCurrentPageMeta();
const next = { ...state, items: { ...state.items } };
if (next.items[meta.path]) {
delete next.items[meta.path];
} else {
next.items[meta.path] = meta;
}
writeStore(next);
}
function updateDetailButton() {
if (!detailButton) return;
const path = currentDetailPath();
const saved = Boolean(path && state.items[path]);
const text = saved ? '♥' : '♡';
if (detailButton.textContent !== text) detailButton.textContent = text;
detailButton.setAttribute('aria-label', saved ? '从心愿单移除' : '加入心愿单');
detailButton.setAttribute('title', saved ? '从心愿单移除' : '加入心愿单');
detailButton.setAttribute('aria-pressed', String(saved));
}
function renderToolbar() {
if (toolbar) return;
toolbar = document.createElement('div');
toolbar.className = 'rrw-toolbar';
toolbar.innerHTML = `
<button type="button" data-rrw-action="open">♡ 心愿单 <span data-rrw-count>0</span></button>
<button type="button" data-rrw-action="filter" aria-pressed="false">只看心愿</button>
`;
toolbar.addEventListener('click', (event) => {
const button = event.target.closest('button[data-rrw-action]');
if (!button) return;
if (button.dataset.rrwAction === 'open') {
openPanel();
return;
}
filterEnabled = !filterEnabled;
button.setAttribute('aria-pressed', String(filterEnabled));
applyFilter();
});
document.body.append(toolbar);
updateToolbar();
}
function updateToolbar() {
if (!toolbar) return;
const count = Object.values(state.items).filter((item) => {
const key = wishlistGroupKey(item);
return key === 'character' || key === 'preset';
}).length;
const countNode = toolbar.querySelector('[data-rrw-count]');
if (countNode) countNode.textContent = String(count);
const filterButton = toolbar.querySelector('[data-rrw-action="filter"]');
if (filterButton) filterButton.setAttribute('aria-pressed', String(filterEnabled));
}
function openPanel() {
closePanel();
const backdrop = document.createElement('div');
backdrop.className = 'rrw-panel-backdrop';
backdrop.addEventListener('click', closePanel);
panel = document.createElement('section');
panel.className = 'rrw-panel';
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-label', 'RisuRealm 心愿单');
document.body.append(backdrop, panel);
renderPanel();
}
function closePanel() {
document.querySelectorAll('.rrw-panel, .rrw-panel-backdrop').forEach((node) => node.remove());
panel = null;
}
function renderPanel() {
if (!panel) return;
const items = Object.values(state.items).sort((a, b) => String(b.addedAt || '').localeCompare(String(a.addedAt || '')));
const characterItems = items.filter((item) => wishlistGroupKey(item) === 'character');
const presetItems = items.filter((item) => wishlistGroupKey(item) === 'preset');
const visibleTotal = characterItems.length + presetItems.length;
const tabItems = panelFilter === 'preset' ? presetItems : characterItems;
const activeItems = tabItems.filter(matchesTagFilter);
const selectedCount = getSelectedItems().length;
panel.innerHTML = '';
const header = document.createElement('div');
header.className = 'rrw-panel-header';
const title = document.createElement('div');
title.className = 'rrw-panel-title';
title.textContent = `心愿单 (${visibleTotal})`;
const actions = document.createElement('div');
actions.className = 'rrw-panel-actions';
const clearButton = document.createElement('button');
clearButton.type = 'button';
clearButton.textContent = '清空';
clearButton.addEventListener('click', () => {
if (!items.length) return;
if (confirm('确定清空 RisuRealm 心愿单吗?')) {
selectedPaths.clear();
selectedTags.clear();
writeStore({ version: 1, items: {} });
}
});
const exportSelectedButton = document.createElement('button');
exportSelectedButton.type = 'button';
exportSelectedButton.textContent = '导出所选';
exportSelectedButton.addEventListener('click', exportSelectedWishlist);
const downloadSelectedButton = document.createElement('button');
downloadSelectedButton.type = 'button';
downloadSelectedButton.textContent = '直接下载所选';
downloadSelectedButton.addEventListener('click', downloadSelectedItems);
const nonCommercialButton = document.createElement('button');
nonCommercialButton.type = 'button';
nonCommercialButton.textContent = `非商业下载: ${nonCommercialDownload ? '开' : '关'}`;
nonCommercialButton.title = '开启后 API 下载会附加 non_commercial=true';
nonCommercialButton.setAttribute('aria-pressed', String(nonCommercialDownload));
nonCommercialButton.addEventListener('click', () => {
nonCommercialDownload = !nonCommercialDownload;
writeBool(NON_COMMERCIAL_KEY, nonCommercialDownload);
renderPanel();
});
const importButton = document.createElement('button');
importButton.type = 'button';
importButton.textContent = '导入';
importButton.addEventListener('click', importWishlist);
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.textContent = '关闭';
closeButton.addEventListener('click', closePanel);
actions.append(importButton, exportSelectedButton, nonCommercialButton, downloadSelectedButton, clearButton, closeButton);
header.append(title, actions);
panel.append(header);
if (visibleTotal) {
panel.append(createFilterBar(characterItems, presetItems, activeItems, selectedCount));
}
if (!visibleTotal) {
const empty = document.createElement('div');
empty.className = 'rrw-empty';
empty.textContent = '还没有加入心愿单的角色卡或预设。';
panel.append(empty);
return;
}
if (!activeItems.length) {
const empty = document.createElement('div');
empty.className = 'rrw-empty';
empty.textContent = selectedTags.size
? '没有符合当前 tag 筛选的项目。'
: panelFilter === 'preset'
? '还没有加入心愿单的预设。'
: '还没有加入心愿单的角色卡。';
panel.append(empty);
return;
}
const list = document.createElement('div');
list.className = 'rrw-list';
activeItems.forEach((item) => {
list.append(createWishlistRow(item));
});
panel.append(list);
}
function createWishlistTab(filter, label) {
const button = document.createElement('button');
button.className = 'rrw-tab';
button.type = 'button';
button.textContent = label;
button.setAttribute('aria-pressed', String(panelFilter === filter));
button.addEventListener('click', () => {
panelFilter = filter;
renderPanel();
});
return button;
}
function createFilterBar(characterItems, presetItems, activeItems, selectedCount) {
const bar = document.createElement('div');
bar.className = 'rrw-filterbar';
const selectBar = document.createElement('div');
selectBar.className = 'rrw-selectbar';
const tabItems = panelFilter === 'preset' ? presetItems : characterItems;
const tabs = document.createElement('div');
tabs.className = 'rrw-tabs';
tabs.append(
createWishlistTab('character', `角色卡 (${characterItems.length})`),
createWishlistTab('preset', `预设 (${presetItems.length})`),
);
const allSelected = activeItems.length > 0 && activeItems.every((item) => selectedPaths.has(item.path));
const selectAllButton = document.createElement('button');
selectAllButton.type = 'button';
selectAllButton.textContent = allSelected ? '取消全选' : '全选当前';
selectAllButton.addEventListener('click', () => {
activeItems.forEach((item) => {
if (allSelected) selectedPaths.delete(item.path);
else selectedPaths.add(item.path);
});
renderPanel();
});
const clearSelectedButton = document.createElement('button');
clearSelectedButton.type = 'button';
clearSelectedButton.textContent = '清除选择';
clearSelectedButton.addEventListener('click', () => {
selectedPaths.clear();
renderPanel();
});
const tags = collectTags(tabItems);
const tagToggleButton = document.createElement('button');
tagToggleButton.type = 'button';
tagToggleButton.textContent = selectedTags.size ? `Tag (${selectedTags.size})` : 'Tag';
tagToggleButton.setAttribute('aria-expanded', String(tagExpanded));
tagToggleButton.setAttribute('aria-pressed', String(tagExpanded || selectedTags.size > 0));
tagToggleButton.addEventListener('click', () => {
tagExpanded = !tagExpanded;
renderPanel();
});
const count = document.createElement('span');
count.className = 'rrw-selected-count';
count.textContent = `已选 ${selectedCount}`;
selectBar.append(tabs, selectAllButton, clearSelectedButton, tagToggleButton, count);
bar.append(selectBar);
if (!tagExpanded || !tags.length) return bar;
const modes = document.createElement('div');
modes.className = 'rrw-tag-modes';
modes.append(createTagModeButton('any', '并集'), createTagModeButton('all', '交集'));
const clearTags = document.createElement('button');
clearTags.type = 'button';
clearTags.textContent = '清除 tag';
clearTags.addEventListener('click', () => {
selectedTags.clear();
renderPanel();
});
modes.append(clearTags);
const tagList = document.createElement('div');
tagList.className = 'rrw-tag-list';
tags.forEach((tag) => tagList.append(createTagButton(tag)));
bar.append(modes, tagList);
return bar;
}
function createTagModeButton(mode, label) {
const button = document.createElement('button');
button.className = 'rrw-tag-mode';
button.type = 'button';
button.textContent = label;
button.setAttribute('aria-pressed', String(tagFilterMode === mode));
button.addEventListener('click', () => {
tagFilterMode = mode;
renderPanel();
});
return button;
}
function createTagButton(tag) {
const button = document.createElement('button');
const key = tagKey(tag);
button.className = 'rrw-tag';
button.type = 'button';
button.textContent = tag;
button.setAttribute('aria-pressed', String(selectedTags.has(key)));
button.addEventListener('click', () => {
if (selectedTags.has(key)) selectedTags.delete(key);
else selectedTags.add(key);
renderPanel();
});
return button;
}
function collectTags(items) {
const tags = new Map();
items.forEach((item) => {
itemTags(item).forEach((tag) => {
const key = tagKey(tag);
if (!tags.has(key)) tags.set(key, tag);
});
});
return Array.from(tags.values()).sort((a, b) => a.localeCompare(b));
}
function matchesTagFilter(item) {
if (!selectedTags.size) return true;
const tags = new Set(itemTags(item).map(tagKey));
if (tagFilterMode === 'all') {
return Array.from(selectedTags).every((tag) => tags.has(tag));
}
return Array.from(selectedTags).some((tag) => tags.has(tag));
}
function itemTags(item) {
return normalizeTags(item && item.tags);
}
function getSelectedItems() {
return Object.values(state.items).filter((item) => selectedPaths.has(item.path));
}
function wishlistGroupKey(item) {
const type = String(item.type || typeFromPath(item.path || '')).toLowerCase();
if (type === 'character' || type === 'normal' || type === 'charx') return 'character';
if (type === 'preset') return 'preset';
if (type === 'module' || type === 'lorebook') return 'module';
return 'other';
}
function createWishlistRow(item) {
const row = document.createElement('a');
row.className = 'rrw-item';
row.href = item.url || new URL(item.path, location.origin).href;
const checkbox = document.createElement('input');
checkbox.className = 'rrw-item-check';
checkbox.type = 'checkbox';
checkbox.checked = selectedPaths.has(item.path);
checkbox.title = '选择';
checkbox.addEventListener('click', (event) => {
event.stopPropagation();
});
checkbox.addEventListener('change', (event) => {
event.preventDefault();
event.stopPropagation();
if (checkbox.checked) selectedPaths.add(item.path);
else selectedPaths.delete(item.path);
renderPanel();
});
const img = document.createElement('img');
img.alt = '';
img.loading = 'lazy';
img.src = item.image || '';
const main = document.createElement('div');
main.className = 'rrw-item-main';
const itemTitle = document.createElement('div');
itemTitle.className = 'rrw-item-title';
itemTitle.textContent = item.title || 'Untitled';
const meta = document.createElement('div');
meta.className = 'rrw-item-meta';
meta.textContent = item.author ? `By ${item.author}` : item.type || '';
const tags = document.createElement('div');
tags.className = 'rrw-item-tags';
tags.textContent = itemTags(item).join(' · ');
const remove = document.createElement('button');
remove.className = 'rrw-remove';
remove.type = 'button';
remove.textContent = '×';
remove.title = '移除';
remove.setAttribute('aria-label', `移除 ${item.title || item.path}`);
remove.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
const next = { ...state, items: { ...state.items } };
delete next.items[item.path];
selectedPaths.delete(item.path);
writeStore(next);
});
main.append(itemTitle, meta);
if (tags.textContent) main.append(tags);
row.append(checkbox, img, main, remove);
return row;
}
function exportWishlist(items, name) {
const exportItems = (items && items.length ? items : Object.values(state.items));
const payload = {
exportedAt: new Date().toISOString(),
source: 'RisuRealm Wishlist',
version: 1,
items: Object.fromEntries(exportItems.map((item) => [item.path, item])),
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8' });
const link = document.createElement('a');
const date = new Date().toISOString().slice(0, 10);
link.href = URL.createObjectURL(blob);
link.download = `${name || 'risurealm-wishlist'}-${date}.json`;
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
}
function exportSelectedWishlist() {
const items = getSelectedItems();
if (!items.length) {
alert('请先勾选要导出的项目。');
return;
}
exportWishlist(items, 'risurealm-wishlist-selected');
}
async function downloadSelectedItems() {
const items = getSelectedItems();
if (!items.length) {
alert('请先勾选要下载的项目。');
return;
}
if (items.length > 8 && !confirm(`将直接下载 ${items.length} 个文件,浏览器可能会请求允许多个下载。继续吗?`)) return;
const failed = [];
for (const item of items) {
const error = await downloadItem(item);
if (error) failed.push(`${item.title || item.path} - ${error}`);
}
if (failed.length) alert(`以下项目下载失败:\n${failed.slice(0, 10).join('\n')}${failed.length > 10 ? '\n...' : ''}`);
}
async function downloadItem(item) {
let lastError = '没有可用下载格式';
for (const candidate of downloadCandidates(item)) {
try {
const response = await fetch(candidate.url);
if (!response.ok) {
lastError = await downloadErrorMessage(response, candidate);
continue;
}
const blob = await response.blob();
triggerDownload(blob, candidate.name);
return '';
} catch (error) {
lastError = error && error.message ? error.message : '网络错误';
}
}
return lastError;
}
async function downloadErrorMessage(response, candidate) {
let message = '';
try {
const body = await response.clone().json();
message = body.message || body.error || '';
} catch (_) {
try {
message = (await response.text()).slice(0, 120).trim();
} catch (_) {
message = '';
}
}
return `${candidate.format || 'download'} ${response.status}${message ? ` ${message}` : ''}`;
}
function downloadCandidates(item) {
const id = idFromPath(item.path);
if (!id) return [];
const title = safeFileName(item.title || id);
const type = String(item.downloadType || item.type || typeFromPath(item.path)).toLowerCase();
const candidates = [];
const addFormat = (format, ext) => {
candidates.push({ format, name: `${title}.${ext}`, url: `/api/v1/download/${format}/${id}` });
};
if (item.downloadUrl) {
candidates.push({
format: downloadFormatFromUrl(item.downloadUrl),
name: item.downloadName || downloadNameFromUrl(title, item.downloadUrl),
url: item.downloadUrl,
});
}
if (type === 'charx') addFormat('charx-v3', 'charx');
if (type === 'charx' || type === 'normal' || type === 'character') {
addFormat('png-v3', 'png');
addFormat('json-v3', 'json');
addFormat('json-v2', 'json');
}
if (type === 'preset') {
addFormat('preset-risu-v1', 'risupreset');
addFormat('preset-st-chat', 'json');
}
if (type === 'module' || type === 'lorebook') {
addFormat('module-v1', 'json');
addFormat('lorebook-v3', 'json');
addFormat('lorebook-v2', 'json');
}
const seen = new Set();
return candidates.filter((candidate) => {
const url = new URL(candidate.url, location.origin).href;
const downloadUrl = withApiDownloadOptions(url);
if (seen.has(downloadUrl)) return false;
seen.add(downloadUrl);
candidate.url = downloadUrl;
return true;
});
}
function withApiDownloadOptions(url) {
const normalized = new URL(url, location.origin);
if (normalized.pathname.includes('/api/v1/download/')) {
normalized.searchParams.set('cors', 'true');
if (nonCommercialDownload) normalized.searchParams.set('non_commercial', 'true');
}
return normalized.href;
}
function downloadFormatFromUrl(url) {
const match = String(url || '').match(/\/api\/v1\/download\/([^/]+)\//);
return match ? match[1] : '';
}
function downloadNameFromUrl(title, url) {
const format = downloadFormatFromUrl(url);
if (format === 'charx-v3') return `${title}.charx`;
if (format.startsWith('png-')) return `${title}.png`;
if (format === 'preset-risu-v1') return `${title}.risupreset`;
if (format) return `${title}.json`;
return `${title}.download`;
}
function triggerDownload(blob, name) {
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = name;
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
}
function safeFileName(name) {
return String(name || 'risurealm-card').replace(/[\\/:*?"<>|]+/g, '_').slice(0, 120);
}
function importWishlist() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json,.json';
input.addEventListener('change', async () => {
const file = input.files && input.files[0];
input.remove();
if (!file) return;
try {
const imported = parseImportPayload(await file.text());
const count = Object.keys(imported.items).length;
if (!count) {
alert('没有找到可导入的心愿单项目。');
return;
}
const nextItems = { ...state.items, ...imported.items };
const addedCount = Object.keys(nextItems).length - Object.keys(state.items).length;
if (!confirm(`导入 ${count} 条心愿单项目?当前列表会合并,重复项目会以导入文件为准。`)) return;
writeStore({ version: 1, items: nextItems });
alert(`导入完成:新增 ${addedCount} 条,总计 ${Object.keys(nextItems).length} 条。`);
} catch (error) {
console.error('[RisuRealm Wishlist] Import failed', error);
alert('导入失败:请选择由 RisuRealm Wishlist 导出的 JSON 文件。');
}
});
document.body.append(input);
input.click();
}
function parseImportPayload(text) {
const parsed = JSON.parse(text);
const rawItems = Array.isArray(parsed)
? parsed
: parsed && typeof parsed === 'object'
? parsed.items
: null;
if (!rawItems || typeof rawItems !== 'object') {
throw new Error('Invalid wishlist payload');
}
const entries = Array.isArray(rawItems)
? rawItems.map((item) => [item && item.path, item])
: Object.entries(rawItems);
const items = {};
entries.forEach(([path, item]) => {
if (!item || typeof item !== 'object') return;
const normalizedPath = canonicalPath(item.path || path || item.url || '');
if (!DETAIL_PATH_RE.test(normalizedPath)) return;
items[normalizedPath] = {
addedAt: item.addedAt || new Date().toISOString(),
author: String(item.author || ''),
downloadName: String(item.downloadName || ''),
downloadType: String(item.downloadType || ''),
downloadUrl: String(item.downloadUrl || ''),
image: String(item.image || ''),
path: normalizedPath,
tags: normalizeTags(item.tags || []),
title: String(item.title || normalizedPath.split('/').pop() || 'Untitled'),
type: item.type || typeFromPath(normalizedPath),
url: new URL(normalizedPath, location.origin).href,
};
});
return { version: 1, items };
}
function refreshAll() {
document.querySelectorAll('a.rrw-card').forEach(updateCard);
renderDetailButton();
applyFilter();
updateToolbar();
renderPanel();
}
function applyFilter() {
document.documentElement.classList.toggle('rrw-filtering', filterEnabled && !currentDetailPath());
}
function scheduleScan() {
if (observerScheduled) return;
observerScheduled = true;
requestAnimationFrame(() => {
observerScheduled = false;
renderDetailButton();
scanCards();
});
}
function observePage() {
const observer = new MutationObserver(scheduleScan);
observer.observe(document.body, { childList: true, subtree: true });
}
function patchHistory() {
['pushState', 'replaceState'].forEach((method) => {
const original = history[method];
history[method] = function patchedHistoryMethod() {
const result = original.apply(this, arguments);
scheduleScan();
return result;
};
});
}
})();