草榴搜索/板块悬停放大封面、标题预览图、品质徽章与 qBittorrent 一键发送和下载按钮。
// ==UserScript==
// @name 草榴Manager
// @namespace http://tampermonkey.net/
// @version 1.8
// @description 草榴搜索/板块悬停放大封面、标题预览图、品质徽章与 qBittorrent 一键发送和下载按钮。
// @author truclocphung1713
// @match https://t66y.com/search.php*
// @match https://t66y.com/thread0806.php*
// @match https://t66y.com/htm_data/*
// @match http://t66y.com/search.php*
// @match http://t66y.com/thread0806.php*
// @match http://t66y.com/htm_data/*
// @icon none
// @run-at document-end
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @connect www.rmdown.com
// @connect *
// ==/UserScript==
(function () {
'use strict';
const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const downloadResolveCache = new Map();
const DOWNLOAD_RECORDS_KEY = '草榴ManagerDownloadedThreads';
let downloadRecordsCache = null;
const downloadStatusListeners = new Map();
/**
* 向页面注入 CSS
*/
function injectStyle(css) {
const style = document.createElement('style');
style.type = 'text/css';
style.textContent = css;
document.head.appendChild(style);
}
function fetchCrossOriginText(url) {
if (!url) {
return Promise.reject(new Error('無效的請求地址'));
}
if (typeof GM_xmlhttpRequest === 'function') {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
headers: {
'Referer': 'https://www.rmdown.com/'
},
onload: (resp) => {
if (resp.status >= 200 && resp.status < 400) {
resolve(resp.responseText);
} else {
reject(new Error('HTTP ' + resp.status));
}
},
onerror: () => reject(new Error('網絡錯誤')),
ontimeout: () => reject(new Error('請求超時'))
});
});
}
return fetch(url, { credentials: 'include' }).then(resp => {
if (!resp.ok) {
throw new Error('HTTP ' + resp.status);
}
return resp.text();
});
}
function fetchCrossOriginBinary(url, options = {}) {
if (!url) {
return Promise.reject(new Error('無效的請求地址'));
}
const headers = {
'Referer': options.referer || 'https://www.rmdown.com/',
...(options.headers || {})
};
const method = options.method || 'GET';
if (typeof GM_xmlhttpRequest === 'function') {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method,
url,
headers,
responseType: 'arraybuffer',
onload: (resp) => {
if (resp.status >= 200 && resp.status < 400) {
resolve({
buffer: resp.response,
headers: resp.responseHeaders || ''
});
} else {
reject(new Error('HTTP ' + resp.status));
}
},
onerror: () => reject(new Error('網絡錯誤')),
ontimeout: () => reject(new Error('請求超時'))
});
});
}
return fetch(url, {
method,
headers,
credentials: 'include'
}).then(async (resp) => {
if (!resp.ok) {
throw new Error('HTTP ' + resp.status);
}
const buffer = await resp.arrayBuffer();
return {
buffer,
headers: resp.headers
};
});
}
function extractFilenameFromContentDisposition(headerValue) {
if (!headerValue) return '';
let matched = headerValue.match(/filename\*=(?:UTF-8'')?([^;]+)/i);
if (matched && matched[1]) {
try {
return decodeURIComponent(matched[1].trim().replace(/["']/g, ''));
} catch (err) {
return matched[1].trim().replace(/["']/g, '');
}
}
matched = headerValue.match(/filename="([^"]+)"/i);
if (matched && matched[1]) {
return matched[1].trim();
}
matched = headerValue.match(/filename=([^;]+)/i);
if (matched && matched[1]) {
return matched[1].trim().replace(/["']/g, '');
}
return '';
}
function extractFilenameFromHeaders(headers) {
if (!headers) return '';
if (typeof headers === 'string') {
const lines = headers.split(/\r?\n/);
for (const line of lines) {
if (!line) continue;
if (line.toLowerCase().startsWith('content-disposition')) {
const value = line.split(':').slice(1).join(':').trim();
return extractFilenameFromContentDisposition(value);
}
}
return '';
}
if (typeof headers.get === 'function') {
const value = headers.get('content-disposition');
return extractFilenameFromContentDisposition(value || '');
}
return '';
}
function ensureArrayBuffer(data) {
if (!data) return null;
if (data instanceof ArrayBuffer) {
return data;
}
if (ArrayBuffer.isView(data)) {
return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
}
return null;
}
function gmCompatibleFetch(url, options = {}) {
const method = options.method || 'GET';
const headers = options.headers ? { ...options.headers } : {};
let body = options.body;
if (body instanceof URLSearchParams) {
if (!headers['Content-Type'] && !headers['content-type']) {
headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
}
body = body.toString();
}
if (typeof GM_xmlhttpRequest !== 'function') {
return fetch(url, {
...options,
method,
headers,
body
});
}
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method,
url,
headers,
data: body,
timeout: options.timeout || 20000,
anonymous: options.credentials === 'omit',
responseType: options.responseType || 'text',
onload: (resp) => {
const responseText = resp.responseText ?? '';
resolve({
ok: resp.status >= 200 && resp.status < 300,
status: resp.status,
statusText: resp.statusText || '',
headers: resp.responseHeaders || '',
text: () => Promise.resolve(responseText),
json: () => Promise.resolve(responseText ? JSON.parse(responseText) : null)
});
},
onerror: (resp) => {
reject(new Error(resp?.statusText || '網絡錯誤'));
},
ontimeout: () => reject(new Error('請求超時'))
});
});
}
const href = location.href;
const threadDataCache = new Map();
let currentListHoverCtx = null;
let gallerySourceHighlight = null;
function setCurrentListHover(ctx) {
currentListHoverCtx = ctx;
}
function clearGallerySourceHighlight() {
if (gallerySourceHighlight?.element && gallerySourceHighlight.className) {
try {
gallerySourceHighlight.element.classList.remove(gallerySourceHighlight.className);
} catch (err) {
// 元素可能已被移除,忽略錯誤
}
}
gallerySourceHighlight = null;
}
function applyGallerySourceHighlight(ctx) {
if (!ctx || !ctx.threadUrl) {
clearGallerySourceHighlight();
return;
}
let target = null;
let className = '';
if (ctx.source === 'board' && ctx.cover instanceof HTMLElement) {
target = ctx.cover;
className = 'clm-gallery-focus-cover';
} else if (ctx.source === 'search' && ctx.titleEl instanceof HTMLElement) {
target = ctx.titleEl;
className = 'clm-gallery-focus-title';
}
if (!target || !className) {
clearGallerySourceHighlight();
return;
}
clearGallerySourceHighlight();
target.classList.add(className);
gallerySourceHighlight = { element: target, className, threadUrl: ctx.threadUrl };
}
function focusGallerySource(threadUrl, ctxOverride = null) {
if (!threadUrl) {
clearGallerySourceHighlight();
return;
}
const normalizedTarget = normalizeThreadKey(threadUrl);
if (!normalizedTarget) {
clearGallerySourceHighlight();
return;
}
let candidate = ctxOverride;
if (!candidate || normalizeThreadKey(candidate.threadUrl) !== normalizedTarget) {
if (currentListHoverCtx && normalizeThreadKey(currentListHoverCtx.threadUrl) === normalizedTarget) {
candidate = currentListHoverCtx;
}
}
if (candidate) {
applyGallerySourceHighlight(candidate);
} else {
clearGallerySourceHighlight();
}
}
function getAbsoluteUrl(url, base = location.href) {
if (!url) return null;
try {
return new URL(url, base).href;
} catch (e) {
console.warn('clm 無法解析 URL', url, e);
return null;
}
}
function normalizeThreadKey(threadUrl) {
const abs = getAbsoluteUrl(threadUrl);
if (!abs) return null;
try {
const u = new URL(abs);
u.hash = '';
return u.href;
} catch (e) {
return abs;
}
}
const GALLERY_VISITED_STORAGE_KEY = '草榴ManagerGalleryVisited';
const MAX_GALLERY_VISITED_ENTRIES = 400;
let galleryVisitedCache = null;
function loadGalleryVisitedRecords() {
try {
const raw = localStorage.getItem(GALLERY_VISITED_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
return parsed;
}
}
} catch (err) {
console.warn('草榴Manager: 無法讀取畫廊歷史記錄', err);
}
return {};
}
function getGalleryVisitedRecords() {
if (!galleryVisitedCache) {
galleryVisitedCache = loadGalleryVisitedRecords();
}
return galleryVisitedCache;
}
function persistGalleryVisitedRecords() {
if (!galleryVisitedCache) {
galleryVisitedCache = {};
}
try {
localStorage.setItem(GALLERY_VISITED_STORAGE_KEY, JSON.stringify(galleryVisitedCache));
} catch (err) {
console.warn('草榴Manager: 無法保存畫廊歷史記錄', err);
}
}
function pruneGalleryVisitedRecords(records) {
const keys = Object.keys(records);
if (keys.length <= MAX_GALLERY_VISITED_ENTRIES) {
return [];
}
const sorted = keys.sort((a, b) => (records[b] || 0) - (records[a] || 0));
const removed = [];
for (let i = MAX_GALLERY_VISITED_ENTRIES; i < sorted.length; i++) {
const key = sorted[i];
removed.push(key);
delete records[key];
}
return removed;
}
function resolveThreadKey(keyOrUrl) {
if (!keyOrUrl) return null;
if (
keyOrUrl.startsWith('http://') ||
keyOrUrl.startsWith('https://') ||
keyOrUrl.startsWith('//') ||
keyOrUrl.startsWith('/')
) {
return normalizeThreadKey(keyOrUrl);
}
return keyOrUrl;
}
function hasGalleryVisitedThread(keyOrUrl) {
const threadKey = resolveThreadKey(keyOrUrl);
if (!threadKey) return false;
const records = getGalleryVisitedRecords();
return !!records[threadKey];
}
function applyVisitedStateToElement(el, visited) {
if (!el || !el.dataset) return;
const variant = el.dataset.clmGalleryVisitedVariant;
if (!variant) return;
if (variant === 'cover') {
el.classList.toggle('clm-gallery-visited-cover', !!visited);
} else if (variant === 'title') {
el.classList.toggle('clm-gallery-visited-title', !!visited);
}
}
function refreshGalleryVisitedStateForKey(threadKey) {
if (!threadKey) return;
const visited = hasGalleryVisitedThread(threadKey);
document.querySelectorAll('[data-clm-gallery-visited-variant]').forEach((el) => {
if (el.dataset.clmThreadKey === threadKey) {
applyVisitedStateToElement(el, visited);
}
});
}
function bindGalleryVisitedIndicator(element, threadUrl, variant) {
if (!element || !threadUrl) return null;
const threadKey = normalizeThreadKey(threadUrl);
if (!threadKey) return null;
element.dataset.clmThreadKey = threadKey;
if (variant) {
element.dataset.clmGalleryVisitedVariant = variant;
}
applyVisitedStateToElement(element, hasGalleryVisitedThread(threadKey));
return threadKey;
}
function markThreadGalleryVisited(threadUrl) {
const threadKey = normalizeThreadKey(threadUrl);
if (!threadKey) return;
const records = getGalleryVisitedRecords();
records[threadKey] = Date.now();
const removedKeys = pruneGalleryVisitedRecords(records);
persistGalleryVisitedRecords();
refreshGalleryVisitedStateForKey(threadKey);
removedKeys.forEach((key) => refreshGalleryVisitedStateForKey(key));
}
function getDownloadRecords() {
if (!downloadRecordsCache) {
downloadRecordsCache = loadDownloadRecordsFromStorage();
}
return downloadRecordsCache;
}
function loadDownloadRecordsFromStorage() {
try {
const raw = localStorage.getItem(DOWNLOAD_RECORDS_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
return parsed;
}
}
} catch (err) {
console.warn('草榴Manager: 無法讀取下載記錄', err);
}
return {};
}
function persistDownloadRecords() {
if (!downloadRecordsCache) {
downloadRecordsCache = {};
}
try {
localStorage.setItem(DOWNLOAD_RECORDS_KEY, JSON.stringify(downloadRecordsCache));
} catch (err) {
console.warn('草榴Manager: 無法保存下載記錄', err);
}
}
function hasDownloadedThread(threadUrl) {
const key = normalizeThreadKey(threadUrl);
if (!key) return false;
const records = getDownloadRecords();
return !!records[key];
}
function markThreadDownloaded(threadUrl) {
const key = normalizeThreadKey(threadUrl);
if (!key) return;
const records = getDownloadRecords();
records[key] = Date.now();
persistDownloadRecords();
notifyDownloadStatusChange(key);
}
function subscribeDownloadStatus(threadUrl, handler) {
const key = normalizeThreadKey(threadUrl);
if (!key || typeof handler !== 'function') {
return () => {};
}
if (!downloadStatusListeners.has(key)) {
downloadStatusListeners.set(key, new Set());
}
const listeners = downloadStatusListeners.get(key);
listeners.add(handler);
return () => {
listeners.delete(handler);
if (!listeners.size) {
downloadStatusListeners.delete(key);
}
};
}
function notifyDownloadStatusChange(threadKey) {
const listeners = downloadStatusListeners.get(threadKey);
if (!listeners) return;
listeners.forEach((fn) => {
try {
fn(true);
} catch (err) {
console.warn('草榴Manager: 下載狀態回調失敗', err);
}
});
}
const QB_LOG_STORAGE_KEY = '草榴ManagerQbLogs';
const MAX_QB_LOG_ENTRIES = 80;
let qbLogCache = null;
const qbLogSubscribers = new Set();
function loadQbLogsFromStorage() {
try {
const raw = localStorage.getItem(QB_LOG_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed;
}
}
} catch (err) {
console.warn('草榴Manager: 無法讀取日志', err);
}
return [];
}
function getQbLogs() {
if (!qbLogCache) {
qbLogCache = loadQbLogsFromStorage();
}
return qbLogCache;
}
function persistQbLogs() {
if (!qbLogCache) return;
try {
localStorage.setItem(QB_LOG_STORAGE_KEY, JSON.stringify(qbLogCache));
} catch (err) {
console.warn('草榴Manager: 無法保存日志', err);
}
}
function appendQbLog(message, level = 'info') {
const logs = getQbLogs();
logs.push({
id: Date.now() + '_' + Math.random().toString(16).slice(2),
time: Date.now(),
level,
message
});
while (logs.length > MAX_QB_LOG_ENTRIES) {
logs.shift();
}
persistQbLogs();
qbLogSubscribers.forEach((fn) => {
try {
fn(logs.slice());
} catch (err) {
console.warn('草榴Manager: 日志訂閱回調失敗', err);
}
});
}
function clearQbLogs() {
qbLogCache = [];
persistQbLogs();
qbLogSubscribers.forEach((fn) => {
try {
fn([]);
} catch (err) {
console.warn('草榴Manager: 日志訂閱回調失敗', err);
}
});
}
function subscribeQbLogs(handler) {
if (typeof handler !== 'function') {
return () => {};
}
qbLogSubscribers.add(handler);
return () => {
qbLogSubscribers.delete(handler);
};
}
function formatLogTime(ts) {
const date = new Date(ts);
const pad = (n) => (n < 10 ? '0' + n : '' + n);
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
let toastContainer = null;
let toastStyleInjected = false;
function ensureToastContainer() {
if (toastContainer) return toastContainer;
toastContainer = document.createElement('div');
toastContainer.className = 'clm-toast-container';
document.body.appendChild(toastContainer);
if (!toastStyleInjected) {
toastStyleInjected = true;
injectStyle(`
.clm-toast-container {
position: fixed;
right: 20px;
bottom: 20px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 999999 !important;
pointer-events: none;
}
.clm-toast {
min-width: 220px;
max-width: 320px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 10px 12px;
border-radius: 6px;
font-size: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
transform: translateY(20px);
opacity: 0;
transition: opacity 0.25s ease, transform 0.25s ease;
}
.clm-toast.clm-show {
opacity: 1;
transform: translateY(0);
}
.clm-toast.clm-success {
background: rgba(22, 163, 74, 0.95);
}
.clm-toast.clm-error {
background: rgba(220, 38, 38, 0.95);
}
.clm-toast.clm-warning {
background: rgba(234, 179, 8, 0.95);
color: #1f2937;
}
`);
}
return toastContainer;
}
function showToast(message, type = 'info', duration = 4000) {
const container = ensureToastContainer();
const toast = document.createElement('div');
toast.className = `clm-toast clm-${type}`;
toast.textContent = message;
container.appendChild(toast);
requestAnimationFrame(() => {
toast.classList.add('clm-show');
});
setTimeout(() => {
toast.classList.remove('clm-show');
setTimeout(() => {
toast.remove();
}, 250);
}, duration);
}
function summarizeResource(value, maxLength = 80) {
if (!value) return '未知資源';
const str = String(value).trim();
if (str.length <= maxLength) return str;
return str.slice(0, maxLength - 3) + '...';
}
function collectGalleryImages(threadContent, baseHref = location.href) {
if (!threadContent) return [];
const seen = new Set();
const gallery = [];
function pushItem(rawUrl, label) {
if (!rawUrl) return;
// 排除广告占位符和无效URL
if (rawUrl.includes('adblo_ck.jpg') || rawUrl.includes('http://a.d/')) return;
const abs = getAbsoluteUrl(rawUrl, baseHref);
if (!abs || seen.has(abs)) return;
seen.add(abs);
gallery.push({
url: abs,
label: label || ''
});
}
// 收集所有带有真实图片数据的img标签
// 优先查找在.tpc_content中的图片,排除广告区域
const contentArea = threadContent.querySelector('.tpc_content') || threadContent;
const allImages = contentArea.querySelectorAll('img[ess-data], img[iyl-data], img[data-src]');
allImages.forEach(img => {
// 优先使用ess-data,然后是data-src,最后是iyl-data
const imgUrl = img.getAttribute('ess-data') ||
img.getAttribute('data-src') ||
img.getAttribute('iyl-data');
if (imgUrl && !imgUrl.includes('adblo_ck.jpg') && !imgUrl.includes('http://a.d/')) {
const label = img.getAttribute('title') ||
img.getAttribute('alt') ||
(gallery.length === 0 ? '封面' : `圖片 ${gallery.length + 1}`);
pushItem(imgUrl, label);
}
});
// 如果没有找到任何图片,尝试查找封面图片(兼容旧逻辑)
if (gallery.length === 0) {
const coverImg = threadContent.querySelector('img[ess-data], img[iyl-data], img[data-src], img[src*="pb_"], img[src*="cover"]');
if (coverImg) {
const coverUrl = coverImg.getAttribute('ess-data') ||
coverImg.getAttribute('data-src') ||
coverImg.getAttribute('iyl-data') ||
coverImg.src;
if (coverUrl) {
pushItem(coverUrl, coverImg.getAttribute('title') || '封面');
}
}
}
// 收集.cl-gallery中的链接(兼容旧逻辑)
const galleryAnchors = threadContent.querySelectorAll('.cl-gallery a[href]');
galleryAnchors.forEach(anchor => {
const href = anchor.getAttribute('href');
if (!href) return;
const label = anchor.querySelector('img')?.getAttribute('title') || anchor.textContent.trim() || '預覽';
pushItem(href, label);
});
return gallery;
}
function extractCleanText(node) {
if (!node) return '';
const clone = node.cloneNode(true);
const removable = clone.querySelectorAll('script, style, iframe, video, audio');
removable.forEach(el => el.remove());
// 将 <br> 和 <br/> 标签转换为换行符
const brElements = clone.querySelectorAll('br');
brElements.forEach(br => {
const textNode = document.createTextNode('\n');
br.parentNode.replaceChild(textNode, br);
});
const text = clone.textContent
.replace(/\u00A0/g, ' ')
.replace(/\s+\n/g, '\n')
.replace(/\n{2,}/g, '\n')
.replace(/[ \t]{2,}/g, ' ')
.trim();
return text;
}
function extractPostUser(contentEl) {
if (!contentEl) return '';
// 方法1:向上查找包含 tpc_content 的最外层 th(评论内容所在的 th)
// 然后找到这个 th 所在行的第一个 th(用户名所在的 th)
let current = contentEl;
let outerTh = null;
// 向上查找,找到包含 tpc_content 的最外层 th
// 这个 th 应该包含一个 table 元素,并且这个 table 应该包含当前的 contentEl
while (current && current !== document.body) {
if (current.tagName === 'TH') {
const table = current.querySelector('table');
if (table && table.contains(contentEl)) {
outerTh = current;
break;
}
}
current = current.parentElement;
}
if (outerTh) {
// 找到这个 th 所在的 tr
const row = outerTh.closest('tr');
if (row) {
// 查找同一行的第一个 th(包含用户名)
const firstTh = row.querySelector('th:first-child');
if (firstTh && firstTh !== outerTh) {
// 在第一个 th 中查找 b 标签(用户名)
const bTag = firstTh.querySelector('b');
if (bTag) {
const text = bTag.textContent.trim();
if (text) {
return text;
}
}
}
}
}
// 方法2:直接查找包含 tpc_content 的 div.t.t2 或 .t2,然后找第一行的第一个 th
const tDiv = contentEl.closest('.t.t2, .t2');
if (tDiv) {
// 查找 tDiv 内的第一个 table(最外层的 table)
const table = tDiv.querySelector('> table, table');
if (table) {
// 查找 table 的第一行(tbody 内的第一行,或者直接的第一行)
const firstRow = table.querySelector('tbody > tr.tr1, tbody > tr:first-child, tr.tr1, tr:first-child');
if (firstRow) {
// 查找第一行的第一个 th(包含用户名)
const firstTh = firstRow.querySelector('th:first-child');
if (firstTh) {
// 在第一个 th 中查找 b 标签(用户名)
const bTag = firstTh.querySelector('b');
if (bTag) {
const text = bTag.textContent.trim();
if (text) {
return text;
}
}
}
}
}
}
// 方法3:查找包含 tpc_content 的表格,向上找到包含它的最外层 table
// 然后查找这个 table 的第一行第一个 th
let table = contentEl.closest('table');
if (table) {
// 继续向上查找,找到最外层的 table(包含评论的 table)
while (table && table.parentElement) {
const parentTable = table.parentElement.closest('table');
if (parentTable) {
table = parentTable;
} else {
break;
}
}
// 查找这个 table 的第一行第一个 th
const firstRow = table.querySelector('tbody > tr:first-child, tr:first-child');
if (firstRow) {
const firstTh = firstRow.querySelector('th:first-child');
if (firstTh) {
const bTag = firstTh.querySelector('b');
if (bTag) {
const text = bTag.textContent.trim();
if (text) {
return text;
}
}
}
}
}
// 最后备用方案:查找其他可能的位置
const td = contentEl.closest('td');
const row = td?.parentElement;
const candidateContainers = new Set();
if (row) {
const firstCell = row.querySelector('td:first-child, th:first-child');
if (firstCell && firstCell !== td) {
candidateContainers.add(firstCell);
}
if (row.previousElementSibling) {
candidateContainers.add(row.previousElementSibling);
}
candidateContainers.add(row);
}
candidateContainers.add(td?.previousElementSibling || null);
candidateContainers.add(contentEl.closest('.tpc'));
candidateContainers.add(contentEl.closest('table'));
const selectors = [
'.readName a',
'.readName',
'.tpc_info a',
'.tpc_info',
'.tipad a',
'.tipad b',
'.tal a',
'.tal b',
'.authi a',
'.authi',
'b'
];
for (const container of candidateContainers) {
if (!container) continue;
for (const sel of selectors) {
const target = container.querySelector(sel);
if (target) {
const text = target.textContent.trim();
if (text) {
return text;
}
}
}
if (container.dataset?.author) {
const author = container.dataset.author.trim();
if (author) return author;
}
}
return '';
}
function parseTitleTags(titleText) {
if (!titleText) return { quality: null, size: null, code: null, title: '' };
// 移除 HTML 标签和多余空格
const cleanTitle = titleText.replace(/<[^>]+>/g, '').trim();
// 匹配格式:允许前面有前缀,如 "新作 [HD/5.75G] BOKD-305 标题文本"
// 使用非贪婪匹配,确保能匹配到第一个 [ ] 对
const match = cleanTitle.match(/\[([^\]]+)\]\s*(.+)$/);
if (!match) {
// 如果没有匹配到 [ ] 格式,尝试直接匹配番号格式
// 例如:BOKD-303 标题文本
const codeMatch = cleanTitle.match(/^([A-Z0-9]+[-_][0-9]+)\s+(.+)$/i);
if (codeMatch) {
return {
quality: null,
size: null,
code: codeMatch[1].toUpperCase(),
title: codeMatch[2].trim()
};
}
return { quality: null, size: null, code: null, title: cleanTitle };
}
const bracketContent = match[1]; // HD/5.75G
const titlePart = match[2]; // BOKD-305 AVデビュー ボクこう見えてオチンチンついてます。 神戸まこ。
let quality = null;
let size = null;
let code = null;
let title = '';
// 解析括号内的内容:HD/5.75G
const bracketParts = bracketContent.split('/');
if (bracketParts.length >= 2) {
// 第一个部分:清晰度 (SD/HD/4K/VR)
const qualityPart = bracketParts[0].trim().toUpperCase();
if (['SD', 'HD', '4K', 'VR'].includes(qualityPart)) {
quality = qualityPart;
}
// 第二个部分:文件大小 (如 5.75G, 6.23G)
const sizePart = bracketParts[1].trim();
if (sizePart.match(/^[\d.]+[GMK]?B?$/i)) {
size = sizePart.toUpperCase();
}
} else if (bracketContent.trim()) {
// 如果只有一个部分,尝试判断是清晰度还是大小
const singlePart = bracketContent.trim().toUpperCase();
if (['SD', 'HD', '4K', 'VR'].includes(singlePart)) {
quality = singlePart;
} else if (singlePart.match(/^[\d.]+[GMK]?B?$/i)) {
size = singlePart;
}
}
// 解析标题部分:提取番号和片名
// 匹配番号格式:BOKD-305 或类似格式 (字母数字-数字,支持多种分隔符)
const codeMatch = titlePart.match(/^([A-Z0-9]+[-_][0-9]+)\s+(.+)$/i);
if (codeMatch) {
code = codeMatch[1].toUpperCase(); // BOKD-305
title = codeMatch[2].trim(); // AVデビュー ボクこう見えてオチンチンついてます。 神戸まこ。
} else {
// 如果没有番号,整个作为标题
title = titlePart.trim();
}
return { quality, size, code, title };
}
function collectThreadContext(doc) {
const contentBlocks = Array.from(doc.querySelectorAll('.tpc_content'));
if (!contentBlocks.length) {
return {
topic: null,
comments: [],
ads: []
};
}
// 收集所有 ftad-ct 元素
const allFtadElements = Array.from(doc.querySelectorAll('.ftad-ct'));
const ads = allFtadElements.map(el => el.outerHTML);
// 调试:输出收集到的广告数量
if (ads.length > 0) {
console.log('clm 收集到广告数量:', ads.length, ads);
}
// 获取标题(从 <td class="h"> 中获取)
let titleInfo = null;
let rawTitleText = null;
// 查找 <td class="h"> 元素
const hTd = doc.querySelector('td.h');
if (hTd) {
// 查找 <b>本頁主題:</b> 或 <b>本页主题:</b>
const themeLabel = hTd.querySelector('b');
if (themeLabel && (themeLabel.textContent.includes('本頁主題') || themeLabel.textContent.includes('本页主题'))) {
// 获取 <b> 标签后面的所有内容
let titleText = '';
// 方法1:尝试从整个 td 的 innerHTML 中提取(保留格式信息)
const fullHtml = hTd.innerHTML || '';
const htmlMatch = fullHtml.match(/本[頁页]主題[::]\s*<\/b>\s*(.+)/);
if (htmlMatch) {
// 提取 HTML 内容,然后转换为文本(保留格式如 [HD/5.87G])
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlMatch[1];
titleText = tempDiv.textContent || tempDiv.innerText || '';
}
// 方法2:如果方法1没获取到,从 <b> 标签的 nextSibling 开始,收集所有后续节点的文本
if (!titleText.trim()) {
let node = themeLabel.nextSibling;
while (node) {
if (node.nodeType === Node.TEXT_NODE) {
titleText += node.textContent;
} else if (node.nodeType === Node.ELEMENT_NODE) {
titleText += node.textContent;
}
node = node.nextSibling;
}
}
// 方法3:如果方法2没获取到,尝试从整个 td 中提取(使用正则)
if (!titleText.trim()) {
const fullText = hTd.textContent || hTd.innerText || '';
const match = fullText.match(/本[頁页]主題[::]\s*(.+)/);
if (match) {
titleText = match[1].trim();
}
}
// 方法4:如果还是没获取到,尝试获取整个 td 的文本,然后移除"本頁主題:"部分
if (!titleText.trim()) {
const fullText = hTd.textContent || hTd.innerText || '';
titleText = fullText.replace(/.*?本[頁页]主題[::]\s*/, '').trim();
}
if (titleText.trim()) {
rawTitleText = titleText.trim();
titleInfo = parseTitleTags(titleText);
// 如果解析后没有标题,使用原始标题
if (titleInfo && !titleInfo.title) {
titleInfo.title = rawTitleText;
}
}
}
}
// 如果从 td.h 没获取到,尝试从 f16 获取(作为后备方案)
if (!titleInfo && !rawTitleText) {
const firstContentBlock = contentBlocks[0];
if (firstContentBlock) {
// 尝试多种方式查找 f16 元素
let f16Element = null;
const postContainer = firstContentBlock.closest('.t.t2, .t2, .t');
if (postContainer) {
f16Element = postContainer.querySelector('h4.f16, .f16, h4[class*="f16"]');
}
// 如果没找到,尝试在整个文档中查找(作为后备方案)
if (!f16Element) {
f16Element = doc.querySelector('h4.f16, .f16, h4[class*="f16"]');
}
if (f16Element) {
const titleText = f16Element.textContent || f16Element.innerText || '';
if (titleText.trim()) {
rawTitleText = titleText.trim();
titleInfo = parseTitleTags(titleText);
// 如果解析后没有标题,使用原始标题
if (titleInfo && !titleInfo.title) {
titleInfo.title = rawTitleText;
}
}
}
}
}
const posts = contentBlocks.map((el, idx) => {
const user = extractPostUser(el) || (idx === 0 ? '樓主' : `回覆 ${idx}`);
const content = extractCleanText(el);
// 查找该帖子相关的 ftad-ct 元素(在同一个 .t.t2 容器内或附近)
const postContainer = el.closest('.t.t2, .t2, .t');
let postAds = [];
if (postContainer) {
// 查找同一容器内的 ftad-ct 元素
const containerFtads = postContainer.querySelectorAll('.ftad-ct');
postAds = Array.from(containerFtads).map(ftad => ftad.outerHTML);
}
// 查找该帖子对应的 tr.tr1.do_not_catch 中的 tips 元素
let postTips = [];
if (postContainer) {
// 方法1:查找同一容器内的 tr.tr1.do_not_catch 元素
let tr1DoNotCatch = postContainer.querySelector('tr.tr1.do_not_catch');
// 方法2:如果没找到,尝试查找包含当前 contentEl 的 table,然后找其 tr.tr1.do_not_catch
if (!tr1DoNotCatch) {
const contentTable = el.closest('table');
if (contentTable) {
// 向上查找最外层的 table(包含整个帖子的 table)
let outerTable = contentTable;
while (outerTable && outerTable.parentElement) {
const parentTable = outerTable.parentElement.closest('table');
if (parentTable) {
outerTable = parentTable;
} else {
break;
}
}
if (outerTable) {
tr1DoNotCatch = outerTable.querySelector('tr.tr1.do_not_catch');
}
}
}
if (tr1DoNotCatch) {
// 只查找 .tips 元素,排除 .tiptop 等其他包含 tip 的元素
const tipsElements = tr1DoNotCatch.querySelectorAll('.tips');
postTips = Array.from(tipsElements).map(tip => tip.outerHTML);
}
}
return {
user,
content: content.length > 600 ? `${content.slice(0, 600)}…` : content,
ads: postAds,
tips: postTips,
titleInfo: idx === 0 ? titleInfo : null,
rawTitle: idx === 0 ? rawTitleText : null
};
});
const [topic, ...rest] = posts;
const comments = rest.slice(0, 30);
return {
topic,
comments,
ads
};
}
function collectThreadAdBlocks(doc) {
if (!doc) return [];
// 收集所有 ftad-ct 元素
const ftadElements = Array.from(doc.querySelectorAll('.ftad-ct'));
return ftadElements.map(el => el.outerHTML);
}
const adScriptCache = new Map();
function decodeJsStringLiteral(input) {
if (!input) return '';
let output = '';
for (let i = 0; i < input.length; i++) {
const ch = input[i];
if (ch !== '\\') {
output += ch;
continue;
}
i += 1;
if (i >= input.length) {
break;
}
const next = input[i];
switch (next) {
case 'n':
output += '\n';
break;
case 'r':
output += '\r';
break;
case 't':
output += '\t';
break;
case 'b':
output += '\b';
break;
case 'f':
output += '\f';
break;
case 'v':
output += '\v';
break;
case '0':
output += '\0';
break;
case '\\':
output += '\\';
break;
case '"':
case '\'':
case '`':
output += next;
break;
case 'x': {
const hex = input.slice(i + 1, i + 3);
if (/^[0-9a-fA-F]{2}$/.test(hex)) {
output += String.fromCharCode(parseInt(hex, 16));
i += 2;
} else {
output += next;
}
break;
}
case 'u': {
if (input[i + 1] === '{') {
const endBrace = input.indexOf('}', i + 2);
if (endBrace !== -1) {
const codePointHex = input.slice(i + 2, endBrace);
if (/^[0-9a-fA-F]+$/.test(codePointHex)) {
output += String.fromCodePoint(parseInt(codePointHex, 16));
i = endBrace;
break;
}
}
}
const hex = input.slice(i + 1, i + 5);
if (/^[0-9a-fA-F]{4}$/.test(hex)) {
output += String.fromCharCode(parseInt(hex, 16));
i += 4;
} else {
output += next;
}
break;
}
default:
output += next;
break;
}
}
return output;
}
function extractSpjsonPayload(scriptText) {
if (!scriptText) return '';
const assignMatch = scriptText.match(/(?:var|let|const)?\s*(?:window\.)?\s*spJson\s*=\s*([\s\S]+?);/i);
if (!assignMatch || !assignMatch[1]) {
return '';
}
function resolveExpression(expr) {
const trimmed = expr.trim();
if (!trimmed) {
return '';
}
const literalMatch = trimmed.match(/^(['"`])([\s\S]*)\1$/);
if (literalMatch) {
return decodeJsStringLiteral(literalMatch[2]);
}
const decodeMatch = trimmed.match(/^decodeURIComponent\s*\(([\s\S]+)\)$/i);
if (decodeMatch) {
const inner = resolveExpression(decodeMatch[1]);
try {
return decodeURIComponent(inner);
} catch (err) {
return inner;
}
}
const jsonParseMatch = trimmed.match(/^JSON\.parse\s*\(([\s\S]+)\)$/i);
if (jsonParseMatch) {
const inner = resolveExpression(jsonParseMatch[1]);
try {
const parsed = JSON.parse(inner);
if (typeof parsed === 'string') {
return parsed;
}
return JSON.stringify(parsed);
} catch (err) {
return inner;
}
}
return '';
}
return resolveExpression(assignMatch[1]);
}
async function fetchAdScriptSource(scriptUrl) {
if (!scriptUrl) return '';
if (adScriptCache.has(scriptUrl)) {
return adScriptCache.get(scriptUrl);
}
const requester = (async () => {
// 直接使用 GM_xmlhttpRequest 避免 CORS 问题
if (typeof GM_xmlhttpRequest === 'function') {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: scriptUrl,
headers: {
'Referer': location.origin
},
onload: (resp) => {
if (resp.status >= 200 && resp.status < 400) {
resolve(resp.responseText);
} else {
reject(new Error('HTTP ' + resp.status));
}
},
onerror: () => reject(new Error('網絡錯誤')),
ontimeout: () => reject(new Error('請求超時'))
});
});
}
// 如果没有 GM_xmlhttpRequest,尝试使用 fetch(可能会遇到 CORS 问题)
try {
const resp = await fetch(scriptUrl, { credentials: 'include' });
if (!resp.ok) {
throw new Error('HTTP ' + resp.status);
}
return await resp.text();
} catch (err) {
throw err;
}
})();
requester.catch(() => adScriptCache.delete(scriptUrl));
adScriptCache.set(scriptUrl, requester);
return requester;
}
function createFtadGridElement(doc, adEntries) {
if (!doc || !adEntries || !adEntries.length) return null;
const container = doc.createElement('div');
container.className = 'ftad-ct';
const columns = adEntries.length > 10 ? Math.ceil(adEntries.length / 2) : 'auto-fit';
container.style.gridTemplateColumns = `repeat(${columns}, minmax(100px, 1fr))`;
const info = doc.createElement('div');
info.className = 'sptable_info';
info.setAttribute('onclick', "event.stopPropagation();window.open('/faq.php?faqjob=ads')");
info.textContent = ' AD';
container.appendChild(info);
adEntries.forEach((entry) => {
if (!entry || !entry.u) return;
const item = doc.createElement('div');
item.className = 'ftad-item';
const link = doc.createElement('a');
link.setAttribute('target', '_blank');
link.setAttribute('title', entry.c || '');
link.setAttribute('href', entry.u);
const title = entry.t ? entry.t.split('|')[1] || entry.t : '';
link.textContent = title;
item.appendChild(link);
container.appendChild(item);
});
return container;
}
function createSpinitTableElement(doc, leftEntry, rightEntry, startIndex = 0) {
if (!doc || !leftEntry) return null;
const table = doc.createElement('table');
table.setAttribute('cellspacing', '0');
table.setAttribute('cellpadding', '5');
table.setAttribute('width', '100%');
table.className = 'sptable_do_not_remove';
const tbody = doc.createElement('tbody');
const tr = doc.createElement('tr');
const makeCell = (entry, linkIndex, appendInfo = false) => {
if (!entry) return null;
const td = doc.createElement('td');
td.setAttribute('width', '50%');
td.setAttribute('valign', 'top');
td.setAttribute('onclick', `clurl('${entry.u}', ${linkIndex})`);
const titleWrapper = doc.createElement('div');
titleWrapper.id = 'ti';
const titleLink = doc.createElement('a');
const title = entry.t ? entry.t.split('|')[0] || entry.t : '';
titleLink.textContent = title;
titleWrapper.appendChild(titleLink);
td.appendChild(titleWrapper);
if (entry.c) {
td.appendChild(doc.createTextNode(entry.c));
}
td.appendChild(doc.createElement('br'));
const anchor = doc.createElement('a');
anchor.id = `srcf${linkIndex}`;
anchor.setAttribute('href', entry.u);
anchor.setAttribute('target', '_blank');
anchor.setAttribute('onclick', 'event.stopPropagation();');
anchor.textContent = entry.l || entry.u;
td.appendChild(anchor);
if (appendInfo) {
const info = doc.createElement('div');
info.className = 'sptable_info';
info.setAttribute('onclick', "event.stopPropagation();window.open('/faq.php?faqjob=ads')");
info.textContent = ' AD';
td.appendChild(info);
}
return td;
};
const leftCell = makeCell(leftEntry, startIndex, false);
const rightCell = rightEntry ? makeCell(rightEntry, startIndex + 1, true) : null;
if (leftCell) {
tr.appendChild(leftCell);
}
if (rightCell) {
tr.appendChild(rightCell);
}
tbody.appendChild(tr);
table.appendChild(tbody);
return table;
}
function hydrateThreadAdsFromData(doc, adEntries) {
if (!doc || !adEntries || !adEntries.length) return false;
let mutated = false;
const inlineScripts = Array.from(doc.querySelectorAll('script')).filter((script) => {
return !script.src && /spinit2?\s*\(\s*\)/.test(script.textContent || '');
});
if (!inlineScripts.length) {
return false;
}
const ftadScripts = inlineScripts.filter((script) => /\bspinit2\s*\(/.test(script.textContent || ''));
ftadScripts.forEach((script) => {
const grid = createFtadGridElement(doc, adEntries);
if (grid) {
script.insertAdjacentElement('afterend', grid);
mutated = true;
}
});
const pairQueue = adEntries.slice();
let linkIndex = 0;
inlineScripts.filter((script) => /\bspinit\s*\(/.test(script.textContent || '') && !/\bspinit2\s*\(/.test(script.textContent || '')).forEach((script) => {
const left = pairQueue.shift();
if (!left) {
return;
}
const right = pairQueue.shift() || null;
const table = createSpinitTableElement(doc, left, right, linkIndex);
if (table) {
script.insertAdjacentElement('afterend', table);
linkIndex += right ? 2 : 1;
mutated = true;
}
});
return mutated;
}
async function collectThreadAdsWithScriptFallback(doc, baseHref, rawHtml = '') {
const direct = collectThreadAdBlocks(doc);
// 检查是否已经找到了 ftad-ct
const hasFtadCt = direct.some(html => /\bftad-ct\b/i.test(html));
// 如果已经找到了 ftad-ct,直接返回
if (hasFtadCt || !doc) {
return direct;
}
const hydrateWithPayload = (payload) => {
if (!payload) {
return false;
}
try {
const adEntries = JSON.parse(payload);
if (!Array.isArray(adEntries) || !adEntries.length) {
return false;
}
return hydrateThreadAdsFromData(doc, adEntries);
} catch (err) {
console.warn('clm 解析 spJson 失敗', err);
return false;
}
};
const inlinePayload = rawHtml ? extractSpjsonPayload(rawHtml) : '';
if (inlinePayload && hydrateWithPayload(inlinePayload)) {
const afterHydrate = collectThreadAdBlocks(doc);
// 合并直接找到的广告和恢复的广告,去重
const combined = [...direct];
afterHydrate.forEach(html => {
if (!combined.some(existing => existing === html)) {
combined.push(html);
}
});
return combined;
}
const scriptNode = doc.querySelector('script[src*="post.js"]');
if (!scriptNode) {
return direct;
}
const scriptUrl = getAbsoluteUrl(scriptNode.getAttribute('src'), baseHref);
if (!scriptUrl) {
return direct;
}
try {
const scriptText = await fetchAdScriptSource(scriptUrl);
const payload = extractSpjsonPayload(scriptText);
if (!payload) {
return direct;
}
if (!hydrateWithPayload(payload)) {
return direct;
}
const afterHydrate = collectThreadAdBlocks(doc);
// 合并直接找到的广告和恢复的广告,去重
const combined = [...direct];
afterHydrate.forEach(html => {
if (!combined.some(existing => existing === html)) {
combined.push(html);
}
});
return combined;
} catch (err) {
console.warn('clm 無法恢復社區贊助內容', err);
return direct;
}
}
function extractThreadDownloadInfo(doc, baseHref = location.href) {
if (!doc) return null;
const candidate = doc.querySelector('#rmlink[href], a[href*="rmdown.com/link.php"]');
if (!candidate) return null;
const raw = candidate.getAttribute('href') || candidate.href;
const pageUrl = getAbsoluteUrl(raw, baseHref);
if (!pageUrl) return null;
return {
type: 'rmdown',
pageUrl
};
}
async function resolveThreadDownloadTarget(downloadInfo) {
if (!downloadInfo) {
throw new Error('沒有可用的下載資訊');
}
if (downloadInfo.type !== 'rmdown') {
throw new Error('未知的下載來源');
}
const cacheKey = downloadInfo.pageUrl;
if (downloadResolveCache.has(cacheKey)) {
return downloadResolveCache.get(cacheKey);
}
const resolver = (async () => {
const html = await fetchCrossOriginText(downloadInfo.pageUrl);
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const form = doc.querySelector('form#dl');
if (!form) {
throw new Error('無法在下載頁中找到目標表單');
}
const params = [];
Array.from(form.elements || []).forEach((el) => {
if (!el || !el.name) return;
const value = el.value || '';
if (!value) return;
params.push(encodeURIComponent(el.name) + '=' + encodeURIComponent(value));
});
if (!params.length) {
throw new Error('下載表單缺少必需參數');
}
const base = new URL('download.php', downloadInfo.pageUrl);
const separator = base.search ? '&' : '?';
const downloadUrl = base.origin + base.pathname + separator + params.join('&');
const { buffer, headers } = await fetchCrossOriginBinary(downloadUrl);
const torrentBinary = ensureArrayBuffer(buffer);
if (!torrentBinary || !torrentBinary.byteLength) {
throw new Error('無法獲取種子文件內容');
}
const filename = extractFilenameFromHeaders(headers) || ('rmdown_' + Date.now() + '.torrent');
return {
url: downloadUrl,
filename,
torrentBinary
};
})();
resolver.catch(() => {
downloadResolveCache.delete(cacheKey);
});
downloadResolveCache.set(cacheKey, resolver);
return resolver;
}
function createGalleryOverlay() {
injectStyle(`
.clm-gallery-overlay {
position: fixed;
inset: 0;
background: rgba(10, 10, 20, 0.82);
display: none;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 12px;
z-index: 100000;
padding: 32px clamp(16px, 4vw, 48px);
}
.clm-gallery-overlay.clm-active {
display: flex;
}
.clm-gallery-layout {
display: grid;
grid-template-columns: minmax(350px, 30vw) 1fr minmax(350px, 30vw);
gap: 20px;
width: min(98vw, 2400px);
height: calc(100vh - 160px);
max-height: calc(100vh - 160px);
align-items: stretch;
}
.clm-gallery-viewer-column {
display: flex;
flex-direction: column;
gap: 18px;
height: 100%;
min-height: 0;
}
.clm-gallery-panel {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 12px;
padding: 16px;
color: #fff;
backdrop-filter: blur(4px);
display: flex;
flex-direction: column;
gap: 12px;
max-height: 100%;
}
.clm-gallery-panel-topic {
max-width: 100%;
}
.clm-gallery-panel-comments {
max-width: 100%;
}
.clm-gallery-panel-header {
font-size: 14px;
font-weight: 600;
letter-spacing: 0.05em;
}
.clm-gallery-panel-body {
flex: 1;
overflow: auto;
font-size: 13px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.92);
}
.clm-gallery-panel-body::-webkit-scrollbar {
width: 6px;
}
.clm-gallery-panel-body::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.25);
border-radius: 3px;
}
.clm-panel-entry {
padding: 8px 10px;
background: rgba(0, 0, 0, 0.25);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
min-width: 0;
}
.clm-panel-entry-user {
font-size: 12px;
letter-spacing: 0.04em;
color: rgba(255, 255, 255, 0.75);
}
.clm-panel-entry-content {
font-size: 13px;
color: #fff;
white-space: pre-line;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
min-width: 0;
}
.clm-panel-entry-content .clm-type-tag {
display: inline-block;
padding: 2px 8px;
margin: 2px 4px 2px 0;
border-radius: 4px;
font-size: 11px;
line-height: 1.4;
}
/* 类型标签使用不同颜色 - 基于类名,至少10种颜色循环使用 */
.clm-panel-entry-content .clm-type-tag.clm-type-tag-color-1 {
background: rgba(59, 130, 246, 0.2) !important;
border: 1px solid rgba(59, 130, 246, 0.4) !important;
color: rgba(147, 197, 253, 0.95) !important;
}
.clm-panel-entry-content .clm-type-tag.clm-type-tag-color-2 {
background: rgba(34, 197, 94, 0.2) !important;
border: 1px solid rgba(34, 197, 94, 0.4) !important;
color: rgba(134, 239, 172, 0.95) !important;
}
.clm-panel-entry-content .clm-type-tag.clm-type-tag-color-3 {
background: rgba(249, 115, 22, 0.2) !important;
border: 1px solid rgba(249, 115, 22, 0.4) !important;
color: rgba(254, 215, 170, 0.95) !important;
}
.clm-panel-entry-content .clm-type-tag.clm-type-tag-color-4 {
background: rgba(168, 85, 247, 0.2) !important;
border: 1px solid rgba(168, 85, 247, 0.4) !important;
color: rgba(221, 214, 254, 0.95) !important;
}
.clm-panel-entry-content .clm-type-tag.clm-type-tag-color-5 {
background: rgba(236, 72, 153, 0.2) !important;
border: 1px solid rgba(236, 72, 153, 0.4) !important;
color: rgba(251, 207, 232, 0.95) !important;
}
.clm-panel-entry-content .clm-type-tag.clm-type-tag-color-6 {
background: rgba(6, 182, 212, 0.2) !important;
border: 1px solid rgba(6, 182, 212, 0.4) !important;
color: rgba(165, 243, 252, 0.95) !important;
}
.clm-panel-entry-content .clm-type-tag.clm-type-tag-color-7 {
background: rgba(234, 179, 8, 0.2) !important;
border: 1px solid rgba(234, 179, 8, 0.4) !important;
color: rgba(253, 224, 71, 0.95) !important;
}
.clm-panel-entry-content .clm-type-tag.clm-type-tag-color-8 {
background: rgba(239, 68, 68, 0.2) !important;
border: 1px solid rgba(239, 68, 68, 0.4) !important;
color: rgba(254, 202, 202, 0.95) !important;
}
.clm-panel-entry-content .clm-type-tag.clm-type-tag-color-9 {
background: rgba(99, 102, 241, 0.2) !important;
border: 1px solid rgba(99, 102, 241, 0.4) !important;
color: rgba(196, 181, 253, 0.95) !important;
}
.clm-panel-entry-content .clm-type-tag.clm-type-tag-color-10 {
background: rgba(20, 184, 166, 0.2) !important;
border: 1px solid rgba(20, 184, 166, 0.4) !important;
color: rgba(153, 246, 228, 0.95) !important;
}
/* 出演者标签样式 - 复用类型标签的颜色方案 */
.clm-panel-entry-content .clm-performer-tag {
display: inline-block;
padding: 2px 8px;
margin: 2px 4px 2px 0;
border-radius: 4px;
font-size: 11px;
line-height: 1.4;
cursor: pointer;
user-select: none;
}
.clm-panel-entry-content .clm-performer-tag.clm-performer-tag-color-1 {
background: rgba(59, 130, 246, 0.2) !important;
border: 1px solid rgba(59, 130, 246, 0.4) !important;
color: rgba(147, 197, 253, 0.95) !important;
}
.clm-panel-entry-content .clm-performer-tag.clm-performer-tag-color-2 {
background: rgba(34, 197, 94, 0.2) !important;
border: 1px solid rgba(34, 197, 94, 0.4) !important;
color: rgba(134, 239, 172, 0.95) !important;
}
.clm-panel-entry-content .clm-performer-tag.clm-performer-tag-color-3 {
background: rgba(249, 115, 22, 0.2) !important;
border: 1px solid rgba(249, 115, 22, 0.4) !important;
color: rgba(254, 215, 170, 0.95) !important;
}
.clm-panel-entry-content .clm-performer-tag.clm-performer-tag-color-4 {
background: rgba(168, 85, 247, 0.2) !important;
border: 1px solid rgba(168, 85, 247, 0.4) !important;
color: rgba(221, 214, 254, 0.95) !important;
}
.clm-panel-entry-content .clm-performer-tag.clm-performer-tag-color-5 {
background: rgba(236, 72, 153, 0.2) !important;
border: 1px solid rgba(236, 72, 153, 0.4) !important;
color: rgba(251, 207, 232, 0.95) !important;
}
.clm-panel-entry-content .clm-performer-tag.clm-performer-tag-color-6 {
background: rgba(6, 182, 212, 0.2) !important;
border: 1px solid rgba(6, 182, 212, 0.4) !important;
color: rgba(165, 243, 252, 0.95) !important;
}
.clm-panel-entry-content .clm-performer-tag.clm-performer-tag-color-7 {
background: rgba(234, 179, 8, 0.2) !important;
border: 1px solid rgba(234, 179, 8, 0.4) !important;
color: rgba(253, 224, 71, 0.95) !important;
}
.clm-panel-entry-content .clm-performer-tag.clm-performer-tag-color-8 {
background: rgba(239, 68, 68, 0.2) !important;
border: 1px solid rgba(239, 68, 68, 0.4) !important;
color: rgba(254, 202, 202, 0.95) !important;
}
.clm-panel-entry-content .clm-performer-tag.clm-performer-tag-color-9 {
background: rgba(99, 102, 241, 0.2) !important;
border: 1px solid rgba(99, 102, 241, 0.4) !important;
color: rgba(196, 181, 253, 0.95) !important;
}
.clm-panel-entry-content .clm-performer-tag.clm-performer-tag-color-10 {
background: rgba(20, 184, 166, 0.2) !important;
border: 1px solid rgba(20, 184, 166, 0.4) !important;
color: rgba(153, 246, 228, 0.95) !important;
}
.clm-panel-entry-content .clm-performer-tag:hover {
opacity: 0.8;
}
.clm-panel-entry-title {
font-size: 16px;
font-weight: 600;
color: #fff;
margin-bottom: 12px;
line-height: 1.5;
}
.clm-panel-entry-title-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.clm-panel-entry-title-tag {
display: inline-block;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
}
/* 清晰度标签 - 绿色 */
.clm-title-tag-quality {
background: rgba(34, 197, 94, 0.25);
border: 1px solid rgba(34, 197, 94, 0.5);
color: rgba(134, 239, 172, 1);
}
/* 文件大小标签 - 橙色 */
.clm-title-tag-size {
background: rgba(249, 115, 22, 0.25);
border: 1px solid rgba(249, 115, 22, 0.5);
color: rgba(254, 215, 170, 1);
}
/* 番号标签 - 紫色,嵌套结构 */
.clm-title-tag-code {
background: rgba(168, 85, 247, 0.25);
border: 1px solid rgba(168, 85, 247, 0.5);
color: rgba(221, 214, 254, 1);
}
/* 番号标签前缀部分 - 更深的紫色背景 */
.clm-title-tag-code-prefix {
display: inline-block;
background: rgba(168, 85, 247, 0.5);
padding: 4px 6px;
margin: -4px 4px -4px -10px;
border-radius: 6px 0 0 6px;
font-weight: 600;
cursor: pointer;
}
.clm-title-tag-code-prefix:hover {
background: rgba(168, 85, 247, 0.7);
}
/* 番号标签后缀部分 - 可点击,搜索完整番号 */
.clm-title-tag-code-suffix {
display: inline-block;
padding: 4px 6px;
margin: -4px -10px -4px 4px;
border-radius: 0 6px 6px 0;
transition: background 0.2s ease;
}
.clm-title-tag-code-suffix:hover {
background: rgba(168, 85, 247, 0.4);
}
/* 搜索弹窗样式 */
.clm-search-dialog-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 100003;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.clm-search-dialog {
background: #fff;
border-radius: 8px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
width: min(600px, 90vw);
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.clm-search-dialog-header {
padding: 14px 18px;
border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
display: flex;
justify-content: space-between;
align-items: center;
}
.clm-search-dialog-title {
font-weight: 600;
font-size: 15px;
color: #111827;
}
.clm-search-dialog-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #6b7280;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.clm-search-dialog-close:hover {
background: #e5e7eb;
color: #111827;
}
.clm-search-dialog-body {
padding: 18px;
overflow-y: auto;
flex: 1;
}
.clm-search-form-row {
margin-bottom: 16px;
}
.clm-search-form-row:last-child {
margin-bottom: 0;
}
.clm-search-form-label {
display: block;
font-weight: 600;
font-size: 13px;
color: #374151;
margin-bottom: 6px;
}
.clm-search-form-label.required::after {
content: ' *';
color: #dc2626;
}
.clm-search-form-select,
.clm-search-form-input {
width: 100%;
padding: 6px 10px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 13px;
box-sizing: border-box;
}
.clm-search-form-select:focus,
.clm-search-form-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.clm-search-form-radio-group {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.clm-search-form-radio {
display: flex;
align-items: center;
gap: 6px;
}
.clm-search-form-radio input[type="radio"] {
margin: 0;
}
.clm-search-form-checkbox {
display: flex;
align-items: center;
gap: 6px;
}
.clm-search-form-checkbox input[type="checkbox"] {
margin: 0;
}
.clm-search-dialog-footer {
padding: 12px 18px;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.clm-search-btn {
padding: 8px 20px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: none;
transition: background 0.2s;
}
.clm-search-btn-primary {
background: #3b82f6;
color: #fff;
}
.clm-search-btn-primary:hover {
background: #2563eb;
}
.clm-search-btn-secondary {
background: #e5e7eb;
color: #374151;
}
.clm-search-btn-secondary:hover {
background: #d1d5db;
}
.clm-type-tag {
cursor: pointer;
}
.clm-type-tag:hover {
opacity: 0.8;
}
.clm-panel-entry-title-text {
font-size: 16px;
color: #fff;
line-height: 1.5;
}
.clm-panel-entry-tips {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
color: #333;
max-width: 100%;
overflow: hidden;
position: relative;
}
.clm-panel-entry-tips > div {
color: #333;
max-width: 100%;
overflow: hidden;
}
.clm-panel-entry-tips-scale-wrapper {
width: 100%;
overflow: hidden;
transform-origin: top left;
}
.clm-panel-entry-tips table {
width: 100%;
max-width: 100%;
color: #333;
background: #fff;
table-layout: auto;
word-break: break-word;
}
.clm-panel-entry-tips table td,
.clm-panel-entry-tips table th {
color: #333;
word-break: break-word;
overflow-wrap: break-word;
}
.clm-panel-entry-tips a {
color: #0b5ed7;
word-break: break-all;
}
.clm-panel-empty {
padding: 12px;
text-align: center;
color: rgba(255, 255, 255, 0.6);
font-size: 13px;
}
.clm-gallery-viewer {
position: relative;
max-width: 100%;
width: 100%;
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
border-radius: 12px;
background: rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.clm-gallery-viewer img {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
background: #000;
transition: opacity 0.2s ease;
transform-origin: center center;
cursor: zoom-in;
}
.clm-gallery-viewer img.clm-zoomed {
cursor: move;
}
.clm-gallery-ads-slot {
display: flex;
flex-direction: column;
gap: 8px;
}
.clm-gallery-ads-slot-viewer-top,
.clm-gallery-ads-slot-viewer-bottom {
width: 100%;
}
.clm-gallery-ads-title {
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.68);
margin-bottom: 4px;
}
.clm-gallery-ads {
background: rgba(255, 255, 255, 0.96);
color: #111;
border-radius: 10px;
padding: 12px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 16px 32px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.clm-gallery-ads .ftad-ct {
display: grid !important;
gap: 8px;
width: 100%;
margin: 0;
padding: 0;
}
.clm-gallery-ads .ftad-item {
padding: 8px;
background: rgba(0, 0, 0, 0.03);
border-radius: 6px;
transition: background 0.2s ease;
}
.clm-gallery-ads .ftad-item:hover {
background: rgba(0, 0, 0, 0.08);
}
.clm-gallery-ads .ftad-item a {
color: #0b5ed7;
text-decoration: none;
word-break: break-all;
font-size: 13px;
}
.clm-gallery-ads .ftad-item a:hover {
text-decoration: underline;
}
.clm-gallery-ads .sptable_info {
display: none;
}
.clm-viewer-loading img {
opacity: 0;
}
.clm-gallery-loading-indicator {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
letter-spacing: 0.08em;
background: linear-gradient(135deg, rgba(0,0,0,0.35), rgba(0,0,0,0.15));
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease;
pointer-events: none;
}
.clm-viewer-loading .clm-gallery-loading-indicator {
opacity: 1;
visibility: visible;
}
.clm-gallery-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.55);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 50%;
width: 44px;
height: 44px;
font-size: 22px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
}
.clm-gallery-arrow:hover {
background: rgba(0, 0, 0, 0.85);
}
.clm-gallery-arrow-left {
left: -22px;
}
.clm-gallery-arrow-right {
right: -22px;
}
.clm-gallery-close {
position: absolute;
top: 16px;
right: 24px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
border: none;
font-size: 26px;
cursor: pointer;
padding: 4px 10px;
border-radius: 6px;
}
.clm-gallery-meta {
color: #f5f5f5;
font-size: 14px;
text-align: center;
}
.clm-gallery-hint {
font-size: 12px;
color: rgba(255, 255, 255, 0.65);
}
.clm-gallery-actions {
width: min(98vw, 1600px);
display: flex;
justify-content: center;
gap: 12px;
position: relative;
z-index: 100010;
}
.clm-gallery-download-preview {
position: fixed;
inset: clamp(24px, 6vw, 64px);
background: #fff;
border-radius: 14px;
box-shadow: 0 30px 60px rgba(0, 0, 0, 0.35);
display: none;
flex-direction: column;
z-index: 100010;
pointer-events: auto;
overflow: hidden;
}
.clm-gallery-download-preview.clm-active {
display: flex;
}
.clm-gallery-download-preview-subtitle {
font-size: 12px;
color: #6b7280;
margin-top: 2px;
}
.clm-gallery-download-preview-subtitle:empty {
display: none;
}
.clm-gallery-download-preview-header {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
background: linear-gradient(90deg, #f7f8fb, #ffffff);
}
.clm-gallery-download-preview-title {
font-weight: 600;
font-size: 14px;
color: #0e0f1a;
}
.clm-gallery-download-preview-link {
margin-left: auto;
font-size: 12px;
color: #0b5ed7;
text-decoration: none;
}
.clm-gallery-download-preview-close {
border: none;
background: #0d1117;
color: #fff;
font-size: 12px;
border-radius: 20px;
padding: 6px 14px;
cursor: pointer;
}
.clm-gallery-download-preview-close:hover {
background: #272c34;
}
.clm-gallery-download-preview-frame {
flex: 1;
border: none;
width: 100%;
min-height: 60vh;
background: #fff;
}
.clm-gallery-download-preview-footer {
padding: 10px 18px;
font-size: 12px;
color: rgba(0, 0, 0, 0.6);
border-top: 1px solid rgba(0, 0, 0, 0.05);
background: #fafafa;
}
.clm-download-window-mask {
position: fixed;
inset: 0;
background: rgba(8, 8, 12, 0.65);
display: none;
align-items: center;
justify-content: center;
z-index: 100120;
padding: clamp(16px, 4vw, 48px);
transition: opacity 0.2s ease, visibility 0.2s ease;
}
.clm-download-window-mask.clm-active {
display: flex;
}
.clm-download-window-status {
font-size: 12px;
color: rgba(0, 0, 0, 0.65);
line-height: 1.5;
}
.clm-download-window-status[data-variant="success"] {
color: #059669;
}
.clm-download-window-status[data-variant="error"] {
color: #b91c1c;
}
.clm-gallery-download-btn {
padding: 10px 20px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.35);
background: rgba(0, 0, 0, 0.45);
color: #fff;
font-size: 13px;
letter-spacing: 0.08em;
cursor: pointer;
transition: background 0.2s ease, opacity 0.2s ease;
min-width: 220px;
}
.clm-gallery-download-btn:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.75);
}
.clm-gallery-download-btn.clm-downloaded {
border-color: rgba(16, 185, 129, 0.7);
background: rgba(16, 185, 129, 0.22);
color: #d1fae5;
}
.clm-gallery-download-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.clm-preset-picker-mask {
position: fixed;
inset: 0;
background: transparent;
z-index: 100120;
display: flex;
align-items: flex-end;
justify-content: flex-end;
}
.clm-preset-picker {
width: min(360px, 90vw);
max-height: 80vh;
background: #fff;
border-radius: 12px;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
overflow: hidden;
margin: 20px;
}
.clm-preset-picker-title {
padding: 14px 18px;
font-weight: 600;
border-bottom: 1px solid #eee;
}
.clm-preset-picker-list {
padding: 12px 18px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.clm-preset-picker-option {
border: 1px solid #d0d0d0;
border-radius: 8px;
padding: 10px 12px;
text-align: left;
background: #fdfdfd;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 4px;
transition: border-color 0.2s ease, background 0.2s ease;
}
.clm-preset-picker-option:hover {
background: #f3f6ff;
border-color: #8da9ff;
}
.clm-preset-picker-option strong {
font-size: 13px;
}
.clm-preset-picker-option span {
font-size: 12px;
color: #555;
word-break: break-all;
}
.clm-preset-picker-cancel {
border: none;
border-top: 1px solid #eee;
background: #fafafa;
padding: 10px 0;
cursor: pointer;
font-size: 13px;
}
.clm-preset-picker-cancel:hover {
background: #f0f0f0;
}
@media (max-width: 1800px) {
.clm-gallery-overlay {
transform: scale(0.9);
transform-origin: center center;
}
}
@media (max-width: 1600px) {
.clm-gallery-overlay {
transform: scale(0.85);
transform-origin: center center;
}
.clm-gallery-layout {
grid-template-columns: minmax(320px, 16.67vw) 1fr minmax(320px, 16.67vw);
width: min(98vw, 2200px);
}
}
@media (max-width: 1400px) {
.clm-gallery-overlay {
transform: scale(0.8);
transform-origin: center center;
}
.clm-gallery-layout {
grid-template-columns: minmax(300px, 16.67vw) 1fr minmax(300px, 16.67vw);
width: min(98vw, 2000px);
}
}
@media (max-width: 1200px) {
.clm-gallery-overlay {
transform: scale(0.75);
transform-origin: center center;
}
.clm-gallery-layout {
grid-template-columns: minmax(280px, 16.67vw) 1fr minmax(280px, 16.67vw);
width: min(98vw, 1800px);
}
}
@media (max-width: 1024px) {
.clm-gallery-overlay {
transform: scale(0.7);
transform-origin: center center;
}
.clm-gallery-layout {
grid-template-columns: 1fr;
}
.clm-gallery-download-preview {
inset: clamp(14px, 4vw, 32px);
}
}
`);
const overlay = document.createElement('div');
overlay.className = 'clm-gallery-overlay';
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'clm-gallery-close';
closeBtn.textContent = '✕';
const layout = document.createElement('div');
layout.className = 'clm-gallery-layout';
const topicPanel = document.createElement('section');
topicPanel.className = 'clm-gallery-panel clm-gallery-panel-topic';
const topicHeader = document.createElement('div');
topicHeader.className = 'clm-gallery-panel-header';
topicHeader.textContent = '主題內容';
const topicBody = document.createElement('div');
topicBody.className = 'clm-gallery-panel-body';
topicPanel.appendChild(topicHeader);
topicPanel.appendChild(topicBody);
const viewer = document.createElement('div');
viewer.className = 'clm-gallery-viewer';
const viewerColumn = document.createElement('div');
viewerColumn.className = 'clm-gallery-viewer-column';
// 创建viewer上方的广告显示区域
const viewerAdsTop = document.createElement('div');
viewerAdsTop.className = 'clm-gallery-ads-slot clm-gallery-ads-slot-viewer-top';
viewerAdsTop.style.display = 'none';
const viewerAdsTopTitle = document.createElement('div');
viewerAdsTopTitle.className = 'clm-gallery-ads-title';
viewerAdsTopTitle.textContent = '帖子內 AD';
const viewerAdsTopContainer = document.createElement('div');
viewerAdsTopContainer.className = 'clm-gallery-ads';
viewerAdsTop.appendChild(viewerAdsTopTitle);
viewerAdsTop.appendChild(viewerAdsTopContainer);
const leftBtn = document.createElement('button');
leftBtn.type = 'button';
leftBtn.className = 'clm-gallery-arrow clm-gallery-arrow-left';
leftBtn.textContent = '‹';
const rightBtn = document.createElement('button');
rightBtn.type = 'button';
rightBtn.className = 'clm-gallery-arrow clm-gallery-arrow-right';
rightBtn.textContent = '›';
const viewerImg = document.createElement('img');
viewerImg.alt = '';
const loadingIndicator = document.createElement('div');
loadingIndicator.className = 'clm-gallery-loading-indicator';
loadingIndicator.textContent = '正在載入…';
const galleryQualityBadge = document.createElement('div');
galleryQualityBadge.className = 'clm-quality-badge clm-gallery-quality';
galleryQualityBadge.style.display = 'none';
viewer.appendChild(leftBtn);
viewer.appendChild(viewerImg);
viewer.appendChild(rightBtn);
viewer.appendChild(loadingIndicator);
viewer.appendChild(galleryQualityBadge);
// 创建viewer下方的广告显示区域
const viewerAdsBottom = document.createElement('div');
viewerAdsBottom.className = 'clm-gallery-ads-slot clm-gallery-ads-slot-viewer-bottom';
viewerAdsBottom.style.display = 'none';
const viewerAdsBottomTitle = document.createElement('div');
viewerAdsBottomTitle.className = 'clm-gallery-ads-title';
viewerAdsBottomTitle.textContent = '帖子內 AD';
const viewerAdsBottomContainer = document.createElement('div');
viewerAdsBottomContainer.className = 'clm-gallery-ads';
viewerAdsBottom.appendChild(viewerAdsBottomTitle);
viewerAdsBottom.appendChild(viewerAdsBottomContainer);
viewerColumn.appendChild(viewerAdsTop);
viewerColumn.appendChild(viewer);
viewerColumn.appendChild(viewerAdsBottom);
const commentsPanel = document.createElement('section');
commentsPanel.className = 'clm-gallery-panel clm-gallery-panel-comments';
const commentsHeader = document.createElement('div');
commentsHeader.className = 'clm-gallery-panel-header';
commentsHeader.textContent = '評論內容';
const commentsBody = document.createElement('div');
commentsBody.className = 'clm-gallery-panel-body';
commentsPanel.appendChild(commentsHeader);
commentsPanel.appendChild(commentsBody);
layout.appendChild(topicPanel);
layout.appendChild(viewerColumn);
layout.appendChild(commentsPanel);
const meta = document.createElement('div');
meta.className = 'clm-gallery-meta';
const actions = document.createElement('div');
actions.className = 'clm-gallery-actions';
const downloadBtn = document.createElement('button');
downloadBtn.type = 'button';
downloadBtn.className = 'clm-gallery-download-btn';
downloadBtn.textContent = '打開下載頁面';
downloadBtn.disabled = true;
actions.appendChild(downloadBtn);
const hint = document.createElement('div');
hint.className = 'clm-gallery-hint';
hint.textContent = '← → 切換 · Esc 關閉';
overlay.appendChild(closeBtn);
overlay.appendChild(layout);
overlay.appendChild(meta);
overlay.appendChild(actions);
overlay.appendChild(hint);
document.body.appendChild(overlay);
let items = [];
let currentIndex = 0;
let currentImageToken = 0;
let errorIndicatorTimer = null;
let currentDownloadInfo = null;
let currentThreadKey = null;
let currentQualityTag = null;
let galleryDownloadUnsubscribe = null;
let imageScale = 1;
let imageTranslateX = 0;
let imageTranslateY = 0;
let isDragging = false;
let dragStartX = 0;
let dragStartY = 0;
let dragStartTranslateX = 0;
let dragStartTranslateY = 0;
function formatContentWithTags(text) {
if (!text) return '';
// 转义HTML特殊字符
const escapeHtml = (str) => {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
};
// 按行分割处理
const lines = text.split('\n');
const processedLines = lines.map(line => {
// 检查是否包含类型相关字段(支持多种格式)
// 匹配:类型、ジャンル(日文"类型")
const typeMatch = line.match(/^(.*?(?:类型|ジャンル)[::])(.+)$/);
if (typeMatch) {
const prefix = typeMatch[1]; // "类型:"之前的内容
const typeContent = typeMatch[2]; // "类型:"之后的内容
// 按"|"或空格分隔类型(优先使用"|",如果没有则用空格)
let types = [];
if (typeContent.includes('|')) {
types = typeContent.split('|').map(t => t.trim()).filter(t => t);
} else {
// 按空格分隔,但保留多个连续空格的情况
types = typeContent.split(/\s+/).map(t => t.trim()).filter(t => t);
}
if (types.length > 0) {
// 将每个类型转换为标签,根据索引使用不同的颜色类
const tags = types.map((type, index) => {
// 根据索引选择颜色类(循环使用10种颜色)
const colorClass = `clm-type-tag-color-${(index % 10) + 1}`;
return `<span class="clm-type-tag ${colorClass}">${escapeHtml(type)}</span>`;
}).join('');
return escapeHtml(prefix) + tags;
}
}
// 检查是否包含出演者相关字段(支持多种格式)
// 匹配:出演者、出演、【出演女優】(日文格式)
const performerMatch = line.match(/^(.*?(?:出演者|出演|【出演女優】)[::])(.+)$/);
if (performerMatch) {
const prefix = performerMatch[1]; // "出演者:"之前的内容
const performerContent = performerMatch[2]; // "出演者:"之后的内容
// 按"|"或空格分隔出演者(优先使用"|",如果没有则用空格)
let performers = [];
if (performerContent.includes('|')) {
performers = performerContent.split('|').map(p => p.trim()).filter(p => p);
} else {
// 按空格分隔,但保留多个连续空格的情况
performers = performerContent.split(/\s+/).map(p => p.trim()).filter(p => p);
}
if (performers.length > 0) {
// 将每个出演者转换为标签,使用独立的类名 clm-performer-tag
const tags = performers.map((performer, index) => {
// 根据索引选择颜色类(循环使用10种颜色)
const colorClass = `clm-performer-tag-color-${(index % 10) + 1}`;
return `<span class="clm-performer-tag ${colorClass}">${escapeHtml(performer)}</span>`;
}).join('');
return escapeHtml(prefix) + tags;
}
}
// 普通行直接转义
return escapeHtml(line);
});
// 用<br>连接所有行
return processedLines.join('<br>');
}
function renderPanelEntries(container, entries, emptyText) {
container.innerHTML = '';
if (!entries || !entries.length) {
const empty = document.createElement('div');
empty.className = 'clm-panel-empty';
empty.textContent = emptyText;
container.appendChild(empty);
return;
}
entries.forEach(entry => {
const item = document.createElement('div');
item.className = 'clm-panel-entry';
const user = document.createElement('div');
user.className = 'clm-panel-entry-user';
user.textContent = entry.user || '匿名';
const content = document.createElement('div');
content.className = 'clm-panel-entry-content';
// 使用 innerHTML 来支持标签和换行
content.innerHTML = formatContentWithTags(entry.content || '(無內容)');
// 为出演者标签添加点击事件(只绑定到出演者标签,不包括类型标签)
// 等待DOM更新后直接为每个出演者标签绑定事件
setTimeout(() => {
content.querySelectorAll('.clm-performer-tag').forEach(tag => {
// 设置样式,确保可以点击
tag.style.pointerEvents = 'auto';
tag.style.cursor = 'pointer';
tag.style.position = 'relative';
tag.style.zIndex = '10';
// 添加点击事件(使用捕获阶段确保事件被处理)
const clickHandler = (e) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation(); // 阻止其他事件监听器
const keyword = tag.textContent.trim();
console.log('草榴Manager: 点击出演者标签:', keyword);
if (keyword) {
// 通过全局变量访问searchDialog
const dialog = window.clmSearchDialog;
console.log('草榴Manager: searchDialog:', dialog);
if (dialog && typeof dialog.open === 'function') {
console.log('草榴Manager: 调用 dialog.open:', keyword);
dialog.open(keyword);
} else {
console.warn('草榴Manager: searchDialog 未初始化或 open 方法不存在', dialog);
}
}
};
tag.addEventListener('click', clickHandler, true); // 使用捕获阶段确保事件被处理
tag.addEventListener('mousedown', (e) => {
e.stopPropagation();
}, true);
});
}, 0);
// 如果有 tips 元素,添加到内容中
if (entry.tips && entry.tips.length > 0) {
const tipsContainer = document.createElement('div');
tipsContainer.className = 'clm-panel-entry-tips';
const scaleWrapper = document.createElement('div');
scaleWrapper.className = 'clm-panel-entry-tips-scale-wrapper';
entry.tips.forEach(tipHtml => {
const tipDiv = document.createElement('div');
tipDiv.innerHTML = tipHtml;
scaleWrapper.appendChild(tipDiv);
});
tipsContainer.appendChild(scaleWrapper);
content.appendChild(tipsContainer);
// 等比例缩放tips以适应容器宽度
setTimeout(() => {
const containerWidth = tipsContainer.offsetWidth;
const contentWidth = scaleWrapper.scrollWidth;
if (contentWidth > containerWidth && containerWidth > 0) {
const scale = containerWidth / contentWidth;
scaleWrapper.style.transform = `scale(${scale})`;
scaleWrapper.style.width = `${100 / scale}%`;
// 调整容器高度以适应缩放后的内容
const scaledHeight = scaleWrapper.scrollHeight * scale;
tipsContainer.style.height = `${scaledHeight}px`;
}
}, 50);
}
item.appendChild(user);
item.appendChild(content);
container.appendChild(item);
});
}
function renderTopicPanel(topic) {
topicBody.innerHTML = '';
if (!topic) {
const empty = document.createElement('div');
empty.className = 'clm-panel-empty';
empty.textContent = '暫無主題內容';
topicBody.appendChild(empty);
return;
}
const item = document.createElement('div');
item.className = 'clm-panel-entry';
// 显示标题和标签
const titleContainer = document.createElement('div');
titleContainer.className = 'clm-panel-entry-title';
if (topic.titleInfo && (topic.titleInfo.quality || topic.titleInfo.size || topic.titleInfo.code || topic.titleInfo.title)) {
// 显示标签(清晰度、文件大小、番号)
const tagsContainer = document.createElement('div');
tagsContainer.className = 'clm-panel-entry-title-tags';
let hasTags = false;
// 清晰度标签
if (topic.titleInfo.quality) {
const qualityTag = document.createElement('span');
qualityTag.className = 'clm-panel-entry-title-tag clm-title-tag-quality';
qualityTag.textContent = topic.titleInfo.quality;
tagsContainer.appendChild(qualityTag);
hasTags = true;
}
// 文件大小标签
if (topic.titleInfo.size) {
const sizeTag = document.createElement('span');
sizeTag.className = 'clm-panel-entry-title-tag clm-title-tag-size';
sizeTag.textContent = topic.titleInfo.size;
tagsContainer.appendChild(sizeTag);
hasTags = true;
}
// 番号标签 - 嵌套结构:外层包裹完整番号,内层包裹前缀
if (topic.titleInfo.code) {
const codeTag = document.createElement('span');
codeTag.className = 'clm-panel-entry-title-tag clm-title-tag-code';
// 解析番号,分离前缀和后缀(如 UMSO-618 -> UMSO 和 -618)
const code = topic.titleInfo.code;
const separatorMatch = code.match(/^([A-Z0-9]+)([-_])([0-9]+)$/i);
if (separatorMatch) {
// 有分隔符的情况:创建嵌套结构
const prefix = separatorMatch[1]; // UMSO
const separator = separatorMatch[2]; // -
const suffix = separatorMatch[3]; // 618
const prefixTag = document.createElement('span');
prefixTag.className = 'clm-title-tag-code-prefix';
prefixTag.textContent = prefix;
prefixTag.style.cursor = 'pointer';
prefixTag.addEventListener('click', (e) => {
e.stopPropagation();
// 通过全局变量访问searchDialog
const dialog = window.clmSearchDialog;
if (dialog) {
dialog.open(prefix);
}
});
// 创建后缀标签(可点击,搜索完整番号)
const suffixTag = document.createElement('span');
suffixTag.className = 'clm-title-tag-code-suffix';
suffixTag.textContent = separator + suffix;
suffixTag.style.cursor = 'pointer';
suffixTag.addEventListener('click', (e) => {
e.stopPropagation();
// 通过全局变量访问searchDialog
const dialog = window.clmSearchDialog;
if (dialog && typeof dialog.open === 'function') {
dialog.open(code); // 搜索完整番号
}
});
codeTag.appendChild(prefixTag);
codeTag.appendChild(suffixTag);
} else {
// 没有分隔符的情况:直接显示完整番号
codeTag.textContent = code;
}
tagsContainer.appendChild(codeTag);
hasTags = true;
}
if (hasTags) {
titleContainer.appendChild(tagsContainer);
}
// 显示标题文本(片名)
if (topic.titleInfo.title) {
const titleText = document.createElement('div');
titleText.className = 'clm-panel-entry-title-text';
titleText.textContent = topic.titleInfo.title;
titleContainer.appendChild(titleText);
}
} else if (topic.rawTitle) {
// 如果没有 titleInfo,显示原始标题
const titleText = document.createElement('div');
titleText.className = 'clm-panel-entry-title-text';
titleText.textContent = topic.rawTitle;
titleContainer.appendChild(titleText);
}
// 只有当有标题内容时才添加到 item
if (titleContainer.children.length > 0) {
item.appendChild(titleContainer);
}
// 显示用户
const user = document.createElement('div');
user.className = 'clm-panel-entry-user';
user.textContent = topic.user || '匿名';
item.appendChild(user);
// 显示内容
const content = document.createElement('div');
content.className = 'clm-panel-entry-content';
content.innerHTML = formatContentWithTags(topic.content || '(無內容)');
// 为出演者标签添加点击事件(只绑定到出演者标签,不包括类型标签)
// 等待DOM更新后直接为每个出演者标签绑定事件
setTimeout(() => {
content.querySelectorAll('.clm-performer-tag').forEach(tag => {
// 设置样式,确保可以点击
tag.style.pointerEvents = 'auto';
tag.style.cursor = 'pointer';
tag.style.position = 'relative';
tag.style.zIndex = '10';
// 添加点击事件(使用捕获阶段确保事件被处理)
const clickHandler = (e) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation(); // 阻止其他事件监听器
const keyword = tag.textContent.trim();
console.log('草榴Manager: 点击出演者标签:', keyword);
if (keyword) {
// 通过全局变量访问searchDialog
const dialog = window.clmSearchDialog;
console.log('草榴Manager: searchDialog:', dialog);
if (dialog && typeof dialog.open === 'function') {
console.log('草榴Manager: 调用 dialog.open:', keyword);
dialog.open(keyword);
} else {
console.warn('草榴Manager: searchDialog 未初始化或 open 方法不存在', dialog);
}
}
};
tag.addEventListener('click', clickHandler, true); // 使用捕获阶段确保事件被处理
tag.addEventListener('mousedown', (e) => {
e.stopPropagation();
}, true);
});
}, 0);
// 如果有 tips 元素,添加到内容中
if (topic.tips && topic.tips.length > 0) {
const tipsContainer = document.createElement('div');
tipsContainer.className = 'clm-panel-entry-tips';
const scaleWrapper = document.createElement('div');
scaleWrapper.className = 'clm-panel-entry-tips-scale-wrapper';
topic.tips.forEach(tipHtml => {
const tipDiv = document.createElement('div');
tipDiv.innerHTML = tipHtml;
scaleWrapper.appendChild(tipDiv);
});
tipsContainer.appendChild(scaleWrapper);
content.appendChild(tipsContainer);
// 等比例缩放tips以适应容器宽度
setTimeout(() => {
const containerWidth = tipsContainer.offsetWidth;
const contentWidth = scaleWrapper.scrollWidth;
if (contentWidth > containerWidth && containerWidth > 0) {
const scale = containerWidth / contentWidth;
scaleWrapper.style.transform = `scale(${scale})`;
scaleWrapper.style.width = `${100 / scale}%`;
// 调整容器高度以适应缩放后的内容
const scaledHeight = scaleWrapper.scrollHeight * scale;
tipsContainer.style.height = `${scaledHeight}px`;
}
}, 50);
}
item.appendChild(content);
topicBody.appendChild(item);
}
function renderCommentsPanel(comments) {
renderPanelEntries(commentsBody, comments || [], '暫無評論內容');
}
function renderViewerAds(ads) {
if (!ads || !ads.length) {
viewerAdsTop.style.display = 'none';
viewerAdsBottom.style.display = 'none';
return;
}
// 调试:输出要渲染的广告
console.log('clm 渲染广告:', ads.length, ads);
// 如果有两个或更多广告,第一个显示在上方,第二个显示在下方
// 如果只有一个广告,显示在上方
if (ads.length >= 2) {
viewerAdsTopContainer.innerHTML = ads[0];
viewerAdsTop.style.display = 'flex';
viewerAdsBottomContainer.innerHTML = ads[1];
viewerAdsBottom.style.display = 'flex';
} else if (ads.length === 1) {
viewerAdsTopContainer.innerHTML = ads[0];
viewerAdsTop.style.display = 'flex';
viewerAdsBottom.style.display = 'none';
} else {
viewerAdsTop.style.display = 'none';
viewerAdsBottom.style.display = 'none';
}
// 调试:检查渲染后的元素
setTimeout(() => {
const topFtad = viewerAdsTopContainer.querySelector('.ftad-ct');
const bottomFtad = viewerAdsBottomContainer.querySelector('.ftad-ct');
console.log('clm 渲染后检查:', {
topVisible: viewerAdsTop.style.display,
topHasFtad: !!topFtad,
bottomVisible: viewerAdsBottom.style.display,
bottomHasFtad: !!bottomFtad
});
}, 100);
}
function updateGalleryQuality(tag) {
currentQualityTag = tag || null;
updateQualityBadgeElement(galleryQualityBadge, currentQualityTag);
}
function beginImageLoad(message) {
if (errorIndicatorTimer) {
clearTimeout(errorIndicatorTimer);
errorIndicatorTimer = null;
}
loadingIndicator.textContent = message || '正在載入…';
viewer.classList.add('clm-viewer-loading');
updateGalleryQuality(currentQualityTag);
}
function finishImageLoad() {
if (errorIndicatorTimer) {
clearTimeout(errorIndicatorTimer);
errorIndicatorTimer = null;
}
viewer.classList.remove('clm-viewer-loading');
loadingIndicator.textContent = '正在載入…';
}
function handleImageError() {
if (errorIndicatorTimer) {
clearTimeout(errorIndicatorTimer);
}
loadingIndicator.textContent = '載入失敗,請稍後重試';
viewer.classList.add('clm-viewer-loading');
errorIndicatorTimer = setTimeout(() => {
viewer.classList.remove('clm-viewer-loading');
loadingIndicator.textContent = '正在載入…';
errorIndicatorTimer = null;
}, 1600);
}
function updateMeta() {
if (!items.length) {
meta.textContent = '';
return;
}
meta.textContent = `${currentIndex + 1} / ${items.length} ${items[currentIndex]?.label || ''}`;
}
function cleanupGalleryDownloadWatcher() {
if (galleryDownloadUnsubscribe) {
galleryDownloadUnsubscribe();
galleryDownloadUnsubscribe = null;
}
}
function refreshOverlayDownloadButton() {
const hasDownload = currentThreadKey && currentDownloadInfo && currentDownloadInfo.pageUrl;
if (!hasDownload) {
downloadBtn.classList.remove('clm-downloaded');
downloadBtn.disabled = true;
downloadBtn.textContent = '暫無可下載資源';
delete downloadBtn.dataset.clmThreadKey;
downloadBtn.__clmRefreshDownloadState = null;
return;
}
downloadBtn.dataset.clmThreadKey = currentThreadKey;
downloadBtn.__clmRefreshDownloadState = refreshOverlayDownloadButton;
const downloaded = hasDownloadedThread(currentThreadKey);
downloadBtn.classList.toggle('clm-downloaded', downloaded);
downloadBtn.disabled = false;
downloadBtn.textContent = downloaded ? '已下載' : '打開下載頁面';
}
function updateDownloadAction(downloadInfo) {
currentDownloadInfo = downloadInfo || null;
cleanupGalleryDownloadWatcher();
if (currentThreadKey && currentDownloadInfo && currentDownloadInfo.pageUrl) {
galleryDownloadUnsubscribe = subscribeDownloadStatus(currentThreadKey, () => refreshOverlayDownloadButton());
}
refreshOverlayDownloadButton();
}
function handleDownloadClick() {
if (!downloadBtn.dataset.clmThreadKey) return;
handleThreadDownloadButtonClick(downloadBtn);
}
function resetImageTransform() {
imageScale = 1;
imageTranslateX = 0;
imageTranslateY = 0;
applyImageTransform();
}
function applyImageTransform() {
viewerImg.style.transform = `translate(${imageTranslateX}px, ${imageTranslateY}px) scale(${imageScale})`;
viewerImg.classList.toggle('clm-zoomed', imageScale > 1);
}
function zoomImage(delta, clientX, clientY) {
const rect = viewerImg.getBoundingClientRect();
const imgCenterX = rect.left + rect.width / 2;
const imgCenterY = rect.top + rect.height / 2;
// 计算鼠标相对于图片中心的位置
const mouseX = clientX - imgCenterX;
const mouseY = clientY - imgCenterY;
// 缩放因子:向下滚动(delta > 0)缩小,向上滚动(delta < 0)放大
const zoomFactor = delta > 0 ? 0.9 : 1.1;
const newScale = Math.max(1, Math.min(5, imageScale * zoomFactor));
if (newScale === imageScale) return;
// 计算缩放后的偏移,使鼠标位置保持相对不变
const scaleChange = newScale / imageScale;
imageTranslateX = mouseX - (mouseX - imageTranslateX) * scaleChange;
imageTranslateY = mouseY - (mouseY - imageTranslateY) * scaleChange;
imageScale = newScale;
applyImageTransform();
}
function showImage(index) {
const item = items[index];
if (!item) return;
const token = ++currentImageToken;
resetImageTransform();
beginImageLoad();
const targetSrc = item.url;
viewerImg.onload = () => {
if (token !== currentImageToken) return;
finishImageLoad();
resetImageTransform();
};
viewerImg.onerror = () => {
if (token !== currentImageToken) return;
handleImageError();
};
viewerImg.removeAttribute('src');
requestAnimationFrame(() => {
if (token !== currentImageToken) return;
viewerImg.src = targetSrc;
viewerImg.alt = item.label || '';
});
currentIndex = index;
updateMeta();
}
function showNext() {
if (items.length <= 1) return;
const nextIndex = (currentIndex + 1) % items.length;
showImage(nextIndex);
}
function showPrev() {
if (items.length <= 1) return;
const prevIndex = (currentIndex - 1 + items.length) % items.length;
showImage(prevIndex);
}
function closeOverlay() {
overlay.classList.remove('clm-active');
viewerImg.removeAttribute('src');
currentImageToken += 1;
finishImageLoad();
resetImageTransform();
currentThreadKey = null;
updateDownloadAction(null);
updateGalleryQuality(null);
clearGallerySourceHighlight();
closeInlineDownloadWindowIfOpen();
}
function openLoadingState(message = '正在載入畫廊…') {
overlay.classList.add('clm-active');
items = [];
currentIndex = 0;
currentImageToken += 1;
viewerImg.removeAttribute('src');
viewerImg.alt = '';
renderTopicPanel(null);
renderCommentsPanel([]);
renderViewerAds([]);
meta.textContent = '';
updateGalleryQuality(null);
currentThreadKey = null;
updateDownloadAction(null);
beginImageLoad(message);
closeInlineDownloadWindowIfOpen();
}
function openOverlay(newItems, options = {}) {
if (!newItems || !newItems.length) return;
const {
startIndex = 0,
topic = null,
comments = [],
download = null,
threadUrl = null,
qualityTag = null,
ads = []
} = options;
items = newItems;
currentIndex = Math.min(Math.max(startIndex, 0), items.length - 1);
overlay.classList.add('clm-active');
renderTopicPanel(topic);
renderCommentsPanel(comments);
renderViewerAds(ads);
currentThreadKey = threadUrl ? normalizeThreadKey(threadUrl) : null;
updateGalleryQuality(qualityTag);
updateDownloadAction(download);
showImage(currentIndex);
}
// 滚轮放大功能 - 绑定到viewer和图片上
const handleWheel = (ev) => {
if (!overlay.classList.contains('clm-active')) return;
if (!viewerImg.src) return;
ev.preventDefault();
ev.stopPropagation();
const delta = ev.deltaY;
zoomImage(delta, ev.clientX, ev.clientY);
};
viewer.addEventListener('wheel', handleWheel, { passive: false });
viewerImg.addEventListener('wheel', handleWheel, { passive: false });
// 拖拽功能(当图片放大时)
viewerImg.addEventListener('mousedown', (ev) => {
if (imageScale <= 1) return;
isDragging = true;
dragStartX = ev.clientX;
dragStartY = ev.clientY;
dragStartTranslateX = imageTranslateX;
dragStartTranslateY = imageTranslateY;
viewerImg.style.cursor = 'grabbing';
ev.preventDefault();
});
document.addEventListener('mousemove', (ev) => {
if (!isDragging || imageScale <= 1) return;
const deltaX = ev.clientX - dragStartX;
const deltaY = ev.clientY - dragStartY;
imageTranslateX = dragStartTranslateX + deltaX;
imageTranslateY = dragStartTranslateY + deltaY;
applyImageTransform();
});
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
if (imageScale > 1) {
viewerImg.style.cursor = 'move';
} else {
viewerImg.style.cursor = 'zoom-in';
}
}
});
// 双击重置缩放
viewerImg.addEventListener('dblclick', () => {
resetImageTransform();
});
closeBtn.addEventListener('click', () => closeOverlay());
overlay.addEventListener('click', (ev) => {
if (ev.target === overlay) {
closeOverlay();
}
});
leftBtn.addEventListener('click', () => showPrev());
rightBtn.addEventListener('click', () => showNext());
downloadBtn.addEventListener('click', () => handleDownloadClick());
document.addEventListener('keydown', (ev) => {
if (!overlay.classList.contains('clm-active')) return;
if (ev.key === 'ArrowRight') {
ev.preventDefault();
showNext();
} else if (ev.key === 'ArrowLeft') {
ev.preventDefault();
showPrev();
} else if (ev.key === 'Escape') {
ev.preventDefault();
if (!closeInlineDownloadWindowIfOpen()) {
closeOverlay();
}
}
});
return {
open: openOverlay,
close: closeOverlay,
isOpen: () => overlay.classList.contains('clm-active'),
showNext,
showPrev,
showLoading: openLoadingState
};
}
/**
* -------------------------------
* 搜索功能:点击标签进行搜索
* -------------------------------
*/
const SEARCH_SETTINGS_KEY = '草榴ManagerSearchSettings';
const DEFAULT_SEARCH_SETTINGS = {
f_fid: '', // 社区分类(必选)
sch_area: '0', // 搜索帖子范围:0=主题标题, 1=主题标题与主题内容, 2=回复标题与回复内容
sch_time: 'all', // 发表主题时间
method: 'AND', // 关键词匹配方式:AND=完全匹配, OR=部分匹配
orderway: 'postdate', // 结果排序:postdate=发布时间, lastpost=最后回复时间, replies=回复, hits=赞
asc: 'DESC', // 升序/降序:ASC=升序, DESC=降序
sch_author: '', // 限定用戶(用戶名)
digest: false // 精华帖标志
};
function loadSearchSettings() {
try {
const raw = localStorage.getItem(SEARCH_SETTINGS_KEY);
if (raw) {
const parsed = JSON.parse(raw);
return { ...DEFAULT_SEARCH_SETTINGS, ...parsed };
}
} catch (e) {
console.error('草榴Manager: 搜索设置读取失败,使用默认值', e);
}
return { ...DEFAULT_SEARCH_SETTINGS };
}
function saveSearchSettings(settings) {
try {
localStorage.setItem(SEARCH_SETTINGS_KEY, JSON.stringify(settings));
} catch (e) {
console.error('草榴Manager: 搜索设置保存失败', e);
}
}
function createSearchDialog() {
const mask = document.createElement('div');
mask.className = 'clm-search-dialog-mask';
mask.style.display = 'none';
const dialog = document.createElement('div');
dialog.className = 'clm-search-dialog';
const header = document.createElement('div');
header.className = 'clm-search-dialog-header';
const title = document.createElement('div');
title.className = 'clm-search-dialog-title';
title.textContent = '搜索选项';
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'clm-search-dialog-close';
closeBtn.textContent = '×';
header.appendChild(title);
header.appendChild(closeBtn);
const body = document.createElement('div');
body.className = 'clm-search-dialog-body';
// 关键词(必选)
const keywordRow = document.createElement('div');
keywordRow.className = 'clm-search-form-row';
const keywordLabel = document.createElement('label');
keywordLabel.className = 'clm-search-form-label required';
keywordLabel.textContent = '关键词';
const keywordInput = document.createElement('input');
keywordInput.type = 'text';
keywordInput.className = 'clm-search-form-input';
keywordInput.name = 'keyword';
keywordInput.id = 'sch_keyword';
keywordInput.placeholder = '請輸入搜索關鍵詞';
keywordRow.appendChild(keywordLabel);
keywordRow.appendChild(keywordInput);
body.appendChild(keywordRow);
// 社区分类(必选)
const fidRow = document.createElement('div');
fidRow.className = 'clm-search-form-row';
const fidLabel = document.createElement('label');
fidLabel.className = 'clm-search-form-label required';
fidLabel.textContent = '社区分类';
const fidSelect = document.createElement('select');
fidSelect.className = 'clm-search-form-select';
fidSelect.name = 'f_fid';
fidSelect.innerHTML = `
<option value="">請選擇板塊</option>
<option value="1">>> BT電影下載</option>
<option value="2"> |- 亞洲無碼原創區</option>
<option value="15"> |- 亞洲有碼原創區</option>
<option value="4"> |- 歐美原創區</option>
<option value="5"> |- 動漫原創區</option>
<option value="25"> |- 國產原創區</option>
<option value="26"> |- 中字原創區</option>
<option value="28"> |- AI破解原創區</option>
<option value="27"> |- 綜合分享區</option>
<option value="21"> |- HTTP下載區</option>
<option value="22"> |- 在綫成人影院</option>
<option value="10"> |- 草榴影視庫</option>
<option value="11"> |- 亞洲區</option>
<option value="12"> |- 歐美區</option>
<option value="13"> |- 動漫區</option>
<option value="14"> |- 圖片區</option>
<option value="23"> |- 博彩區</option>
<option value="6">>> 草榴休閑區</option>
<option value="7"> |- 技術討論區</option>
<option value="8"> |- 新時代的我們</option>
<option value="16"> |- 達蓋爾的旗幟</option>
<option value="20"> |- 成人文學交流區</option>
<option value="9"> |- 草榴資訊</option>
`;
fidRow.appendChild(fidLabel);
fidRow.appendChild(fidSelect);
body.appendChild(fidRow);
// 搜索帖子范围
const areaRow = document.createElement('div');
areaRow.className = 'clm-search-form-row';
const areaLabel = document.createElement('label');
areaLabel.className = 'clm-search-form-label';
areaLabel.textContent = '搜索帖子范围';
const areaGroup = document.createElement('div');
areaGroup.className = 'clm-search-form-radio-group';
const area0 = document.createElement('div');
area0.className = 'clm-search-form-radio';
area0.innerHTML = '<input type="radio" name="sch_area" value="0" id="sch_area_0" checked><label for="sch_area_0">主题标题</label>';
areaGroup.appendChild(area0);
areaRow.appendChild(areaLabel);
areaRow.appendChild(areaGroup);
body.appendChild(areaRow);
// 发表主题时间
const timeRow = document.createElement('div');
timeRow.className = 'clm-search-form-row';
const timeLabel = document.createElement('label');
timeLabel.className = 'clm-search-form-label';
timeLabel.textContent = '发表主题时间';
const timeSelect = document.createElement('select');
timeSelect.className = 'clm-search-form-select';
timeSelect.name = 'sch_time';
timeSelect.innerHTML = `
<option value="all">所有主题</option>
<option value="86400">1天内的主题</option>
<option value="172800">2天内的主题</option>
<option value="604800">1星期内的主题</option>
<option value="2592000">1个月内的主题</option>
<option value="5184000">2个月内的主题</option>
<option value="7776000">3个月内的主题</option>
<option value="15552000">6个月内的主题</option>
<option value="31536000">1年内的主题</option>
`;
timeRow.appendChild(timeLabel);
timeRow.appendChild(timeSelect);
body.appendChild(timeRow);
// 关键词匹配方式
const methodRow = document.createElement('div');
methodRow.className = 'clm-search-form-row';
const methodLabel = document.createElement('label');
methodLabel.className = 'clm-search-form-label';
methodLabel.textContent = '关键词匹配方式';
const methodGroup = document.createElement('div');
methodGroup.className = 'clm-search-form-radio-group';
const methodAnd = document.createElement('div');
methodAnd.className = 'clm-search-form-radio';
methodAnd.innerHTML = '<input type="radio" name="method" value="AND" id="method_and" checked><label for="method_and">完全匹配</label>';
const methodOr = document.createElement('div');
methodOr.className = 'clm-search-form-radio';
methodOr.innerHTML = '<input type="radio" name="method" value="OR" id="method_or"><label for="method_or">部分匹配</label>';
methodGroup.appendChild(methodAnd);
methodGroup.appendChild(methodOr);
methodRow.appendChild(methodLabel);
methodRow.appendChild(methodGroup);
body.appendChild(methodRow);
// 结果排序
const orderRow = document.createElement('div');
orderRow.className = 'clm-search-form-row';
const orderLabel = document.createElement('label');
orderLabel.className = 'clm-search-form-label';
orderLabel.textContent = '结果排序';
const orderSelect = document.createElement('select');
orderSelect.className = 'clm-search-form-select';
orderSelect.name = 'orderway';
orderSelect.style.marginBottom = '8px';
orderSelect.innerHTML = `
<option value="postdate">发布时间</option>
<option value="lastpost">最后回复时间</option>
<option value="replies">回复</option>
<option value="hits">赞</option>
`;
const ascGroup = document.createElement('div');
ascGroup.className = 'clm-search-form-radio-group';
const ascAsc = document.createElement('div');
ascAsc.className = 'clm-search-form-radio';
ascAsc.innerHTML = '<input type="radio" name="asc" value="ASC" id="asc_asc"><label for="asc_asc">升序</label>';
const ascDesc = document.createElement('div');
ascDesc.className = 'clm-search-form-radio';
ascDesc.innerHTML = '<input type="radio" name="asc" value="DESC" id="asc_desc" checked><label for="asc_desc">降序</label>';
ascGroup.appendChild(ascAsc);
ascGroup.appendChild(ascDesc);
orderRow.appendChild(orderLabel);
orderRow.appendChild(orderSelect);
orderRow.appendChild(ascGroup);
body.appendChild(orderRow);
// 限定用戶
const authorRow = document.createElement('div');
authorRow.className = 'clm-search-form-row';
const authorLabel = document.createElement('label');
authorLabel.className = 'clm-search-form-label';
authorLabel.textContent = '限定用戶(請輸入用戶名或留空)';
const authorInput = document.createElement('input');
authorInput.type = 'text';
authorInput.className = 'clm-search-form-input';
authorInput.name = 'pwuser';
authorInput.id = 'sch_author';
authorInput.placeholder = '請輸入用戶名或留空';
authorRow.appendChild(authorLabel);
authorRow.appendChild(authorInput);
body.appendChild(authorRow);
// 精华帖标志
const digestRow = document.createElement('div');
digestRow.className = 'clm-search-form-row';
const digestCheckbox = document.createElement('div');
digestCheckbox.className = 'clm-search-form-checkbox';
digestCheckbox.innerHTML = '<input type="checkbox" name="digest" value="1" id="digest_check"><label for="digest_check">精华帖标志</label>';
digestRow.appendChild(digestCheckbox);
body.appendChild(digestRow);
const footer = document.createElement('div');
footer.className = 'clm-search-dialog-footer';
const searchBtn = document.createElement('button');
searchBtn.type = 'button';
searchBtn.className = 'clm-search-btn clm-search-btn-primary';
searchBtn.textContent = '搜索';
const cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'clm-search-btn clm-search-btn-secondary';
cancelBtn.textContent = '取消';
footer.appendChild(searchBtn);
footer.appendChild(cancelBtn);
dialog.appendChild(header);
dialog.appendChild(body);
dialog.appendChild(footer);
mask.appendChild(dialog);
document.body.appendChild(mask);
let currentKeyword = '';
let currentSearchCallback = null;
function open(keyword, callback) {
currentKeyword = keyword;
currentSearchCallback = callback;
const settings = loadSearchSettings();
// 设置关键词输入框
const keywordInput = document.getElementById('sch_keyword');
if (keywordInput) {
keywordInput.value = keyword || '';
}
// 加载保存的设置
fidSelect.value = settings.f_fid || '';
// 清除所有单选按钮的选中状态
document.querySelectorAll('input[name="sch_area"]').forEach(radio => {
radio.checked = false;
});
document.querySelectorAll('input[name="method"]').forEach(radio => {
radio.checked = false;
});
document.querySelectorAll('input[name="asc"]').forEach(radio => {
radio.checked = false;
});
// 设置选中的单选按钮
const schAreaRadio = document.querySelector('input[name="sch_area"][value="' + (settings.sch_area || '0') + '"]');
if (schAreaRadio) schAreaRadio.checked = true;
timeSelect.value = settings.sch_time || 'all';
const methodRadio = document.querySelector('input[name="method"][value="' + (settings.method || 'AND') + '"]');
if (methodRadio) methodRadio.checked = true;
orderSelect.value = settings.orderway || 'postdate';
const ascRadio = document.querySelector('input[name="asc"][value="' + (settings.asc || 'DESC') + '"]');
if (ascRadio) ascRadio.checked = true;
const digestCheck = document.getElementById('digest_check');
if (digestCheck) {
digestCheck.checked = settings.digest || false;
}
const authorInput = document.getElementById('sch_author');
if (authorInput) {
authorInput.value = settings.sch_author || '';
}
mask.style.display = 'flex';
}
function close() {
mask.style.display = 'none';
currentKeyword = '';
currentSearchCallback = null;
}
function performSearch() {
// 验证必选项
const keywordInput = document.getElementById('sch_keyword');
const keyword = keywordInput?.value.trim() || '';
if (!keyword) {
alert('请输入关键词(必选项)');
if (keywordInput) keywordInput.focus();
return;
}
if (!fidSelect.value) {
alert('请选择社区分类(必选项)');
fidSelect.focus();
return;
}
// 收集表单数据
const authorInput = document.getElementById('sch_author');
const authorValue = authorInput?.value.trim() || '';
const formData = {
step: '2',
s_type: 'forum',
keyword: keyword,
f_fid: fidSelect.value,
sch_area: document.querySelector('input[name="sch_area"]:checked')?.value || '0',
sch_time: timeSelect.value,
method: document.querySelector('input[name="method"]:checked')?.value || 'AND',
orderway: orderSelect.value,
asc: document.querySelector('input[name="asc"]:checked')?.value || 'DESC',
pwuser: authorValue,
digest: document.getElementById('digest_check')?.checked ? '1' : ''
};
// 保存设置
const settings = {
f_fid: formData.f_fid,
sch_area: formData.sch_area,
sch_time: formData.sch_time,
method: formData.method,
orderway: formData.orderway,
asc: formData.asc,
sch_author: authorValue,
digest: document.getElementById('digest_check')?.checked || false
};
saveSearchSettings(settings);
// 构建搜索URL
const params = new URLSearchParams();
Object.keys(formData).forEach(key => {
if (formData[key]) {
params.append(key, formData[key]);
}
});
const searchUrl = 'https://t66y.com/search.php?' + params.toString();
// 打开新标签页
window.open(searchUrl, '_blank');
// 执行回调
if (currentSearchCallback) {
currentSearchCallback();
}
close();
}
closeBtn.addEventListener('click', close);
cancelBtn.addEventListener('click', close);
searchBtn.addEventListener('click', performSearch);
mask.addEventListener('click', (e) => {
if (e.target === mask) {
close();
}
});
// ESC键关闭
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && mask.style.display === 'flex') {
close();
}
});
return {
open,
close
};
}
function createInlineDownloadWindow() {
const mask = document.createElement('div');
mask.className = 'clm-download-window-mask';
const preview = document.createElement('div');
preview.className = 'clm-gallery-download-preview';
const header = document.createElement('div');
header.className = 'clm-gallery-download-preview-header';
const headerMeta = document.createElement('div');
headerMeta.style.display = 'flex';
headerMeta.style.flexDirection = 'column';
headerMeta.style.gap = '2px';
const title = document.createElement('div');
title.className = 'clm-gallery-download-preview-title';
title.textContent = '下載窗口 · RMDOWN';
const subtitle = document.createElement('div');
subtitle.className = 'clm-gallery-download-preview-subtitle';
headerMeta.appendChild(title);
headerMeta.appendChild(subtitle);
const link = document.createElement('a');
link.className = 'clm-gallery-download-preview-link';
link.textContent = '新窗口開啟';
link.target = '_blank';
link.rel = 'noopener noreferrer';
// 点击新窗口开启时,关闭下载窗口(避免遮挡)
link.addEventListener('click', () => {
close();
});
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'clm-gallery-download-preview-close';
closeBtn.textContent = '關閉';
header.appendChild(headerMeta);
header.appendChild(link);
header.appendChild(closeBtn);
const frame = document.createElement('iframe');
frame.className = 'clm-gallery-download-preview-frame';
frame.title = 'RMDOWN 下載內容';
// 确保 iframe 允许导航,不设置 sandbox 属性以允许所有导航
const footer = document.createElement('div');
footer.className = 'clm-gallery-download-preview-footer';
const status = document.createElement('div');
status.className = 'clm-download-window-status';
status.textContent = '準備載入下載頁面…';
const fallbackHint = document.createElement('div');
fallbackHint.style.marginTop = '6px';
fallbackHint.style.fontSize = '11px';
fallbackHint.style.color = 'rgba(0, 0, 0, 0.45)';
fallbackHint.textContent = '若頁面無法顯示,請使用新窗口開啟連結。';
footer.appendChild(status);
footer.appendChild(fallbackHint);
preview.appendChild(header);
preview.appendChild(frame);
preview.appendChild(footer);
mask.appendChild(preview);
document.body.appendChild(mask);
const state = {
open: false,
currentUrl: '',
loadPromise: null,
loadResolve: null
};
function setTitle(text) {
title.textContent = text || '下載窗口 · RMDOWN';
}
function setSubtitle(text) {
subtitle.textContent = text || '';
}
function setLink(url) {
if (url) {
link.href = url;
link.style.pointerEvents = '';
link.style.opacity = '';
} else {
link.removeAttribute('href');
link.style.pointerEvents = 'none';
link.style.opacity = '0.45';
}
}
function setPageUrl(url) {
state.currentUrl = url || '';
// 重置加载 Promise
if (state.loadResolve) {
state.loadResolve = null;
}
state.loadPromise = null;
if (url) {
// 创建新的 Promise 用于等待加载完成
state.loadPromise = new Promise((resolve) => {
state.loadResolve = resolve;
});
frame.src = url;
// 记录初始 URL,用于检测页面跳转
let initialUrl = url;
let hasNavigated = false;
// 监听 iframe 加载完成,尝试检测下载链接
frame.onload = function() {
// 检查 iframe 是否发生了跳转(比如跳转到广告页面)
// 这是正常行为,当用户点击 DOWNLOAD 后,页面会跳转到广告页面
try {
const currentUrl = frame.contentWindow.location.href;
if (currentUrl !== initialUrl && !currentUrl.includes('rmdown.com')) {
// iframe 已跳转到其他页面(可能是广告页面),这是正常行为
// 保持窗口打开,让用户看到跳转后的页面
hasNavigated = true;
setStatus('頁面已跳轉到廣告頁面(正常行為)', 'info');
// 通知加载完成
if (state.loadResolve) {
state.loadResolve();
state.loadResolve = null;
}
return;
}
// 如果还在 rmdown.com 域名下,检查是否有 poData,以便后续跳转
if (currentUrl.includes('rmdown.com')) {
try {
const iframeWindow = frame.contentWindow;
if (typeof iframeWindow.poData !== 'undefined' && Array.isArray(iframeWindow.poData) && iframeWindow.poData.length > 0) {
// poData 存在,跳转应该会在 downloadFile 完成后自动执行
console.log('草榴Manager: 检测到 poData,等待自动跳转');
}
} catch (e) {
// 无法访问 iframe 的 window 对象
}
}
} catch (e) {
// 跨域限制,无法访问 location
// 这是正常的,当页面跳转到其他域名时会出现跨域限制
// 我们假设跳转已经发生,这是正常行为
if (!hasNavigated) {
// 可能是第一次加载后的跳转,更新状态
setStatus('頁面可能已跳轉(跨域限制無法檢測)', 'info');
}
}
// 等待一小段时间,确保页面完全渲染
setTimeout(() => {
try {
// 尝试访问 iframe 内容(可能因跨域限制而失败)
const iframeDoc = frame.contentDocument || frame.contentWindow.document;
if (iframeDoc) {
// 查找所有可能的下载链接
const downloadLinks = iframeDoc.querySelectorAll('a[href*="download"], a[href*=".torrent"], button[onclick*="download"], button[onclick*="Download"], form[action*="download"], a[href*=".zip"], a[href*=".rar"], a[href*=".7z"]');
downloadLinks.forEach(link => {
// 使用捕获阶段,确保在事件传播前就隐藏窗口
link.addEventListener('click', function(e) {
// 立即隐藏下载窗口,但允许跳转继续
hideForDownload();
}, true); // 使用捕获阶段
});
// 也监听表单提交(可能用于下载)
const forms = iframeDoc.querySelectorAll('form');
forms.forEach(form => {
form.addEventListener('submit', function() {
// 立即隐藏下载窗口,但允许提交继续
hideForDownload();
}, true); // 使用捕获阶段
});
// 监听所有链接点击(更广泛的捕获)
iframeDoc.addEventListener('click', function(e) {
const target = e.target;
// 检查是否是下载相关的链接
if (target.tagName === 'A' || target.closest('a')) {
const href = target.href || (target.closest('a')?.href);
if (href && (href.includes('download') || href.includes('.torrent') || href.includes('.zip') || href.includes('.rar') || href.includes('.7z'))) {
// 延迟一点,确保链接能正常触发
setTimeout(() => {
hideForDownload();
}, 100);
}
}
}, true);
// 监听 DOWNLOAD 按钮点击,确保允许页面跳转
// 查找所有按钮,然后筛选出包含 "DOWNLOAD" 文本的按钮
const allButtons = iframeDoc.querySelectorAll('button');
allButtons.forEach(btn => {
const btnText = btn.textContent || btn.innerText || '';
const btnOnclick = btn.getAttribute('onclick') || '';
const btnTitle = btn.getAttribute('title') || '';
// 检查是否是 DOWNLOAD 按钮
if (btnOnclick.includes('downloadFile') ||
btnTitle.toLowerCase().includes('download') ||
btnText.toUpperCase().includes('DOWNLOAD')) {
btn.addEventListener('click', function(e) {
// 允许点击事件正常传播,不阻止默认行为
// 这样 downloadFile() 函数可以正常执行,包括跳转到广告页面
hideForDownload();
}, false); // 不使用捕获阶段,让事件正常传播
}
});
}
} catch (e) {
// 跨域限制,无法访问 iframe 内容
// 使用备用方案:监听 iframe 的 beforeunload 事件
try {
frame.contentWindow.addEventListener('beforeunload', function() {
// 当 iframe 即将卸载时(可能是下载触发或页面跳转),暂时隐藏下载窗口
hideForDownload();
});
} catch (e2) {
// 如果还是失败,忽略
}
}
// 通知加载完成
if (state.loadResolve) {
state.loadResolve();
state.loadResolve = null;
}
}, 500); // 等待 500ms 确保页面完全加载
};
} else {
frame.removeAttribute('src');
frame.onload = null;
// 如果没有 URL,立即 resolve
if (state.loadResolve) {
state.loadResolve();
state.loadResolve = null;
}
}
}
function waitForLoad() {
return state.loadPromise || Promise.resolve();
}
// 模拟点击 DOWNLOAD 按钮
function simulateDownloadClick() {
try {
const iframeDoc = frame.contentDocument || frame.contentWindow?.document;
if (!iframeDoc) {
console.warn('草榴Manager: 无法访问 iframe 内容,可能因跨域限制');
return false;
}
// 查找所有按钮,然后筛选出包含 "DOWNLOAD" 文本的按钮
const allButtons = iframeDoc.querySelectorAll('button');
for (const btn of allButtons) {
const btnText = btn.textContent || btn.innerText || '';
const btnOnclick = btn.getAttribute('onclick') || '';
const btnTitle = btn.getAttribute('title') || '';
// 检查是否是 DOWNLOAD 按钮
if (btnOnclick.includes('downloadFile') ||
btnTitle.toLowerCase().includes('download') ||
btnText.toUpperCase().includes('DOWNLOAD')) {
console.log('草榴Manager: 找到 DOWNLOAD 按钮,正在模拟点击...');
// 创建并触发点击事件
const clickEvent = new MouseEvent('click', {
view: frame.contentWindow,
bubbles: true,
cancelable: true
});
btn.dispatchEvent(clickEvent);
// 如果按钮有 onclick 属性,也直接调用
if (btnOnclick.includes('downloadFile')) {
try {
// 尝试在 iframe 的 window 上下文中执行 onclick
const iframeWindow = frame.contentWindow;
if (iframeWindow && typeof iframeWindow.downloadFile === 'function') {
iframeWindow.downloadFile(btn);
} else if (btn.onclick) {
btn.onclick();
}
} catch (e) {
console.warn('草榴Manager: 执行 onclick 失败', e);
}
}
hideForDownload();
return true;
}
}
console.warn('草榴Manager: 未找到 DOWNLOAD 按钮');
return false;
} catch (e) {
console.error('草榴Manager: 模拟点击 DOWNLOAD 按钮失败', e);
return false;
}
}
// 暂时隐藏下载窗口,以便浏览器的文件保存对话框显示在最上层
function hideForDownload() {
// 使用 display: none 而不是 visibility,确保完全隐藏
mask.style.display = 'none';
mask.style.zIndex = '1'; // 降低 z-index
// 10秒后恢复显示(给文件保存对话框足够的时间)
setTimeout(() => {
if (state.open) {
mask.style.display = '';
mask.style.zIndex = ''; // 恢复 z-index
}
}, 10000);
}
function setStatus(text, variant = 'info') {
status.textContent = text || '準備中…';
if (variant === 'success' || variant === 'error') {
status.dataset.variant = variant;
} else {
delete status.dataset.variant;
}
}
function open(options = {}) {
if (!state.open) {
state.open = true;
mask.classList.add('clm-active');
}
preview.classList.add('clm-active');
setTitle(options.title || null);
setSubtitle(options.subtitle || '');
if (options.pageUrl) {
setPageUrl(options.pageUrl);
setLink(options.pageUrl);
} else if (state.currentUrl) {
setLink(state.currentUrl);
} else if (options.link) {
setLink(options.link);
} else {
setLink('');
setPageUrl('');
}
setStatus(options.status || '正在初始化下載窗口…');
}
function close() {
if (!state.open) return;
state.open = false;
mask.classList.remove('clm-active');
preview.classList.remove('clm-active');
state.currentUrl = '';
setLink('');
setStatus('準備載入下載頁面…');
frame.removeAttribute('src');
// 重置加载 Promise
if (state.loadResolve) {
state.loadResolve = null;
}
state.loadPromise = null;
// 重置所有处于忙碌状态的下载按钮
const busyButtons = document.querySelectorAll('[data-clm-busy="1"]');
for (const btn of busyButtons) {
btn.dataset.clmBusy = '0';
if (typeof btn.__clmRefreshDownloadState === 'function') {
btn.__clmRefreshDownloadState();
} else {
btn.disabled = false;
}
}
}
closeBtn.addEventListener('click', () => close());
mask.addEventListener('click', (ev) => {
if (ev.target === mask) {
close();
}
});
preview.addEventListener('click', (ev) => ev.stopPropagation());
// ESC 键关闭下载窗口
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape' && state.open) {
ev.preventDefault();
ev.stopPropagation();
close();
}
});
return {
open,
close,
setTitle,
setSubtitle,
setLink,
setPageUrl,
setStatus,
waitForLoad,
simulateDownloadClick,
isOpen: () => state.open,
getFrame: () => frame
};
}
const searchDialog = createSearchDialog();
// 将searchDialog存储到全局变量,以便在闭包中访问
window.clmSearchDialog = searchDialog;
const galleryOverlay = createGalleryOverlay();
const inlineDownloadWindow = createInlineDownloadWindow();
// 将 inlineDownloadWindow 存储到全局变量,以便在闭包中访问
window.clmInlineDownloadWindow = inlineDownloadWindow;
function closeInlineDownloadWindowIfOpen() {
if (inlineDownloadWindow.isOpen()) {
inlineDownloadWindow.close();
return true;
}
return false;
}
let galleryLoadToken = 0;
async function fetchThreadData(threadUrl) {
if (!threadUrl) return null;
const normalized = getAbsoluteUrl(threadUrl);
if (!normalized) return null;
if (threadDataCache.has(normalized)) {
return threadDataCache.get(normalized);
}
const fetchPromise = (async () => {
try {
const resp = await fetch(normalized, { credentials: 'include' });
if (!resp.ok) {
throw new Error(`HTTP ${resp.status}`);
}
const html = await resp.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const threadContent = doc.querySelector('.tpc_content');
const gallery = collectGalleryImages(threadContent, normalized);
// 先恢复广告(执行 spinit),这样 tips 中的表格会被生成
const adsWithFallback = await collectThreadAdsWithScriptFallback(doc, normalized, html);
// 然后再提取 tips(此时 tips 已经包含完整的表格内容)
const { topic, comments, ads: contextAds } = collectThreadContext(doc);
const ads = adsWithFallback.length > 0 ? adsWithFallback : contextAds;
const download = extractThreadDownloadInfo(doc, normalized);
const qualityTag = resolveQualityTagFromDocument(doc);
return { gallery, topic, comments, download, qualityTag, ads };
} catch (err) {
console.error('clm 論壇畫廊載入失敗', normalized, err);
return { gallery: [], topic: null, comments: [], ads: [] };
}
})();
threadDataCache.set(normalized, fetchPromise);
return fetchPromise;
}
async function openGalleryForThread(threadUrl, options = {}) {
if (!threadUrl) return null;
const { instant = false, qualityTag: requestedQualityTag = null } = options;
focusGallerySource(threadUrl, currentListHoverCtx);
const loadToken = ++galleryLoadToken;
if (instant) {
galleryOverlay.showLoading();
}
const data = await fetchThreadData(threadUrl);
if (loadToken !== galleryLoadToken) {
return null;
}
if (!data || !data.gallery.length) {
clearGallerySourceHighlight();
if (instant && galleryOverlay.isOpen()) {
galleryOverlay.close();
}
alert('未找到該帖子的畫廊內容');
return null;
}
const hoverQualityTag = requestedQualityTag ?? currentListHoverCtx?.qualityTag ?? null;
galleryOverlay.open(data.gallery, {
startIndex: 0,
topic: data.topic || null,
comments: data.comments || [],
download: data.download || null,
threadUrl,
qualityTag: data.qualityTag || hoverQualityTag || null,
ads: data.ads || []
});
markThreadGalleryVisited(threadUrl);
return data;
}
function setupThreadDownloadButton(btn, options = {}) {
const defaultLabel = options.label || '下載';
const downloadedLabel = options.downloadedLabel || '已下載';
btn.textContent = defaultLabel;
const threadKey = normalizeThreadKey(options.threadUrl);
if (!threadKey) {
btn.disabled = true;
btn.title = '無法解析帖子地址';
return;
}
const container = options.container || null;
const containerClass = options.containerClass || '';
const defaultTitle = '下載到 qBittorrent';
const downloadedTitle = '已下載,可再次發送到 qBittorrent';
if (options.threadTitle) {
btn.dataset.clmThreadTitle = options.threadTitle;
} else {
delete btn.dataset.clmThreadTitle;
}
const updateState = () => {
const downloaded = hasDownloadedThread(threadKey);
btn.classList.toggle('clm-downloaded', downloaded);
btn.textContent = downloaded ? downloadedLabel : defaultLabel;
btn.title = downloaded ? downloadedTitle : defaultTitle;
if (container && containerClass) {
container.classList.toggle(containerClass, downloaded);
}
if (btn.dataset.clmBusy !== '1') {
btn.disabled = false;
}
};
btn.dataset.clmThreadKey = threadKey;
btn.__clmRefreshDownloadState = updateState;
updateState();
subscribeDownloadStatus(threadKey, () => updateState());
btn.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
if (btn.dataset.clmBusy === '1') return;
handleThreadDownloadButtonClick(btn);
});
}
async function handleThreadDownloadButtonClick(btn) {
const threadKey = btn.dataset.clmThreadKey;
if (!threadKey) return;
btn.dataset.clmBusy = '1';
btn.disabled = true;
inlineDownloadWindow.open({
subtitle: btn.dataset.clmThreadTitle || '',
status: '正在載入帖子內容…'
});
const restore = () => {
btn.dataset.clmBusy = '0';
if (typeof btn.__clmRefreshDownloadState === 'function') {
btn.__clmRefreshDownloadState();
} else {
btn.disabled = false;
}
};
try {
btn.textContent = '載入帖子…';
inlineDownloadWindow.setStatus('正在解析帖子與下載資訊…');
const threadData = await fetchThreadData(threadKey);
if (!threadData || !threadData.download || !threadData.download.pageUrl) {
inlineDownloadWindow.setStatus('該帖子沒有可解析的下載連結。', 'error');
alert('該帖子沒有可解析的下載連結。');
return;
}
inlineDownloadWindow.setPageUrl(threadData.download.pageUrl);
inlineDownloadWindow.setLink(threadData.download.pageUrl);
inlineDownloadWindow.setStatus('正在載入下載頁面,請稍候…');
btn.textContent = '載入中…';
// 等待下载窗口完全加载
try {
await inlineDownloadWindow.waitForLoad();
// 检查下载窗口是否仍然打开
if (!inlineDownloadWindow.isOpen()) {
return;
}
inlineDownloadWindow.setStatus('下載頁面已載入,準備模擬點擊 DOWNLOAD…');
// 等待一小段时间确保页面完全渲染
await new Promise(resolve => setTimeout(resolve, 500));
// 检查下载窗口是否仍然打开
if (!inlineDownloadWindow.isOpen()) {
return;
}
// 模拟点击 DOWNLOAD 按钮
const clicked = inlineDownloadWindow.simulateDownloadClick();
if (clicked) {
inlineDownloadWindow.setStatus('已模擬點擊 DOWNLOAD,等待頁面跳轉…');
// 等待页面跳转(通常跳转到广告页面)
await new Promise(resolve => setTimeout(resolve, 1000));
// 检查下载窗口是否仍然打开
if (!inlineDownloadWindow.isOpen()) {
return;
}
} else {
inlineDownloadWindow.setStatus('未找到 DOWNLOAD 按鈕,嘗試直接解析下載連結…');
}
} catch (err) {
console.error('草榴Manager: 等待下載頁面載入失敗', err);
// 即使失败也继续,给用户一个超时保护
}
// 检查下载窗口是否仍然打开
if (!inlineDownloadWindow.isOpen()) {
return;
}
btn.textContent = '選擇儲存位置…';
let settings;
try {
settings = loadSettings();
} catch (err) {
console.error('草榴Manager: 設置讀取失敗', err);
inlineDownloadWindow.setStatus('無法讀取設置,請稍後再試。', 'error');
alert('無法讀取設置,請稍後再試。');
return;
}
if (!settings.qb.enabled) {
inlineDownloadWindow.setStatus('請先在設置中啟用 qBittorrent 集成。', 'error');
alert('請先在草榴Manager 設置中啟用 qBittorrent 集成。');
return;
}
inlineDownloadWindow.setStatus('請選擇 qBittorrent 儲存預設…');
const preset = await openPresetPickerDialog(settings);
if (!preset) {
inlineDownloadWindow.setStatus('已取消發送至 qBittorrent。');
return;
}
// 检查下载窗口是否仍然打开
if (!inlineDownloadWindow.isOpen()) {
return;
}
btn.textContent = '解析下載連結…';
inlineDownloadWindow.setStatus('正在獲取種子…');
const resolved = await resolveThreadDownloadTarget(threadData.download);
inlineDownloadWindow.setStatus('正在發送到 qBittorrent…');
const ok = await sendToQbittorrent(resolved, preset.id);
if (ok) {
inlineDownloadWindow.setStatus('已自動點擊 DOWNLOAD 並發送至 qBittorrent。', 'success');
markThreadDownloaded(threadKey);
} else {
inlineDownloadWindow.setStatus('發送到 qBittorrent 失敗。', 'error');
}
} catch (err) {
console.error('草榴Manager: 列表下載按鈕執行失敗', err);
inlineDownloadWindow.setStatus('下載流程失敗:' + (err?.message || err), 'error');
alert('下載發送失敗:' + (err?.message || err));
} finally {
restore();
}
}
document.addEventListener('keydown', (ev) => {
if (galleryOverlay.isOpen()) return;
if (ev.key !== 'ArrowRight' && ev.key !== 'ArrowLeft') return;
if (!currentListHoverCtx || !currentListHoverCtx.threadUrl) return;
ev.preventDefault();
openGalleryForThread(currentListHoverCtx.threadUrl, {
instant: true,
qualityTag: currentListHoverCtx.qualityTag || null
});
});
injectStyle(`
.clm-quality-badge {
position: absolute;
left: 12px;
bottom: 12px;
padding: 4px 12px 5px;
font-size: 13px;
line-height: 1;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.5);
background: rgba(12, 12, 20, 0.82);
color: #fff;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
pointer-events: none;
display: none;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
align-items: center;
justify-content: center;
min-width: 64px;
}
.clm-quality-badge:empty {
display: none !important;
}
#tail .clm-tail-quality {
left: 12px;
bottom: 12px;
top: auto;
right: auto;
transform-origin: bottom left;
transform: translate(
calc(var(--clm-tail-extra-x, 0px) * -1),
var(--clm-tail-extra-y, 0px)
) scale(var(--clm-tail-scale, 1));
font-size: 13px;
padding: 4px 12px 5px;
min-width: 64px;
}
.clm-gallery-quality {
left: 24px;
bottom: 24px;
font-size: 14px;
padding: 5px 16px 6px;
}
.wf_item .image-big.clm-gallery-focus-cover,
.wf_item .image-big.clm-gallery-focus-cover:hover,
.wf_item .image-big.clm-gallery-visited-cover,
.wf_item .image-big.clm-gallery-visited-cover:hover {
box-shadow: 0 0 0 3px rgba(251, 146, 60, 0.85), 0 0 18px rgba(251, 146, 60, 0.45);
border-radius: 8px;
}
.wf_item .image-big.clm-gallery-focus-cover img,
.wf_item .image-big.clm-gallery-visited-cover img {
outline: 3px solid rgba(249, 115, 22, 0.85);
outline-offset: 2px;
}
.clm-gallery-focus-title,
.clm-gallery-visited-title {
color: #f97316 !important;
text-shadow: 0 0 6px rgba(0, 0, 0, 0.35);
font-weight: 700 !important;
}
`);
const QUALITY_TAG_PATTERNS = [
{ tag: '2160P', regex: /\b(2160p|4k|uhd)\b/i },
{ tag: '1440P', regex: /\b(1440p|2k)\b/i },
{ tag: '1080P', regex: /\b1080p\b/i },
{ tag: '720P', regex: /\b720p\b/i },
{ tag: 'BluRay', regex: /\b(bluray|blu-ray|bd)\b/i },
{ tag: 'HDR', regex: /\bHDR\b/i },
{ tag: 'VR', regex: /\bVR\b/i },
{ tag: 'HD', regex: /\bHD\b/i },
{ tag: 'SD', regex: /\bSD\b/i }
];
function detectQualityTagFromTitle(titleText) {
if (!titleText) return null;
for (const { tag, regex } of QUALITY_TAG_PATTERNS) {
if (regex.test(titleText)) {
return tag;
}
}
return null;
}
function resolveQualityTagFromDocument(doc) {
if (!doc) return null;
const pieces = [];
const selectors = [
'.tpc_title h1',
'.tpc_title .h',
'.t table .tr1 h4',
'.t table .tr2 h4',
'.t table .tr3 h4',
'.t table .tr4 h4',
'.t table .tr5 h4',
'.tpc_content h1',
'.tpc_content .tpc_title',
'.tpc_content strong',
'.tpc_content b'
];
selectors.forEach((sel) => {
const el = doc.querySelector(sel);
if (el?.textContent) {
pieces.push(el.textContent);
}
});
const keywords = doc.querySelector('meta[name="keywords"]')?.getAttribute('content');
if (keywords) {
pieces.push(keywords);
}
const description = doc.querySelector('meta[name="description"]')?.getAttribute('content');
if (description) {
pieces.push(description);
}
if (doc.title) {
pieces.push(doc.title);
} else {
const titleEl = doc.querySelector('title');
if (titleEl?.textContent) {
pieces.push(titleEl.textContent);
}
}
return detectQualityTagFromTitle(pieces.join(' '));
}
function resolveQualityTagFromListItem(wfItem, threadAnchor = null) {
if (!wfItem) return null;
const selectors = [
'.title a',
'.title',
'.subject a',
'.subject',
'.t_subject',
'.tsubject',
'.wf_text tl',
'.wf_text .tl',
'.wf_text a',
'.wf_text'
];
const pieces = [];
selectors.forEach((sel) => {
const el = wfItem.querySelector(sel);
if (!el) return;
if (el.textContent) {
pieces.push(el.textContent);
}
if (el.getAttribute) {
const attrTitle = el.getAttribute('title');
if (attrTitle) {
pieces.push(attrTitle);
}
}
});
if (threadAnchor) {
if (threadAnchor.textContent) {
pieces.push(threadAnchor.textContent);
}
const anchorTitle = threadAnchor.getAttribute('title');
if (anchorTitle) {
pieces.push(anchorTitle);
}
}
const combined = pieces.join(' ').trim();
return detectQualityTagFromTitle(combined);
}
function updateQualityBadgeElement(badgeEl, tag) {
if (!badgeEl) return;
if (tag) {
badgeEl.textContent = tag.toUpperCase();
badgeEl.style.display = 'inline-flex';
} else {
badgeEl.textContent = '';
badgeEl.style.display = 'none';
}
}
// 搜索页面(search.php)
if (href.indexOf('search.php') !== -1) {
// 搜索页已有預覽框 #tail,本腳本只對其中的封面圖做放大效果
// 當鼠標移到預覽圖片區域上時,放大約 2 倍(約 100% 放大)
injectStyle(`
#tail {
/* 保持原站腳本行為,不改位置邏輯,只增加放大效果所需樣式 */
overflow: visible !important;
z-index: 9999 !important;
position: absolute !important;
--clm-tail-scale: 1;
--clm-tail-extra-x: 0px;
--clm-tail-extra-y: 0px;
}
#tail img {
transition: transform 0.2s ease-in-out;
transform-origin: center center;
border-radius: 4px;
}
#tail img:hover,
#tail.clm-tail-force-zoom img {
transform: scale(2);
position: relative;
z-index: 10000;
box-shadow: 0 0 12px rgba(0, 0, 0, 0.7);
border: 2px solid #ffffff;
}
.clm-title-download {
margin-left: 8px;
padding: 2px 6px;
font-size: 11px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.3);
background: rgba(0, 0, 0, 0.65);
color: #fff;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
}
.clm-title-download.clm-downloaded {
background: rgba(16, 185, 129, 0.85);
border-color: rgba(255, 255, 255, 0.6);
color: #fff;
}
tr.tr3.t_one.clm-thread-downloaded {
background: rgba(34, 197, 94, 0.12);
}
.clm-title-download::before {
content: '⬇';
font-size: 11px;
}
.clm-title-download:hover {
background: rgba(0, 0, 0, 0.85);
}
#tail .clm-tail-quality {
z-index: 10002;
}
`);
function updateTailQualityFollow() {
const tail = document.getElementById('tail');
if (!tail) return;
const img = tail.querySelector('img');
if (!img) {
tail.style.removeProperty('--clm-tail-extra-x');
tail.style.removeProperty('--clm-tail-extra-y');
tail.style.removeProperty('--clm-tail-scale');
return;
}
const baseWidth = img.clientWidth || img.naturalWidth;
const baseHeight = img.clientHeight || img.naturalHeight;
if (!baseWidth || !baseHeight) return;
const scaleActive = tail.classList.contains('clm-tail-force-zoom') || tail.matches(':hover');
const scale = scaleActive ? 2 : 1;
if (scale <= 1) {
tail.style.removeProperty('--clm-tail-extra-x');
tail.style.removeProperty('--clm-tail-extra-y');
tail.style.removeProperty('--clm-tail-scale');
return;
}
const extraX = (baseWidth * (scale - 1)) / 2;
const extraY = (baseHeight * (scale - 1)) / 2;
tail.style.setProperty('--clm-tail-extra-x', `${extraX}px`);
tail.style.setProperty('--clm-tail-extra-y', `${extraY}px`);
tail.style.setProperty('--clm-tail-scale', `${scale}`);
}
function setTailZoomState(force) {
const tailEl = document.getElementById('tail');
if (!tailEl) return null;
tailEl.classList.toggle('clm-tail-force-zoom', !!force);
requestAnimationFrame(() => updateTailQualityFollow());
return tailEl;
}
function getTailControls() {
const tail = document.getElementById('tail');
if (!tail) return null;
if (tail.__clmControls) {
return tail.__clmControls;
}
let tailHideTimer = null;
function cancelHide() {
if (tailHideTimer) {
clearTimeout(tailHideTimer);
tailHideTimer = null;
}
}
function scheduleHide() {
cancelHide();
tailHideTimer = setTimeout(() => {
tail.style.display = 'none';
tail.classList.remove('clm-tail-force-zoom');
if (currentTailHoverCtx && currentListHoverCtx === currentTailHoverCtx) {
setCurrentListHover(null);
}
currentTailHoverCtx = null;
currentTailAnchorEl = null;
setTailQualityLabel(null);
}, 200);
}
tail.addEventListener('mouseenter', cancelHide);
tail.addEventListener('mouseleave', scheduleHide);
tail.addEventListener('mouseenter', () => {
if (currentTailHoverCtx) {
setCurrentListHover(currentTailHoverCtx);
setTailQualityLabel(currentTailHoverCtx.qualityTag || null);
}
updateTailQualityFollow();
});
tail.addEventListener('mouseleave', () => {
if (currentTailHoverCtx && currentListHoverCtx === currentTailHoverCtx) {
setCurrentListHover(null);
}
updateTailQualityFollow();
});
tail.addEventListener('load', (ev) => {
if (ev.target && ev.target.tagName === 'IMG') {
updateTailQualityFollow();
}
}, true);
tail.__clmControls = { scheduleHide, cancelHide };
return tail.__clmControls;
}
let currentTailAnchorEl = null;
let currentTailHoverCtx = null;
function ensureTailQualityElement() {
const tail = document.getElementById('tail');
if (!tail) return null;
let badge = tail.querySelector('.clm-tail-quality');
if (!badge) {
badge = document.createElement('div');
badge.className = 'clm-quality-badge clm-tail-quality';
badge.style.display = 'none';
tail.appendChild(badge);
}
return badge;
}
function setTailQualityLabel(tag) {
const badge = ensureTailQualityElement();
if (!badge) return;
updateQualityBadgeElement(badge, tag);
}
function adjustTailPositionForElement(anchorEl) {
const tail = document.getElementById('tail');
if (!tail || !anchorEl) return;
const style = window.getComputedStyle(tail);
if (style.display === 'none') {
return;
}
const anchorRect = anchorEl.getBoundingClientRect();
const scrollX = window.scrollX || document.documentElement.scrollLeft || 0;
const scrollY = window.scrollY || document.documentElement.scrollTop || 0;
const viewportWidth = document.documentElement.clientWidth || window.innerWidth || 0;
const viewportHeight = document.documentElement.clientHeight || window.innerHeight || 0;
const tailRect = tail.getBoundingClientRect();
let tailWidth = tailRect.width || tail.offsetWidth || 360;
let tailHeight = tailRect.height || tail.offsetHeight || 240;
if (!tailWidth) {
tailWidth = 360;
}
if (!tailHeight) {
tailHeight = 240;
}
const viewportLeft = scrollX + 12;
const viewportRight = scrollX + Math.max(0, viewportWidth) - 12;
let left = anchorRect.right + scrollX + 12;
if (left + tailWidth > viewportRight) {
left = anchorRect.left + scrollX - tailWidth - 12;
}
if (left < viewportLeft) {
left = Math.max(viewportLeft, viewportRight - tailWidth);
}
let top = anchorRect.top + scrollY;
const minTop = scrollY + 12;
const maxTop = scrollY + Math.max(0, viewportHeight) - tailHeight - 12;
if (top < minTop) {
top = minTop;
}
if (top > maxTop) {
top = maxTop;
}
tail.style.position = 'absolute';
tail.style.left = `${Math.round(left)}px`;
tail.style.top = `${Math.round(top)}px`;
}
function scheduleTailPositionUpdate(anchorEl) {
if (!anchorEl) return;
requestAnimationFrame(() => {
requestAnimationFrame(() => adjustTailPositionForElement(anchorEl));
});
}
function refreshTailPositionIfNeeded() {
if (!currentTailAnchorEl) return;
adjustTailPositionForElement(currentTailAnchorEl);
updateTailQualityFollow();
}
window.addEventListener('scroll', refreshTailPositionIfNeeded, { passive: true });
window.addEventListener('resize', refreshTailPositionIfNeeded);
// 預先嘗試初始化(若 tail 尚未生成,後續 getTailControls 會再處理)
getTailControls();
// 讓「標題文字」懸停時也能觸發預覽圖片
try {
const rows = document.querySelectorAll('tr.tr3.t_one');
rows.forEach(row => {
const th = row.querySelector('th');
if (!th) return;
const titleLink = th.querySelector('a[target="_blank"]');
const preSpan = th.querySelector('span.sgreen.pre');
if (!titleLink || !preSpan) return;
const threadUrl = getAbsoluteUrl(titleLink.getAttribute('href') || titleLink.href);
const titleQualityTag = detectQualityTagFromTitle(
(titleLink.textContent || '') + ' ' + (titleLink.title || '')
);
if (threadUrl) {
bindGalleryVisitedIndicator(titleLink, threadUrl, 'title');
}
if (!th.querySelector('.clm-title-download')) {
const titleBtn = document.createElement('button');
titleBtn.type = 'button';
titleBtn.className = 'clm-title-download';
titleLink.insertAdjacentElement('afterend', titleBtn);
setupThreadDownloadButton(titleBtn, {
threadUrl,
container: row,
containerClass: 'clm-thread-downloaded',
label: '下載',
downloadedLabel: '已下載',
threadTitle: (titleLink.textContent || '').trim()
});
}
// 懸停標題時,調用站內的 preImg,並模擬一次 mousemove,讓預覽框立刻顯示
let positionUpdateRaf = null;
const updatePositionLoop = () => {
if (currentTailAnchorEl === titleLink) {
adjustTailPositionForElement(titleLink);
positionUpdateRaf = requestAnimationFrame(updatePositionLoop);
} else {
positionUpdateRaf = null;
}
};
titleLink.addEventListener('mouseenter', () => {
if (typeof pageWindow.preImg === 'function') {
pageWindow.preImg(preSpan);
const tailControls = getTailControls();
tailControls && tailControls.cancelHide && tailControls.cancelHide();
const rect = titleLink.getBoundingClientRect();
const scrollX = pageWindow.scrollX || window.scrollX;
const scrollY = pageWindow.scrollY || window.scrollY;
const centerX = rect.left + rect.width / 2 + scrollX;
const centerY = rect.top + rect.height / 2 + scrollY;
const moveEvent = new MouseEvent('mousemove', {
bubbles: true,
cancelable: true,
clientX: centerX - scrollX,
clientY: centerY - scrollY
});
preSpan.dispatchEvent(moveEvent);
setTailZoomState(true);
setTailQualityLabel(titleQualityTag);
currentTailAnchorEl = titleLink;
scheduleTailPositionUpdate(titleLink);
// 持續更新 tail 位置,防止站內腳本根據鼠標位置更新導致閃爍
if (!positionUpdateRaf) {
positionUpdateRaf = requestAnimationFrame(updatePositionLoop);
}
}
if (threadUrl) {
const tailCover = document.getElementById('tail') || null;
const tailHoverCtx = {
source: 'search',
threadUrl,
cover: tailCover,
qualityTag: titleQualityTag,
titleEl: titleLink
};
currentTailHoverCtx = tailHoverCtx;
setCurrentListHover(tailHoverCtx);
}
});
// 鼠標離開標題時隱藏預覽框
titleLink.addEventListener('mouseleave', () => {
// 停止位置更新循環
if (positionUpdateRaf) {
cancelAnimationFrame(positionUpdateRaf);
positionUpdateRaf = null;
}
const tail = document.getElementById('tail');
if (tail) {
const tailControls = getTailControls();
if (tailControls && tailControls.scheduleHide) {
tailControls.scheduleHide();
} else {
tail.style.display = 'none';
}
}
currentTailAnchorEl = null;
if (currentListHoverCtx?.threadUrl === threadUrl) {
setCurrentListHover(null);
}
});
});
} catch (e) {
// 安全兜底,避免因站內腳本變動導致報錯
console.error('草榴Manager(search.php) 綁定標題預覽時出錯: ', e);
}
}
// 板塊圖文模式頁面(thread0806.php 圖文模式)
if (href.indexOf('thread0806.php') !== -1) {
// 圖文模式封面在 .wf_item .image-big img 中
// 鼠標懸停封面圖時,放大約 2 倍(約 100% 放大),並保證不被父元素裁切
injectStyle(`
.wf_item .image-big {
overflow: visible !important;
position: relative;
--clm-cover-scale: 1;
--clm-cover-extra-x: 0px;
--clm-cover-extra-y: 0px;
}
.wf_item .image-big img {
transition: transform 0.2s ease-in-out;
transform-origin: center center;
border-radius: 4px;
}
.wf_item .image-big:hover img {
transform: scale(2);
position: relative;
z-index: 9999;
box-shadow: 0 0 12px rgba(0, 0, 0, 0.7);
border: 2px solid #ffffff;
}
.wf_item .clm-cover-download {
position: absolute;
top: 8px;
right: 8px;
padding: 4px 8px;
font-size: 11px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.3);
background: rgba(0, 0, 0, 0.7);
color: #fff;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
z-index: 1;
transform-origin: top right;
transform: translate(
var(--clm-cover-extra-x, 0px),
calc(var(--clm-cover-extra-y, 0px) * -1)
) scale(var(--clm-cover-scale, 1));
transition: background 0.15s ease-in-out, transform 0.2s ease-in-out;
}
.wf_item .clm-cover-download.clm-downloaded {
background: rgba(16, 185, 129, 0.9);
border-color: rgba(255, 255, 255, 0.55);
}
.wf_item .clm-cover-download::before {
content: '⬇';
font-size: 11px;
}
.wf_item .image-big:hover .clm-cover-download {
z-index: 10001;
}
.wf_item .clm-cover-download:hover {
background: rgba(0, 0, 0, 0.9);
}
.wf_item .clm-cover-quality {
z-index: 1;
transform-origin: bottom left;
transform: translate(
calc(var(--clm-cover-extra-x, 0px) * -1),
var(--clm-cover-extra-y, 0px)
) scale(var(--clm-cover-scale, 1));
}
.wf_item .image-big:hover .clm-cover-quality {
z-index: 10002;
}
.wf_item .clm-text-quality {
position: relative;
left: auto;
bottom: auto;
display: inline-flex;
margin-top: 6px;
transform: none;
}
.wf_item.clm-thread-downloaded {
position: relative;
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.25);
border-radius: 6px;
}
.wf_item.clm-thread-downloaded::after {
content: '已下載';
position: absolute;
top: 8px;
left: 8px;
background: rgba(34, 197, 94, 0.85);
color: #fff;
font-size: 10px;
padding: 2px 6px;
border-radius: 999px;
letter-spacing: 0.08em;
z-index: 2;
}
`);
const COVER_SCALE = 2;
function attachCoverDownloadButtons() {
const covers = document.querySelectorAll('.wf_item .image-big');
covers.forEach(cover => {
if (cover.dataset.clmCoverBtnAttached === '1') return;
cover.dataset.clmCoverBtnAttached = '1';
const wfItem = cover.closest('.wf_item');
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'clm-cover-download';
const qualityBadge = document.createElement('div');
qualityBadge.className = 'clm-quality-badge clm-cover-quality';
qualityBadge.style.display = 'none';
cover.appendChild(qualityBadge);
const img = cover.querySelector('img');
const applyCoverButtonFollow = () => {
if (!img || !cover.matches(':hover')) return;
const baseWidth = img.clientWidth || img.naturalWidth;
const baseHeight = img.clientHeight || img.naturalHeight;
if (!baseWidth || !baseHeight) return;
const extraX = (baseWidth * (COVER_SCALE - 1)) / 2;
const extraY = (baseHeight * (COVER_SCALE - 1)) / 2;
cover.style.setProperty('--clm-cover-extra-x', `${extraX}px`);
cover.style.setProperty('--clm-cover-extra-y', `${extraY}px`);
cover.style.setProperty('--clm-cover-scale', `${COVER_SCALE}`);
};
const resetCoverButtonFollow = () => {
cover.style.removeProperty('--clm-cover-extra-x');
cover.style.removeProperty('--clm-cover-extra-y');
cover.style.removeProperty('--clm-cover-scale');
};
cover.addEventListener('mouseenter', applyCoverButtonFollow);
cover.addEventListener('mousemove', applyCoverButtonFollow);
cover.addEventListener('mouseleave', resetCoverButtonFollow);
if (img) {
img.addEventListener('load', () => {
if (cover.matches(':hover')) {
applyCoverButtonFollow();
}
});
}
cover.appendChild(btn);
let threadUrl = null;
const threadAnchor = cover.querySelector('a[href]') ||
wfItem?.querySelector('a[href]');
if (threadAnchor) {
const rawHref = threadAnchor.getAttribute('href') || threadAnchor.href;
threadUrl = getAbsoluteUrl(rawHref);
if (threadUrl) {
bindGalleryVisitedIndicator(cover, threadUrl, 'cover');
}
if (threadUrl) {
cover.addEventListener('mouseenter', () => {
const hoverQuality = ensureCoverQuality();
setCurrentListHover({
source: 'board',
threadUrl,
cover,
qualityTag: hoverQuality
});
});
cover.addEventListener('mouseleave', () => {
if (currentListHoverCtx?.cover === cover) {
setCurrentListHover(null);
}
});
}
}
const resolveCoverQuality = () => {
return resolveQualityTagFromListItem(wfItem, threadAnchor);
};
let cachedCoverQuality = null;
const ensureCoverQuality = () => {
cachedCoverQuality = resolveCoverQuality();
updateQualityBadgeElement(qualityBadge, cachedCoverQuality);
return cachedCoverQuality;
};
ensureCoverQuality();
setupThreadDownloadButton(btn, {
threadUrl,
container: wfItem,
containerClass: 'clm-thread-downloaded',
label: '下載',
downloadedLabel: '已下載',
threadTitle: (threadAnchor?.textContent || '').trim()
});
});
}
function attachTextOnlyQualityBadges() {
const items = document.querySelectorAll('.wf_item');
items.forEach(item => {
if (item.querySelector('.image-big')) {
return;
}
const threadAnchor = item.querySelector('a[href]');
const qualityTag = resolveQualityTagFromListItem(item, threadAnchor);
let badge = item.querySelector('.clm-text-quality');
if (!qualityTag) {
if (badge) {
badge.remove();
}
return;
}
const textContainer = item.querySelector('.wf_text');
if (!textContainer) {
return;
}
if (!badge) {
badge = document.createElement('div');
badge.className = 'clm-quality-badge clm-text-quality';
textContainer.appendChild(badge);
}
updateQualityBadgeElement(badge, qualityTag);
});
}
attachCoverDownloadButtons();
attachTextOnlyQualityBadges();
const coverObserver = new MutationObserver(() => {
attachCoverDownloadButtons();
attachTextOnlyQualityBadges();
});
coverObserver.observe(document.body, { childList: true, subtree: true });
}
/**
* -------------------------------
* 全局:右下角設置面板 & qBittorrent
* -------------------------------
*/
const STORAGE_KEY = '草榴ManagerSettings';
const DEFAULT_SETTINGS = {
qb: {
enabled: false,
baseUrl: '',
username: '',
password: '',
defaultCategory: '',
savePresets: [
{
id: 'default',
name: '預設下載目錄',
savePath: '',
tags: ''
}
]
}
};
function loadSettings() {
let settings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
if (parsed.qb) {
Object.assign(settings.qb, parsed.qb);
if (Array.isArray(parsed.qb.savePresets)) {
settings.qb.savePresets = parsed.qb.savePresets.map((preset) => Object.assign({}, preset));
}
}
}
}
} catch (e) {
console.error('草榴Manager: 設置讀取失敗,使用默認值', e);
settings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
}
normalizeSavePresets(settings);
return settings;
}
function saveSettings(settings) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch (e) {
console.error('草榴Manager: 設置保存失敗', e);
}
}
function normalizeSavePresets(settings) {
const list = Array.isArray(settings.qb.savePresets) ? settings.qb.savePresets : [];
if (!list.length) {
settings.qb.savePresets = DEFAULT_SETTINGS.qb.savePresets.map((preset) => Object.assign({}, preset));
return;
}
let mutated = false;
settings.qb.savePresets = list.map((preset, index) => {
const cloned = Object.assign({}, preset);
if (!cloned.id) {
cloned.id = index === 0 ? 'default' : `preset_${Date.now()}_${index}`;
mutated = true;
}
return cloned;
});
if (mutated) {
saveSettings(settings);
}
}
/**
* 向頁面注入右下角的設置按鈕與面板
*/
function createSettingsUI() {
injectStyle(`
.clm-settings-btn {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 10001;
background: rgba(0, 0, 0, 0.7);
color: #fff;
border-radius: 16px;
padding: 6px 12px;
cursor: pointer;
font-size: 12px;
line-height: 1.4;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
user-select: none;
}
.clm-settings-btn:hover {
background: rgba(0, 0, 0, 0.85);
}
.clm-settings-panel-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
}
.clm-settings-panel {
width: 420px;
max-width: 95vw;
max-height: 85vh;
background: #f5f5f5;
color: #333;
border-radius: 8px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
font-size: 12px;
display: flex;
flex-direction: column;
}
.clm-settings-header {
padding: 10px 12px;
border-bottom: 1px solid #ddd;
font-weight: bold;
display: flex;
align-items: center;
justify-content: space-between;
background: #fafafa;
}
.clm-settings-close {
cursor: pointer;
padding: 0 6px;
}
.clm-settings-body {
padding: 10px 12px;
overflow: auto;
}
.clm-settings-footer {
padding: 8px 12px;
border-top: 1px solid #ddd;
text-align: right;
background: #fafafa;
}
.clm-form-row {
margin-bottom: 8px;
}
.clm-form-row label {
display: block;
margin-bottom: 2px;
font-weight: bold;
}
.clm-form-row input[type="text"],
.clm-form-row input[type="password"] {
width: 100%;
box-sizing: border-box;
padding: 4px 6px;
border: 1px solid #ccc;
border-radius: 3px;
font-size: 12px;
}
.clm-form-row input[type="checkbox"] {
margin-right: 4px;
}
.clm-presets-list {
border: 1px solid #ddd;
border-radius: 4px;
padding: 6px;
background: #fff;
max-height: 200px;
overflow: auto;
}
.clm-test-row {
display: flex;
align-items: center;
gap: 8px;
}
.clm-test-status {
font-size: 11px;
color: #555;
}
.clm-test-status.clm-ok {
color: #15803d;
}
.clm-test-status.clm-failed {
color: #b91c1c;
}
.clm-preset-item {
border-bottom: 1px dashed #eee;
padding-bottom: 6px;
margin-bottom: 6px;
}
.clm-preset-item:last-child {
border-bottom: none;
padding-bottom: 0;
margin-bottom: 0;
}
.clm-preset-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
font-weight: bold;
}
.clm-small-btn {
display: inline-block;
padding: 2px 6px;
font-size: 11px;
border-radius: 3px;
border: 1px solid #aaa;
background: #f7f7f7;
cursor: pointer;
margin-left: 4px;
}
.clm-small-btn:hover {
background: #eee;
}
.clm-primary-btn {
border-color: #2b7cff;
background: #2b7cff;
color: #fff;
}
.clm-primary-btn:hover {
background: #1f5ecc;
}
.clm-log-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.clm-log-box {
max-height: 160px;
overflow: auto;
border: 1px solid #ddd;
border-radius: 4px;
padding: 6px;
background: #fff;
font-family: Consolas, monospace;
font-size: 11px;
line-height: 1.5;
}
.clm-log-entry {
display: flex;
gap: 6px;
border-bottom: 1px dotted #eee;
padding: 2px 0;
}
.clm-log-entry:last-child {
border-bottom: none;
}
.clm-log-time {
color: #6b7280;
flex: 0 0 52px;
}
.clm-log-message {
flex: 1;
color: #111827;
}
.clm-log-entry.clm-log-info .clm-log-message {
color: #1f2937;
}
.clm-log-entry.clm-log-success .clm-log-message {
color: #15803d;
}
.clm-log-entry.clm-log-warning .clm-log-message {
color: #b45309;
}
.clm-log-entry.clm-log-error .clm-log-message {
color: #b91c1c;
}
.clm-log-empty {
color: #9ca3af;
text-align: center;
padding: 8px 0;
}
`);
const btn = document.createElement('div');
btn.className = 'clm-settings-btn';
btn.textContent = '草榴Manager 設置';
btn.addEventListener('click', () => {
openSettingsPanel();
});
document.body.appendChild(btn);
}
function openSettingsPanel() {
const settings = loadSettings();
let logUnsubscribe = null;
let connectivityStatusEl = null;
function clearConnectivityStatus() {
if (!connectivityStatusEl) return;
connectivityStatusEl.textContent = '';
connectivityStatusEl.classList.remove('clm-ok', 'clm-failed');
}
const mask = document.createElement('div');
mask.className = 'clm-settings-panel-mask';
const panel = document.createElement('div');
panel.className = 'clm-settings-panel';
function closePanel() {
if (logUnsubscribe) {
logUnsubscribe();
logUnsubscribe = null;
}
if (mask.parentNode) {
mask.parentNode.removeChild(mask);
}
}
const header = document.createElement('div');
header.className = 'clm-settings-header';
header.innerHTML = '<span>草榴Manager 設置</span>';
const closeBtn = document.createElement('span');
closeBtn.className = 'clm-settings-close';
closeBtn.textContent = '✕';
closeBtn.addEventListener('click', () => {
closePanel();
});
header.appendChild(closeBtn);
const body = document.createElement('div');
body.className = 'clm-settings-body';
// qBittorrent 開關
const rowEnable = document.createElement('div');
rowEnable.className = 'clm-form-row';
const enableLabel = document.createElement('label');
const enableCheckbox = document.createElement('input');
enableCheckbox.type = 'checkbox';
enableCheckbox.checked = !!settings.qb.enabled;
enableCheckbox.addEventListener('change', () => clearConnectivityStatus());
enableLabel.appendChild(enableCheckbox);
enableLabel.appendChild(document.createTextNode('啟用 qBittorrent 集成'));
rowEnable.appendChild(enableLabel);
body.appendChild(rowEnable);
// 基本連接信息
body.appendChild(createInputRow('qBittorrent WebUI 地址(如:http://127.0.0.1:8080)', settings.qb.baseUrl, (val) => {
settings.qb.baseUrl = val.trim();
clearConnectivityStatus();
}));
body.appendChild(createInputRow('qBittorrent 用戶名', settings.qb.username, (val) => {
settings.qb.username = val.trim();
clearConnectivityStatus();
}));
const rowPwd = document.createElement('div');
rowPwd.className = 'clm-form-row';
const pwdLabel = document.createElement('label');
pwdLabel.textContent = 'qBittorrent 密碼';
const pwdInput = document.createElement('input');
pwdInput.type = 'password';
pwdInput.value = settings.qb.password || '';
pwdInput.addEventListener('input', () => {
settings.qb.password = pwdInput.value;
clearConnectivityStatus();
});
rowPwd.appendChild(pwdLabel);
rowPwd.appendChild(pwdInput);
body.appendChild(rowPwd);
body.appendChild(createInputRow('統一分類(category)', settings.qb.defaultCategory, (val) => {
settings.qb.defaultCategory = val.trim();
clearConnectivityStatus();
}));
const testRow = document.createElement('div');
testRow.className = 'clm-form-row clm-test-row';
const testBtn = document.createElement('button');
testBtn.type = 'button';
testBtn.className = 'clm-small-btn';
testBtn.textContent = '測試連通性';
const testStatus = document.createElement('span');
testStatus.className = 'clm-test-status';
connectivityStatusEl = testStatus;
testBtn.addEventListener('click', async () => {
clearConnectivityStatus();
testBtn.disabled = true;
testBtn.textContent = '測試中…';
try {
const snapshot = JSON.parse(JSON.stringify(settings));
snapshot.qb.enabled = enableCheckbox.checked;
const version = await testQbittorrentConnectivity(snapshot);
testStatus.textContent = '連通成功,版本:' + version;
testStatus.classList.add('clm-ok');
} catch (err) {
testStatus.textContent = '連通失敗:' + (err?.message || err);
testStatus.classList.add('clm-failed');
} finally {
testBtn.disabled = false;
testBtn.textContent = '測試連通性';
}
});
testRow.appendChild(testBtn);
testRow.appendChild(testStatus);
body.appendChild(testRow);
// 多個儲存位置預設
const presetsRow = document.createElement('div');
presetsRow.className = 'clm-form-row';
const presetsLabel = document.createElement('label');
presetsLabel.textContent = '儲存位置預設(可為每個路徑設置標籤)';
presetsRow.appendChild(presetsLabel);
const presetsWrap = document.createElement('div');
presetsWrap.className = 'clm-presets-list';
function renderPresets() {
presetsWrap.innerHTML = '';
settings.qb.savePresets.forEach((preset, index) => {
const item = document.createElement('div');
item.className = 'clm-preset-item';
const header = document.createElement('div');
header.className = 'clm-preset-item-header';
const title = document.createElement('span');
title.textContent = preset.name || ('預設路徑 ' + (index + 1));
const deleteBtn = document.createElement('span');
deleteBtn.className = 'clm-small-btn';
deleteBtn.textContent = '刪除';
deleteBtn.addEventListener('click', () => {
if (settings.qb.savePresets.length <= 1) {
alert('至少保留一個儲存位置。');
return;
}
settings.qb.savePresets.splice(index, 1);
renderPresets();
});
header.appendChild(title);
header.appendChild(deleteBtn);
item.appendChild(header);
item.appendChild(createInputRow('名稱', preset.name, (val) => {
preset.name = val;
title.textContent = val || ('預設路徑 ' + (index + 1));
}));
item.appendChild(createInputRow('儲存路徑(savepath)', preset.savePath, (val) => {
preset.savePath = val;
}));
item.appendChild(createInputRow('標籤(tags,逗號分隔)', preset.tags, (val) => {
preset.tags = val;
}));
presetsWrap.appendChild(item);
});
}
renderPresets();
const addPresetBtn = document.createElement('button');
addPresetBtn.className = 'clm-small-btn';
addPresetBtn.textContent = '新增儲存位置';
addPresetBtn.addEventListener('click', () => {
const id = 'preset_' + Date.now();
settings.qb.savePresets.push({
id,
name: '新建儲存位置',
savePath: '',
tags: ''
});
renderPresets();
});
presetsRow.appendChild(presetsWrap);
presetsRow.appendChild(addPresetBtn);
body.appendChild(presetsRow);
const logRow = document.createElement('div');
logRow.className = 'clm-form-row';
const logLabel = document.createElement('label');
logLabel.textContent = 'qBittorrent 調試日誌';
logRow.appendChild(logLabel);
const logToolbar = document.createElement('div');
logToolbar.className = 'clm-log-toolbar';
const logHint = document.createElement('span');
logHint.textContent = '僅保留最近 80 條記錄';
const clearLogBtn = document.createElement('button');
clearLogBtn.type = 'button';
clearLogBtn.className = 'clm-small-btn';
clearLogBtn.textContent = '清空日誌';
clearLogBtn.addEventListener('click', () => {
clearQbLogs();
showToast('日志已清空', 'success');
});
logToolbar.appendChild(logHint);
logToolbar.appendChild(clearLogBtn);
logRow.appendChild(logToolbar);
const logBox = document.createElement('div');
logBox.className = 'clm-log-box';
logRow.appendChild(logBox);
function renderLogs(entries) {
const logs = (entries || getQbLogs()).slice().reverse();
logBox.innerHTML = '';
if (!logs.length) {
const empty = document.createElement('div');
empty.className = 'clm-log-empty';
empty.textContent = '暫無日誌';
logBox.appendChild(empty);
return;
}
logs.forEach((log) => {
const item = document.createElement('div');
item.className = `clm-log-entry clm-log-${log.level || 'info'}`;
const timeEl = document.createElement('span');
timeEl.className = 'clm-log-time';
timeEl.textContent = formatLogTime(log.time);
const msgEl = document.createElement('span');
msgEl.className = 'clm-log-message';
msgEl.textContent = log.message;
item.appendChild(timeEl);
item.appendChild(msgEl);
logBox.appendChild(item);
});
}
renderLogs();
logUnsubscribe = subscribeQbLogs(renderLogs);
body.appendChild(logRow);
const footer = document.createElement('div');
footer.className = 'clm-settings-footer';
const saveBtn = document.createElement('button');
saveBtn.className = 'clm-small-btn clm-primary-btn';
saveBtn.textContent = '保存並關閉';
saveBtn.addEventListener('click', () => {
settings.qb.enabled = enableCheckbox.checked;
saveSettings(settings);
closePanel();
});
const cancelBtn = document.createElement('button');
cancelBtn.className = 'clm-small-btn';
cancelBtn.textContent = '取消';
cancelBtn.addEventListener('click', () => {
closePanel();
});
footer.appendChild(cancelBtn);
footer.appendChild(saveBtn);
panel.appendChild(header);
panel.appendChild(body);
panel.appendChild(footer);
mask.appendChild(panel);
mask.addEventListener('click', (e) => {
if (e.target === mask) {
closePanel();
}
});
document.body.appendChild(mask);
}
function createInputRow(labelText, value, onChange) {
const row = document.createElement('div');
row.className = 'clm-form-row';
const label = document.createElement('label');
label.textContent = labelText;
const input = document.createElement('input');
input.type = 'text';
input.value = value || '';
input.addEventListener('input', () => {
onChange(input.value);
});
row.appendChild(label);
row.appendChild(input);
return row;
}
function openPresetPickerDialog(settingsOverride) {
const settings = settingsOverride || loadSettings();
normalizeSavePresets(settings);
const presets = Array.isArray(settings.qb.savePresets) ? settings.qb.savePresets : [];
if (!settings.qb.enabled) {
showToast('請先啟用 qBittorrent 集成。', 'warning');
return Promise.resolve(null);
}
if (!presets.length) {
showToast('請先在設置中新增至少一個儲存位置。', 'warning');
return Promise.resolve(null);
}
return new Promise((resolve) => {
const mask = document.createElement('div');
mask.className = 'clm-preset-picker-mask';
const panel = document.createElement('div');
panel.className = 'clm-preset-picker';
const title = document.createElement('div');
title.className = 'clm-preset-picker-title';
title.textContent = '選擇儲存位置';
panel.appendChild(title);
const list = document.createElement('div');
list.className = 'clm-preset-picker-list';
presets.forEach((preset, index) => {
const option = document.createElement('button');
option.type = 'button';
option.className = 'clm-preset-picker-option';
const nameEl = document.createElement('strong');
nameEl.textContent = preset.name || ('預設路徑 ' + (index + 1));
const pathEl = document.createElement('span');
pathEl.textContent = preset.savePath || '使用 qBittorrent 默認路徑';
option.appendChild(nameEl);
option.appendChild(pathEl);
option.addEventListener('click', () => {
cleanup();
resolve(preset);
});
list.appendChild(option);
});
panel.appendChild(list);
const cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'clm-preset-picker-cancel';
cancelBtn.textContent = '取消';
cancelBtn.addEventListener('click', () => {
cleanup();
resolve(null);
});
panel.appendChild(cancelBtn);
mask.appendChild(panel);
document.body.appendChild(mask);
// 点击 mask 区域(但不是 panel)时关闭
mask.addEventListener('click', (ev) => {
if (ev.target === mask) {
cleanup();
resolve(null);
}
});
// 阻止 panel 内的点击事件冒泡到 mask
panel.addEventListener('click', (ev) => {
ev.stopPropagation();
});
function cleanup() {
if (mask.parentNode) {
mask.parentNode.removeChild(mask);
}
}
});
}
async function ensureQbittorrentLogin(baseUrl, qbSettings, options = {}) {
const { throwOnFail = false } = options;
if (!qbSettings?.username || !qbSettings?.password) {
return true;
}
const body = new URLSearchParams();
body.set('username', qbSettings.username);
body.set('password', qbSettings.password);
try {
const resp = await gmCompatibleFetch(baseUrl + '/api/v2/auth/login', {
method: 'POST',
credentials: 'include',
body
});
if (!resp.ok) {
const errMsg = 'HTTP ' + resp.status;
if (throwOnFail) {
throw new Error('登錄失敗:' + errMsg);
}
console.error('草榴Manager: qBittorrent 登錄失敗', errMsg);
return false;
}
const text = (await resp.text()).trim().toLowerCase();
if (text.includes('fail')) {
if (throwOnFail) {
throw new Error('登錄失敗,請確認帳號密碼');
}
console.error('草榴Manager: qBittorrent 登錄失敗 - 憑證錯誤');
return false;
}
return true;
} catch (err) {
if (throwOnFail) {
throw err;
}
console.error('草榴Manager: qBittorrent 登錄失敗', err);
return false;
}
}
async function testQbittorrentConnectivity(settings) {
if (!settings?.qb?.baseUrl) {
throw new Error('請先填寫 qBittorrent WebUI 地址。');
}
const base = settings.qb.baseUrl.replace(/\/+$/, '');
appendQbLog('開始測試 qBittorrent 連線:' + base, 'info');
await ensureQbittorrentLogin(base, settings.qb, { throwOnFail: true });
try {
const resp = await gmCompatibleFetch(base + '/api/v2/app/version', {
method: 'GET',
credentials: 'include'
});
if (!resp.ok) {
throw new Error('HTTP ' + resp.status);
}
const version = (await resp.text()).trim();
appendQbLog('連線測試成功,版本:' + (version || 'Unknown'), 'success');
return version || 'Unknown';
} catch (err) {
const msg = err?.message || err;
appendQbLog('連線測試失敗:' + msg, 'error');
throw new Error('連線失敗:' + msg);
}
}
/**
* 對外導出:調用 qBittorrent 添加種子 / 磁力的函數
* 可在 console 中調用:
* window.草榴ManagerSendToQb('magnet:?xt=urn:btih:....', '某個預設ID');
*/
async function sendToQbittorrent(torrentSource, presetId) {
const settings = loadSettings();
const sourceDescriptor = typeof torrentSource === 'string'
? torrentSource
: (torrentSource?.url || torrentSource?.filename || '');
const resourceLabel = summarizeResource(sourceDescriptor);
appendQbLog('收到下載請求:' + resourceLabel, 'info');
if (!settings.qb.enabled) {
appendQbLog('qBittorrent 集成未啟用,已取消請求。', 'warning');
showToast('請先在設置中啟用 qBittorrent 集成。', 'warning');
return false;
}
if (!settings.qb.baseUrl) {
appendQbLog('未配置 qBittorrent WebUI 地址,已取消請求。', 'warning');
showToast('請在設置中填寫 qBittorrent WebUI 地址。', 'warning');
return false;
}
const base = settings.qb.baseUrl.replace(/\/+$/, '');
appendQbLog('使用 WebUI 地址:' + base, 'info');
try {
await ensureQbittorrentLogin(base, settings.qb, { throwOnFail: true });
appendQbLog('與 qBittorrent 會話建立成功。', 'success');
} catch (err) {
const msg = err?.message || err;
appendQbLog('登入 qBittorrent 失敗:' + msg, 'error');
showToast('登入 qBittorrent 失敗:' + msg, 'error');
return false;
}
let preset = null;
if (presetId) {
preset = (settings.qb.savePresets || []).find(p => p.id === presetId);
}
if (!preset) {
preset = settings.qb.savePresets && settings.qb.savePresets[0];
}
if (!preset) {
appendQbLog('沒有可用的儲存位置預設,請先在設置中新增。', 'warning');
showToast('請先新增儲存位置預設。', 'warning');
return false;
}
appendQbLog('使用儲存預設:' + (preset.name || preset.id || '未命名') +
(preset.savePath ? ` | 路徑:${preset.savePath}` : ''), 'info');
const isBinaryPayload = typeof torrentSource === 'object' && !!torrentSource?.torrentBinary;
let torrentUrl = null;
let qbPayload = null;
if (isBinaryPayload) {
const buffer = ensureArrayBuffer(torrentSource.torrentBinary);
if (!buffer || !buffer.byteLength) {
appendQbLog('種子文件內容無效,已取消。', 'error');
showToast('種子文件內容無效,無法發送。', 'error');
return false;
}
const blob = new Blob([buffer], { type: 'application/x-bittorrent' });
qbPayload = new FormData();
qbPayload.append('torrents', blob, torrentSource.filename || 'download.torrent');
appendQbLog('已取得種子文件,準備以上傳方式提交。', 'info');
} else {
torrentUrl = typeof torrentSource === 'string' ? torrentSource : torrentSource?.url;
if (!torrentUrl) {
appendQbLog('缺少有效的下載地址,已取消。', 'error');
showToast('沒有可用的下載地址。', 'error');
return false;
}
qbPayload = new URLSearchParams();
qbPayload.append('urls', torrentUrl);
appendQbLog('將以遠程 URL 方式提交種子。', 'info');
}
const appendCommonField = (key, value) => {
if (!value) return;
qbPayload.append(key, value);
};
appendCommonField('category', settings.qb.defaultCategory);
appendCommonField('savepath', preset.savePath);
appendCommonField('tags', preset.tags);
appendQbLog(isBinaryPayload ? '正在以上傳方式向 qBittorrent 發送種子…' : '正在向 qBittorrent 發送下載請求…', 'info');
// 尝试发送请求,如果失败则重试一次(重新登录后)
let lastError = null;
for (let attempt = 0; attempt < 2; attempt++) {
if (attempt > 0) {
appendQbLog('請求失敗,嘗試重新登錄後重試…', 'info');
try {
await ensureQbittorrentLogin(base, settings.qb, { throwOnFail: true });
appendQbLog('重新登錄成功,正在重試…', 'success');
} catch (loginErr) {
appendQbLog('重新登錄失敗:' + (loginErr?.message || loginErr), 'error');
break;
}
}
try {
const resp = await gmCompatibleFetch(base + '/api/v2/torrents/add', {
method: 'POST',
credentials: 'include',
body: qbPayload
});
const bodyText = (await resp.text()).trim();
const lowered = bodyText.toLowerCase();
if (!resp.ok || lowered.includes('fail')) {
const msg = `HTTP ${resp.status}` + (bodyText ? `,響應:${bodyText}` : '');
lastError = msg;
// 检查是否是任务已存在的情况(HTTP 200 + "Fails.")
// qBittorrent 在任务已存在时会返回 HTTP 200 但响应内容是 "Fails."
if (resp.status === 200 && (bodyText === 'Fails.' || lowered === 'fails.')) {
// 这通常意味着任务已存在,不是真正的错误
appendQbLog('qBittorrent 回覆:任務可能已存在(' + bodyText + ')。請在 qBittorrent 客戶端檢查是否已有該下載任務。', 'warning');
showToast('任務可能已存在於 qBittorrent 中,請在客戶端確認。', 'warning');
return true; // 返回 true,因为这不是真正的错误
}
// 如果是第一次尝试且响应是"Fails.",尝试重新登录后重试
if (attempt === 0 && lowered.includes('fail') && resp.status === 200) {
appendQbLog('下載請求被拒:' + msg + ',將嘗試重新登錄後重試…', 'warning');
continue; // 重试
}
// 第二次尝试仍然失败,或者不是认证问题
let errorDetail = msg;
if (lowered.includes('fail')) {
if (!isBinaryPayload && torrentUrl) {
errorDetail += '。可能原因:1) 磁力鏈接無效或無法訪問;2) qBittorrent 無法連接到該 URL;3) 種子文件已損壞。';
} else if (isBinaryPayload) {
errorDetail += '。可能原因:1) 種子文件格式錯誤或已損壞;2) qBittorrent 配置問題。';
} else {
errorDetail += '。請檢查 qBittorrent 設置和日誌。';
}
}
appendQbLog('下載請求被拒:' + errorDetail, 'error');
showToast('發送到 qBittorrent 失敗:' + msg, 'error');
return false;
}
// 成功
appendQbLog('qBittorrent 回覆成功:' + (bodyText || 'Ok'), 'success');
showToast('已提交至 qBittorrent,請在客戶端確認。', 'success');
// 在成功响应后 0.5 秒触发跳转到广告页面
setTimeout(() => {
try {
// 获取 iframe(通过全局变量或参数传递)
const frame = window.clmInlineDownloadWindow?.getFrame?.();
if (!frame) {
console.warn('草榴Manager: 无法获取 iframe,跳转可能不会发生');
return;
}
const iframeWindow = frame.contentWindow;
const iframeDoc = frame.contentDocument || iframeWindow.document;
// 提取 poData
let poData = null;
// 方法1: 直接从 window 对象获取
if (iframeWindow.poData && Array.isArray(iframeWindow.poData) && iframeWindow.poData.length > 0) {
poData = iframeWindow.poData;
} else {
// 方法2: 从 script 标签中提取 poData
const scripts = iframeDoc.querySelectorAll('script');
for (let script of scripts) {
const scriptText = script.textContent || script.innerHTML;
// 查找 poJson 的定义(支持单引号和双引号)
const poJsonMatch = scriptText.match(/var\s+poJson\s*=\s*['"]([^'"]+)['"]/);
if (poJsonMatch) {
try {
// 处理转义字符
let poJson = poJsonMatch[1]
.replace(/\\\//g, '/')
.replace(/\\"/g, '"')
.replace(/\\'/g, "'")
.replace(/\\\\/g, '\\');
const parsed = JSON.parse(poJson);
if (Array.isArray(parsed) && parsed.length > 0) {
poData = parsed;
break;
}
} catch (e) {
console.warn('草榴Manager: 解析 poJson 失败', e);
}
}
}
// 方法3: 如果 poData 为空,尝试从 rmData 获取
if (!poData && iframeWindow.rmData && Array.isArray(iframeWindow.rmData) && iframeWindow.rmData.length > 0) {
poData = iframeWindow.rmData;
}
}
// 如果找到了 poData,触发跳转
if (poData && poData.length > 0) {
const randomIndex = Math.floor(Math.random() * poData.length);
const adUrl = poData[randomIndex].u;
if (adUrl) {
console.log('草榴Manager: qBittorrent 成功响应,跳转到广告页面:', adUrl);
iframeWindow.location.href = adUrl;
}
} else {
console.warn('草榴Manager: 未找到 poData,跳转可能不会发生');
}
} catch (e) {
console.warn('草榴Manager: 跳转失败', e);
}
}, 500); // 0.5秒后触发
return true;
} catch (e) {
const msg = e?.message || e;
lastError = msg;
console.error('草榴Manager: 發送到 qBittorrent 時出錯', e);
// 如果是第一次尝试,且可能是认证问题,则重试
if (attempt === 0 && (msg.includes('401') || msg.includes('403') || msg.includes('認證') || msg.includes('登錄'))) {
appendQbLog('發送過程發生錯誤:' + msg + ',將嘗試重新登錄後重試…', 'warning');
continue; // 重试
}
// 其他错误或第二次尝试失败
appendQbLog('發送過程發生錯誤:' + msg, 'error');
showToast('發送到 qBittorrent 時出錯:' + msg, 'error');
return false;
}
}
// 如果所有重试都失败
if (lastError) {
appendQbLog('重試後仍然失敗:' + lastError, 'error');
showToast('發送到 qBittorrent 失敗:' + lastError, 'error');
}
return false;
}
// 對外暴露到 window,方便後續與頁面其他腳本集成
pageWindow.草榴ManagerSendToQb = sendToQbittorrent;
// 在所有匹配頁面中創建右下角設置入口
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', createSettingsUI);
} else {
createSettingsUI();
}
})();