// ==UserScript== // @name Pixiv小说搜索/排行优化 // @namespace local.pixiv.novel.enhanced.pro // @version 4.0 // @description 4.0: 移动端体验优化(精简常驻说明文字改用tooltip/占位文案承载,悬浮按钮缩小,长标签超长部分省略号截断) | 语言筛选(排行与搜索均支持,持久化记住选择) | 关键词屏蔽(命中标题/标签隐藏,含Pixiv原生页面拦截与标签页快速屏蔽按钮) | 排行榜区间抓取(日/周/月/新人榜,跨期聚合上榜次数与最好名次) | 关键词搜索抓取(自动识别真实页数,断点续抓,停止保留已抓) | HTML/CSV导出、深色模式、智能限速、缓存分片 // @author you // @match https://www.pixiv.net/search* // @match https://www.pixiv.net/*/search* // @match https://www.pixiv.net/*novels* // @match https://www.pixiv.net/novel/search.php* // @match https://www.pixiv.net/novel/ranking.php* // @match https://www.pixiv.net/tags/* // @icon https://www.pixiv.net/favicon.ico // @require https://cdn.jsdelivr.net/npm/lz-string@1.5.0/libs/lz-string.min.js // @grant none // @run-at document-idle // @license MIT // ==/UserScript== (function () { 'use strict'; // ==================== 配置常量 ==================== const CONFIG = { CACHE_PREFIX: 'pxNovelEnhanced:', CACHE_SCHEMA: 2, SEARCH_CACHE_TTL_MS: 30 * 60 * 1000, RANK_FRESH_TTL_MS: 30 * 60 * 1000, // 最近几天的榜单可能还在变,短缓存 RANK_FRESH_DAYS: 3, // 超过这个天数的榜单视为历史数据,永不过期 MAX_CACHE_BYTES: 4 * 1024 * 1024, RETRY_TIMES: 3, CONCURRENCY: 3, // 搜索:同一关键词内并发 RANK_PAGE_CONCURRENCY: 2, // 排行:同一期内的分页并发(期与期之间必须串行) ENRICH_CONCURRENCY: 2, // 补全收藏数的并发 DEFAULT_LOAD_MORE_SIZE: 10, RANK_LOAD_MORE_PERIODS: 5, RANK_MAX_PERIODS: 120, RANK_MAX_PAGES_PER_PERIOD: 10, VIRTUAL_THRESHOLD: 50, MOBILE_BREAKPOINT: 768, REQUEST_HISTORY_SIZE: 5, MIN_DELAY: 200, MAX_DELAY: 1500, RANK_MIN_DELAY: 500, // 排行抓取请求量大,基线间隔更保守 PAGE_SIZE_FALLBACK: 24, DEFAULT_SORT_FIELD: 'bookmarkCount', DEFAULT_SORT_ASC: false, PANEL_Z_INDEX: 999998, BUTTON_Z_INDEX: 999999, }; // 注:本脚本用到的都是 Pixiv 网页内部的私有接口(/ajax/search/novels 与 novel/ranking.php?format=json), // 非官方公开 API,字段结构与限流策略可能随时变化。请求量越大越容易触发风控, // 排行榜区间抓取尤其如此——请勿把期数和并发调得过大。 const RANK_MODES = [ { value: 'daily', label: '每日排行' }, { value: 'weekly', label: '每周排行' }, { value: 'monthly', label: '每月排行' }, { value: 'rookie', label: '新人排行' }, { value: 'daily_r18', label: '每日排行 (R-18)' }, { value: 'weekly_r18', label: '每周排行 (R-18)' }, ]; const rankModeLabel = (m) => (RANK_MODES.find(x => x.value === m) || {}).label || m; const rankStepDays = (mode) => (mode.startsWith('weekly') ? 7 : mode.startsWith('monthly') ? 30 : 1); // ==================== 错误类型 ==================== class AbortError extends Error { constructor() { super('Aborted'); this.name = 'AbortError'; } } class AuthError extends Error { constructor(msg) { super(msg); this.name = 'AuthError'; } } // 明确的"没有数据/不该重试"错误:比如该日期没有榜单、返回的不是 JSON class NoDataError extends Error { constructor(msg) { super(msg); this.name = 'NoDataError'; } } // ==================== 工具函数 ==================== const getQueryParam = (name) => new URL(location.href).searchParams.get(name); const sleep = (ms, signal) => new Promise((resolve, reject) => { if (signal?.aborted) { reject(new AbortError()); return; } const cleanup = () => { if (signal) signal.removeEventListener('abort', onAbort); }; const onAbort = () => { clearTimeout(timer); cleanup(); reject(new AbortError()); }; const timer = setTimeout(() => { cleanup(); resolve(); }, ms); if (signal) signal.addEventListener('abort', onAbort); }); const escapeHtml = (str) => String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); const estimateBytes = (str) => str.length * 2; const formatTime = (seconds) => { if (seconds < 60) return Math.round(seconds) + 's'; const m = Math.floor(seconds / 60); const s = Math.round(seconds % 60); return `${m}m ${s}s`; }; const normalizeCoverUrl = (url) => { if (!url) return ''; if (typeof url === 'object') { // 排行榜接口有时把封面给成 { "240mw": "...", "128x128": "..." } 这种形式 const first = Object.values(url).find(v => typeof v === 'string' && v); url = first || ''; } if (!url) return ''; if (/^https?:\/\//i.test(url)) return url; if (url.startsWith('//')) return 'https:' + url; if (url.startsWith('/')) return 'https://i.pximg.net' + url; return url; }; const isMobile = () => window.innerWidth < CONFIG.MOBILE_BREAKPOINT; const isDarkMode = () => window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; const diffPages = (targetPages, knownPages) => { const known = new Set(knownPages); return targetPages.filter(p => !known.has(p)).sort((a, b) => a - b); }; const reachedEnd = (fetchedPages, maxPage) => maxPage != null && fetchedPages.length > 0 && Math.max(...fetchedPages) >= maxPage; // 取第一个有意义的值(用于兼容多种可能的字段名) const pick = (...vals) => vals.find(v => v !== undefined && v !== null && v !== ''); const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : null; }; // ---- 日期工具(排行榜的 date 参数是 YYYYMMDD) ---- const ymd = (d) => `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`; const parseYmd = (s) => { if (!/^\d{8}$/.test(String(s))) return null; return new Date(Number(s.slice(0, 4)), Number(s.slice(4, 6)) - 1, Number(s.slice(6, 8))); }; const prettyYmd = (s) => (/^\d{8}$/.test(String(s)) ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : String(s || '')); // prev_date 拿不到时的兜底步进 const stepBackDate = (mode, dateStr) => { const d = parseYmd(dateStr); if (!d) return null; d.setDate(d.getDate() - rankStepDays(mode)); return ymd(d); }; const isRecentRankDate = (dateStr) => { const d = parseYmd(dateStr); if (!d) return true; return (Date.now() - d.getTime()) < CONFIG.RANK_FRESH_DAYS * 24 * 3600 * 1000; }; // 把 "2021年01月01日 00:00" 之类的日期串尽量转成可比较的时间 const parseLooseDate = (raw) => { if (!raw) return ''; if (typeof raw === 'number') return new Date(raw * (raw > 1e11 ? 1 : 1000)).toISOString(); const m = String(raw).match(/(\d{4})\D+(\d{1,2})\D+(\d{1,2})(?:\D+(\d{1,2})\D+(\d{1,2}))?/); if (m) { const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4] || 0), Number(m[5] || 0)); if (!isNaN(d.getTime())) return d.toISOString(); } const t = Date.parse(raw); return isNaN(t) ? '' : new Date(t).toISOString(); }; // ==================== 深色模式管理 ==================== let darkModeListeners = []; const onDarkModeChange = (callback) => { if (!window.matchMedia) return; const mql = window.matchMedia('(prefers-color-scheme: dark)'); const handler = (e) => callback(e.matches); mql.addEventListener('change', handler); darkModeListeners.push({ mql, handler }); }; const cleanupDarkModeListeners = () => { darkModeListeners.forEach(({ mql, handler }) => mql.removeEventListener('change', handler)); darkModeListeners = []; }; // ==================== 缓存(搜索按关键词分片,排行按"期"分片) ==================== const cacheWriteLocks = new Map(); const withCacheLock = (key, fn) => { const prev = cacheWriteLocks.get(key) || Promise.resolve(); const next = prev.then(fn, fn).finally(() => { if (cacheWriteLocks.get(key) === next) cacheWriteLocks.delete(key); }); cacheWriteLocks.set(key, next); return next; }; const decompressAsync = (raw) => new Promise((resolve) => { const run = () => { try { const decompressed = LZString.decompressFromUTF16(raw); resolve(decompressed ? JSON.parse(decompressed) : null); } catch (e) { resolve(null); } }; if (window.requestIdleCallback) requestIdleCallback(run, { timeout: 300 }); else setTimeout(run, 0); }); // validator: (data) => bool;ttlOf: (data) => 毫秒数,返回 Infinity 表示永不过期 const loadCacheAsync = async (key, validator, ttlOf) => { const raw = localStorage.getItem(key); if (!raw) return null; const data = await decompressAsync(raw); if (!data || data.schema !== CONFIG.CACHE_SCHEMA) return null; if (validator && !validator(data)) return null; const ttl = ttlOf ? ttlOf(data) : Infinity; if (ttl !== Infinity && Date.now() - data.timestamp > ttl) { localStorage.removeItem(key); return null; } return data; }; const loadSearchCache = (key) => loadCacheAsync( key, (d) => Array.isArray(d.fetchedPages), () => CONFIG.SEARCH_CACHE_TTL_MS ); const loadRankCache = (key) => loadCacheAsync( key, (d) => Array.isArray(d.items) && !!d.date, (d) => (isRecentRankDate(d.date) ? CONFIG.RANK_FRESH_TTL_MS : Infinity) ); const writeCache = (key, payloadFn) => withCacheLock(key, async () => { const next = payloadFn(); if (!next) return null; const payload = { ...next, schema: CONFIG.CACHE_SCHEMA, timestamp: Date.now() }; try { const serialized = JSON.stringify(payload); let compressed = LZString.compressToUTF16(serialized); if (estimateBytes(compressed) > CONFIG.MAX_CACHE_BYTES) { await cleanOldCachesAsync(key); compressed = LZString.compressToUTF16(serialized); if (estimateBytes(compressed) > CONFIG.MAX_CACHE_BYTES) { console.warn('[Pixiv优化] 单条缓存过大,放弃保存:', key); return payload; } } localStorage.setItem(key, compressed); } catch (e) { if (e.name === 'QuotaExceededError') { console.warn('[Pixiv优化] localStorage 空间不足,清理旧缓存后重试一次'); await cleanOldCachesAsync(key); try { localStorage.setItem(key, LZString.compressToUTF16(JSON.stringify(payload))); } catch (e2) { console.warn('[Pixiv优化] 清理后仍无法写入,本次结果仅保留在内存中:', e2); } } else { console.warn('[Pixiv优化] 缓存写入失败:', e); } } return payload; }); const cleanOldCachesAsync = async (excludeKey) => { try { const keys = Object.keys(localStorage).filter(k => k.startsWith(CONFIG.CACHE_PREFIX)); if (keys.length < 2) return; const items = []; for (const k of keys) { const raw = localStorage.getItem(k); if (!raw) { items.push({ key: k, timestamp: 0, size: 0 }); continue; } const data = await decompressAsync(raw); items.push({ key: k, timestamp: data?.timestamp || 0, size: raw.length }); } items.sort((a, b) => a.timestamp - b.timestamp); let totalBytes = items.reduce((s, it) => s + it.size * 2, 0); for (const item of items) { if (item.key === excludeKey) continue; if (totalBytes <= CONFIG.MAX_CACHE_BYTES) break; localStorage.removeItem(item.key); totalBytes -= item.size * 2; } } catch (e) { /* 清理失败不影响主流程 */ } }; // ==================== 关键词屏蔽 ==================== // 独立存储,key 名故意不带 CONFIG.CACHE_PREFIX(不带冒号),这样现有的缓存清理 // (cleanOldCachesAsync 按前缀扫描、配额不足时淘汰最旧一批)永远碰不到这份数据—— // 不会出现"搜索缓存写满了,把屏蔽词列表当成最旧的垃圾一起清掉"这种事。 // 数据量很小(几十个词封顶),不需要像搜索/排行缓存那样压缩,读写都是同步的。 // 只匹配标题和标签,不含作者名——常见字当屏蔽词容易连累某个用户名带这个字的作者。 const BLOCKLIST_KEY = 'pxNovelEnhancedBlocklist'; const loadBlocklist = () => { try { const raw = localStorage.getItem(BLOCKLIST_KEY); const arr = raw ? JSON.parse(raw) : []; return Array.isArray(arr) ? arr.filter(x => typeof x === 'string' && x.trim()) : []; } catch (e) { console.warn('[Pixiv优化] 屏蔽词列表读取失败,视为空列表:', e); return []; } }; let blocklist = loadBlocklist(); const saveBlocklist = () => { try { localStorage.setItem(BLOCKLIST_KEY, JSON.stringify(blocklist)); } catch (e) { console.warn('[Pixiv优化] 屏蔽词列表保存失败:', e); } }; // 大小写不敏感去重("NTR" 和 "ntr" 视为同一个词),但保留用户输入时的原始大小写用于显示 const addBlockTerm = (raw) => { const term = String(raw || '').trim(); if (!term) return false; const lower = term.toLowerCase(); if (blocklist.some(t => t.toLowerCase() === lower)) return false; blocklist.push(term); saveBlocklist(); reapplyAllBlocking(); if (typeof renderTagQuickBlockButton === 'function') renderTagQuickBlockButton(); return true; }; const removeBlockTerm = (term) => { const before = blocklist.length; blocklist = blocklist.filter(t => t !== term); if (blocklist.length !== before) { saveBlocklist(); reapplyAllBlocking(); if (typeof renderTagQuickBlockButton === 'function') renderTagQuickBlockButton(); } }; // ==================== 语言筛选(持久化,不像收藏数/字数筛选那样每次重置) ==================== // 起因:Pixiv 原生页面的"作品语言"筛选大概率是纯前端过滤——接口把所有语言 // 混在一起返回(实测数据里每条都带 language 字段,如 "zh-cn"),Pixiv 自己 // 的页面只是按这个字段在浏览器里筛,从没真的把"只要中文"这个条件发给服务器。 // 所以我们这边同样只能在拿到数据之后自己按这个字段过滤,跟屏蔽词是同一个 // 机制。目前只在排行榜数据里实测确认过这个字段,关键词搜索那边字段名未知, // 没有实测验证前不瞎猜,所以 allowed 为空集合时不影响没有 language 值的条目。 const LANG_FILTER_KEY = 'pxNovelEnhancedLangFilter'; const LANG_LABELS = { 'zh-cn': '简体中文', 'zh-tw': '繁体中文', ja: '日语', en: '英语', ko: '韩语' }; const langLabel = (code) => LANG_LABELS[code] || code; const loadAllowedLanguages = () => { try { const raw = localStorage.getItem(LANG_FILTER_KEY); if (!raw) return null; // null = 不限制,显示全部语言(默认状态) const arr = JSON.parse(raw); return Array.isArray(arr) && arr.length ? new Set(arr) : null; } catch (e) { return null; } }; let allowedLanguages = loadAllowedLanguages(); const saveAllowedLanguages = () => { try { localStorage.setItem(LANG_FILTER_KEY, JSON.stringify(allowedLanguages ? [...allowedLanguages] : [])); } catch (e) { console.warn('[Pixiv优化] 语言筛选保存失败:', e); } }; // 只在"确实知道这条数据的语言、且不在允许列表里"时才排除;language 为空 // (比如搜索结果暂未采集这个字段)一律放行,不能因为不知道就误杀 const isLanguageBlocked = (item) => { if (!allowedLanguages || allowedLanguages.size === 0) return false; if (!item.language) return false; return !allowedLanguages.has(item.language); }; // 命中标题或任意一个标签(子串匹配,不区分大小写)即视为屏蔽 const isBlocked = (item) => { if (!blocklist.length) return false; const title = (item.title || '').toLowerCase(); const tags = Array.isArray(item.tags) ? item.tags.map(t => String(t).toLowerCase()) : []; return blocklist.some(term => { const t = term.toLowerCase(); if (!t) return false; return title.includes(t) || tags.some(tag => tag.includes(t)); }); }; // ==================== 原生页面屏蔽(不依赖打开面板) ==================== // 上面的 isBlocked() 服务于我们自己抓取的数据;这里是完全独立的另一件事: // 直接在 Pixiv 原生搜索/排行/标签页面上,把命中屏蔽词的小说卡片整个隐藏掉, // 平时正常刷页面(不开面板)也生效。 // // 核心困难:Pixiv 是打包过的前端框架,卡片的 class 名是随构建生成的哈希, // 每次前端更新都可能变,没法像解析 JSON 接口那样照抄字段名硬编码选择器。 // 所以这里用一个不依赖具体 class 名的通用算法: // 1) 用 /novel/show.php?id= 这个稳定不变的 URL 规则找到所有小说链接 // (缩略图链接、标题链接可能是同一个 id 上的两个不同元素) // 2) 对同一个 id 的所有链接,找它们在 DOM 树里的"最近公共祖先"—— // 这个节点基本就是这张卡片的紧凑边界 // 3) 如果边界太紧(比如漏了收藏数那一行),向上爬,直到爬到"这一层 // 在父节点里有多个同类型兄弟节点"为止——这是"这是一个列表项"的 // 通用信号,不管 Pixiv 怎么改版,列表结构都是这个模式 // 4) 提取这张卡片里的标题文字和标签,交给 isBlocked() 判断,命中就 // display:none(不是 visibility:hidden,这样才能把空间让出来, // 页面变紧凑,无限滚动触发下一批加载也会更快) // // 这是启发式算法,不是照着实测 DOM 写的精确匹配,可能偶尔藏得不够干净 // (留一点小空隙)——如果发生这种情况,把截图发给开发者,照实际结构调整。 const NOVEL_ID_RE = /\/novel\/show\.php\?[^"']*\bid=(\d+)/; const cardRegistry = new Map(); // 小说id -> { root, title, tags } const HIDDEN_ATTR = 'data-px-blocked'; function extractNovelId(href) { if (!href) return null; const m = String(href).match(NOVEL_ID_RE); return m ? m[1] : null; } function nearestCommonAncestor(nodes) { if (nodes.length === 0) return null; if (nodes.length === 1) return nodes[0]; const chains = nodes.map(n => { const chain = []; let cur = n; while (cur) { chain.unshift(cur); cur = cur.parentElement; } return chain; }); const minLen = Math.min(...chains.map(c => c.length)); let common = null, i = 0; while (i < minLen && chains.every(c => c[i] === chains[0][i])) { common = chains[0][i]; i++; } return common; } // 判断"这一层是不是就是重复的卡片",不能靠"同类标签数量"——同一张卡片内部 // 天生就可能有好几个 div(缩略图容器、信息容器…),数量凑巧 >=2 但根本不是 // "重复的卡片",这正是之前留缝的原因。真正靠谱的信号是:兄弟节点里是不是 // 装着完全不同的另一部作品——同一张卡片内部的两个区块必然指向同一个小说 id, // 只有两张真正不同的卡片之间,兄弟节点里的 id 才会不一样。 function siblingHasOtherNovelId(el, ownId) { if (!el.parentElement) return false; for (const sib of el.parentElement.children) { if (sib === el) continue; const a = sib.querySelector && sib.querySelector('a[href*="/novel/show.php"]'); if (!a) continue; const id = extractNovelId(a.getAttribute('href')); if (id && id !== ownId) return true; } return false; } // 安全阀:确认这一层内部仍然只属于同一部作品,没有不小心爬过界、把另一部 // 作品也包进来(如果包进来了,说明爬多了,要退回上一层安全的结果) function belongsToSingleNovel(el, ownId) { if (!el.querySelectorAll) return true; const anchors = el.querySelectorAll('a[href*="/novel/show.php"]'); for (const a of anchors) { const id = extractNovelId(a.getAttribute('href')); if (id && id !== ownId) return false; } return true; } function refineCardRoot(nca, ownId) { let el = nca, hops = 0, lastSafe = nca; while (el && hops < 10) { if (!belongsToSingleNovel(el, ownId)) return lastSafe; // 爬过界了,退回上一层 if (siblingHasOtherNovelId(el, ownId)) return el; // 兄弟节点是别的作品,这层就是卡片边界 lastSafe = el; if (!el.parentElement || el.parentElement === document.body) break; el = el.parentElement; hops++; } return lastSafe; // 十层内没找到明确边界,用爬到的最后一个安全层兜底 } function extractCardTitle(root, anchors) { let best = ''; anchors.forEach(a => { const t = (a.textContent || '').trim(); if (t.length > best.length) best = t; }); if (best) return best; const img = root.querySelector && root.querySelector('img[alt]'); if (img && img.alt && img.alt.trim()) return img.alt.trim(); // 兜底:卡片内第一段看起来像标题的文字(排除纯数字/纯符号,比如收藏数、日期) if (document.createTreeWalker) { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); let node; while ((node = walker.nextNode())) { const t = node.textContent.trim(); if (t.length > 1 && !/^[\d,,.\s]+$/.test(t)) return t; } } return ''; } function extractCardTags(root) { if (!root.querySelectorAll) return []; return Array.from(root.querySelectorAll('a[href*="/tags/"]')) .map(a => (a.textContent || '').replace(/^#/, '').trim()) .filter(Boolean); } function applyHiddenState(id) { const entry = cardRegistry.get(id); if (!entry || !entry.root || !entry.root.isConnected) { cardRegistry.delete(id); return; } const blocked = isBlocked(entry); const already = entry.root.hasAttribute(HIDDEN_ATTR); if (blocked && !already) { entry.root.setAttribute(HIDDEN_ATTR, '1'); entry.root.style.display = 'none'; } else if (!blocked && already) { entry.root.removeAttribute(HIDDEN_ATTR); entry.root.style.display = ''; } } function reapplyAllBlocking() { cardRegistry.forEach((_, id) => applyHiddenState(id)); } function scanNativePage() { // 先清掉已经从页面上消失的旧登记(比如 Pixiv 客户端路由切换到了新的搜索结果), // 否则同一个 id 以后哪怕真的重新出现在页面上也会被误判成"已登记过"而跳过 cardRegistry.forEach((entry, id) => { if (!entry.root || !entry.root.isConnected) cardRegistry.delete(id); }); const anchors = document.querySelectorAll('a[href*="/novel/show.php"]'); const byId = new Map(); anchors.forEach(a => { const id = extractNovelId(a.getAttribute('href')); if (!id || cardRegistry.has(id)) return; // 已登记过的跳过,不重复计算 if (!byId.has(id)) byId.set(id, []); byId.get(id).push(a); }); byId.forEach((anchorList, id) => { const nca = nearestCommonAncestor(anchorList); if (!nca) return; const root = refineCardRoot(nca, id); const entry = { root, title: extractCardTitle(root, anchorList), tags: extractCardTags(root) }; cardRegistry.set(id, entry); applyHiddenState(id); }); } let nativeScanScheduled = false; const scheduleNativeScan = () => { if (nativeScanScheduled) return; nativeScanScheduled = true; const run = () => { nativeScanScheduled = false; scanNativePage(); }; if (window.requestIdleCallback) requestIdleCallback(run, { timeout: 600 }); else setTimeout(run, 250); }; const startNativeBlocking = () => { scheduleNativeScan(); // 首次进入页面先扫一遍 // Pixiv 是无限滚动加载,新卡片会不断被塞进 DOM,靠 MutationObserver 捕获 const observer = new MutationObserver((mutations) => { for (const m of mutations) { if (m.addedNodes && m.addedNodes.length) { scheduleNativeScan(); break; } } }); observer.observe(document.body, { childList: true, subtree: true }); }; // ==================== 标签页快速屏蔽悬浮按钮 ==================== // 点了 Pixiv 上某个标签会跳到 /tags/<标签名>/... 这个页面,标签名字直接写在 // 网址里,不用去猜 DOM 结构就能 100% 准确拿到——比"在页面里找个位置塞屏蔽 // 按钮"简单可靠得多。按钮兼顾两个方向:还没屏蔽就点一下加入,已经屏蔽了 // 再点一下就是移除,状态跟面板里的管理列表实时同步。 const TAG_BLOCK_BTN_ID = 'px-tag-block-btn'; const extractTagFromUrl = () => { const m = location.pathname.match(/\/tags\/([^/]+)/); return m ? decodeURIComponent(m[1]) : null; }; const isTermBlocked = (term) => { const lower = term.toLowerCase(); return blocklist.some(t => t.toLowerCase() === lower); }; function renderTagQuickBlockButton() { const tag = extractTagFromUrl(); const existing = document.getElementById(TAG_BLOCK_BTN_ID); if (!tag) { if (existing) existing.remove(); return; } let btn = existing; if (!btn) { btn = document.createElement('button'); btn.id = TAG_BLOCK_BTN_ID; Object.assign(btn.style, { position: 'fixed', right: '14px', bottom: '114px', zIndex: CONFIG.BUTTON_Z_INDEX, padding: '5px 10px', borderRadius: '14px', border: 'none', color: '#fff', fontSize: '11px', fontWeight: '600', cursor: 'pointer', boxShadow: '0 3px 8px rgba(0,0,0,0.25)', transition: 'transform 0.2s', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', }); btn.addEventListener('mouseenter', () => btn.style.transform = 'scale(1.05)'); btn.addEventListener('mouseleave', () => btn.style.transform = 'scale(1)'); document.body.appendChild(btn); } const blocked = isTermBlocked(tag); btn.textContent = blocked ? `✅ 取消屏蔽「${tag}」` : `🚫 屏蔽「${tag}」`; btn.style.background = blocked ? 'linear-gradient(135deg, #28a745, #1e7e34)' : 'linear-gradient(135deg, #ff6b6b, #cc4444)'; btn.onclick = () => { if (isTermBlocked(tag)) removeBlockTerm(tag); else addBlockTerm(tag); renderTagQuickBlockButton(); // 立即切换按钮文案和颜色 }; } // Pixiv 点标签跳转不一定每次都整页刷新(可能是前端路由),轮询网址变化, // 保证从一个标签页跳到另一个标签页时,这个按钮也能跟着换成新标签 let lastSeenUrl = location.href; setInterval(() => { if (location.href !== lastSeenUrl) { lastSeenUrl = location.href; renderTagQuickBlockButton(); } }, 800); const countRankCache = (mode) => { const prefix = `${CONFIG.CACHE_PREFIX}rank:${mode}:`; return Object.keys(localStorage).filter(k => k.startsWith(prefix)).length; }; const clearRankCache = (mode) => { const prefix = `${CONFIG.CACHE_PREFIX}rank:${mode}:`; Object.keys(localStorage).filter(k => k.startsWith(prefix)).forEach(k => localStorage.removeItem(k)); }; // ==================== 网络层(搜索与排行共用限速/重试) ==================== const requestHistory = []; let currentDelay = 300; let delayFloor = CONFIG.MIN_DELAY; const setDelayFloor = (v) => { delayFloor = v; currentDelay = Math.max(currentDelay, v); }; const updateDelay = (elapsed) => { requestHistory.push(elapsed); if (requestHistory.length > CONFIG.REQUEST_HISTORY_SIZE) requestHistory.shift(); if (requestHistory.length >= 3) { const avg = requestHistory.reduce((a, b) => a + b, 0) / requestHistory.length; if (avg > 2000) currentDelay += 100; else if (avg < 800) currentDelay -= 50; currentDelay = Math.min(CONFIG.MAX_DELAY, Math.max(delayFloor, currentDelay)); } }; const fetchJson = async (url, signal) => { let lastError; for (let attempt = 1; attempt <= CONFIG.RETRY_TIMES; attempt++) { if (signal?.aborted) throw new AbortError(); const start = Date.now(); try { if (currentDelay > 0) await sleep(currentDelay, signal); const res = await fetch(url, { credentials: 'include', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }, signal, }); updateDelay(Date.now() - start); if (res.status === 401 || res.status === 403) { throw new AuthError(`需要登录 Pixiv 后才能抓取 (HTTP ${res.status})`); } if (res.status === 404) { throw new NoDataError('该内容不存在 (HTTP 404)'); } if (res.status === 429) { currentDelay = Math.min(CONFIG.MAX_DELAY, currentDelay + 400); throw new Error('HTTP 429 请求过于频繁'); } if (!res.ok) throw new Error(`HTTP ${res.status}`); const text = await res.text(); let json; try { json = JSON.parse(text); } catch (e) { // 排行榜接口在"日期不存在 / 未登录 / 未开启R18显示"时会直接返回 HTML 页面 throw new NoDataError('返回的不是 JSON(该日期可能没有榜单,或需要登录/开启相应显示设置)'); } if (json.error) throw new NoDataError(`接口返回错误: ${json.message || '未知错误'}`); return json; } catch (e) { if (e instanceof AuthError || e instanceof NoDataError) throw e; // 不重试 if (e.name === 'AbortError') throw new AbortError(); lastError = e; if (attempt < CONFIG.RETRY_TIMES) { await sleep((currentDelay + 500) * Math.pow(2, attempt - 1), signal); } } } throw lastError; }; // 简单的并发池 const runPool = async (tasks, concurrency, worker, signal) => { const queue = [...tasks]; const runner = async () => { while (queue.length > 0) { if (signal?.aborted) return; await worker(queue.shift()); } }; await Promise.all(Array.from({ length: Math.max(1, concurrency) }, runner)); }; // ==================== URL 构造 ==================== const buildSearchUrl = (keyword, page, sMode, mode) => { const url = new URL(`https://www.pixiv.net/ajax/search/novels/${encodeURIComponent(keyword)}`); url.searchParams.set('word', keyword); url.searchParams.set('order', 'date_d'); url.searchParams.set('mode', mode || 'all'); url.searchParams.set('p', String(page)); url.searchParams.set('s_mode', sMode || 's_tag_full'); url.searchParams.set('lang', 'zh'); return url.toString(); }; // 实测可用的接口(老的 novel/ranking.php?format=json 已失效,会返回整页 HTML) // 返回结构:{ error, body: { date:"2026年8月30日", display_a: { rank_a:[...50条], page_a, page, prev, next } } } const buildRankUrl = (mode, date, page) => { const url = new URL('https://www.pixiv.net/ajax/ranking/novel'); url.searchParams.set('mode', mode); if (date) url.searchParams.set('date', date); url.searchParams.set('p', String(page)); url.searchParams.set('lang', 'zh'); return url.toString(); }; const buildNovelDetailUrl = (id) => `https://www.pixiv.net/ajax/novel/${encodeURIComponent(id)}?lang=zh`; // ==================== 数据归一化 ==================== // 搜索接口的条目 const normalizeSearchItem = (raw) => ({ id: String(raw.id ?? ''), title: raw.title || '', userName: raw.userName || '', userId: raw.userId || '', bookmarkCount: num(pick(raw.bookmarkCount, raw.bookmark_count)), textCount: num(pick(raw.textCount, raw.wordCount, raw.text_count)), viewCount: num(pick(raw.viewCount, raw.view_count)), createDate: raw.createDate || '', tags: Array.isArray(raw.tags) ? raw.tags.map(t => (typeof t === 'string' ? t : t.tag || t.name || '')).filter(Boolean) : [], url: normalizeCoverUrl(raw.url || ''), language: raw.language || null, // 实测确认字段名,和排行榜接口一样 }); let loggedRankSample = false; // 排行榜条目的真实字段(与搜索接口完全不同): // rank,id,title,create_date,user_id,user_name,character_count,word_count,tag_a,url, // series_id,series_title,genre,bookmark_count,reading_time,ai_type,x_restrict const normalizeRankItem = (raw) => { if (!loggedRankSample) { loggedRankSample = true; console.info('[Pixiv优化] 排行榜条目原始字段示例(若解析异常请把这段发出来):', raw); } const tags = Array.isArray(raw.tag_a) ? raw.tag_a.map(t => (typeof t === 'string' ? t : t.tag || t.name || '')).filter(Boolean) : (Array.isArray(raw.tags) ? raw.tags.map(t => (typeof t === 'string' ? t : t.tag || t.name || '')).filter(Boolean) : []); return { id: String(pick(raw.id, raw.novel_id, raw.work_id) ?? ''), title: pick(raw.title, raw.novel_title) || '(无标题)', userName: pick(raw.user_name, raw.userName) || '', userId: String(pick(raw.user_id, raw.userId) || ''), bookmarkCount: num(pick(raw.bookmark_count, raw.bookmarkCount)), // 中文小说看字符数更有意义,word_count 另存一份 textCount: num(pick(raw.character_count, raw.text_count, raw.textCount)), wordCount: num(raw.word_count), readingTime: num(raw.reading_time), viewCount: num(pick(raw.view_count, raw.viewCount)), createDate: parseLooseDate(pick(raw.create_date, raw.date, raw.upload_timestamp)), tags, url: normalizeCoverUrl(pick(raw.url, raw.cover_url, raw.image_url) || ''), seriesTitle: raw.series_title || '', rank: num(pick(raw.rank, raw.ranking)), prevRank: num(pick(raw.yes_rank, raw.prev_rank)), language: raw.language || null, // 实测确认字段名,如 "zh-cn" / "ja" / "en" }; }; const recomputeDerived = (item) => { const snaps = item.snapshots || []; item.appearCount = snaps.length; const ranks = snaps.map(s => s.rank).filter(r => r != null); item.bestRank = ranks.length ? Math.min(...ranks) : null; const sorted = snaps.slice().sort((a, b) => String(b.date).localeCompare(String(a.date))); item.latestRank = sorted.length ? sorted[0].rank : null; item.latestDate = sorted.length ? sorted[0].date : ''; item.firstDate = sorted.length ? sorted[sorted.length - 1].date : ''; }; // ==================== 会话状态(搜索与排行互不污染) ==================== const newSearchSession = (keyword, cacheKey) => ({ type: 'search', keyword, cacheKey, items: [], byId: new Map(), fetchedPages: [], targetPages: [], maxPage: null, total: null, failed: [], noMore: false, stoppedEarly: false, }); const newRankSession = (mode, pagesPerPeriod) => ({ type: 'rank', mode, pagesPerPeriod, items: [], byId: new Map(), dates: [], // 已成功抓到的期(锚点日期,YYYYMMDD;周/月榜取该期结束日) periodLabels: {}, // 锚点日期 -> 展示用范围标签(周/月榜如 "9/5~9/11";日榜不设) cursorDate: null, // 下一期要抓的日期,来自上一期的 prev_date targetPeriods: 0, failed: [], // 失败的期(日期字符串,重试按钮用) failReasons: {}, // 日期 -> 失败原因文案,仅用于展示 emptyStreak: 0, // 连续"该日期没有榜单"的次数,超限就收手 noMore: false, stoppedEarly: false, enriched: new Set(), // 已补全过详情的 id }); let session = null; let sortField = CONFIG.DEFAULT_SORT_FIELD; let sortAsc = CONFIG.DEFAULT_SORT_ASC; let filters = { bookmarkMin: 0, bookmarkMax: Infinity, textCountMin: 0, textCountMax: Infinity, dateFrom: '', dateTo: '' }; const resetViewState = () => { sortField = CONFIG.DEFAULT_SORT_FIELD; sortAsc = CONFIG.DEFAULT_SORT_ASC; filters = { bookmarkMin: 0, bookmarkMax: Infinity, textCountMin: 0, textCountMax: Infinity, dateFrom: '', dateTo: '' }; }; let abortController = null; let isFetching = false; let stopRequested = false; let currentTheme = 'light'; // ==================== 页面匹配 ==================== const searchParams = new URL(location.href).searchParams; const isNewSearch = /\/search(\?|$)/.test(location.pathname + location.search) && searchParams.get('type') === 'novel'; const isOldNovelSearch = /\/novels(\?|$)/.test(location.pathname + location.search) || /novel\/search\.php/.test(location.pathname) || /\/tags\/.+\/novels/.test(location.pathname); const isRankPage = /\/novel\/ranking\.php/.test(location.pathname); if (!isNewSearch && !isOldNovelSearch && !isRankPage) { console.info('[Pixiv优化] 当前页面不是小说搜索/排行页,脚本未激活。'); return; } const extractKeyword = () => { const q = getQueryParam('q'); if (q) return q; const tagMatch = location.pathname.match(/\/tags\/([^/]+)\/novels/); if (tagMatch) return decodeURIComponent(tagMatch[1]); const word = getQueryParam('word'); if (word) return word; return null; }; const normalizeSMode = (raw) => { const map = { tag: 's_tag', tag_full: 's_tag_full', tag_tc: 's_tag_full', keyword: 's_tc', s_tag: 's_tag', s_tag_full: 's_tag_full', s_tc: 's_tc' }; return map[raw] || 's_tag_full'; }; // ==================== UI 基础 ==================== const BTN_ID = 'px-enhanced-btn'; const PANEL_ID = 'px-enhanced-panel'; const BACKDROP_ID = 'px-enhanced-backdrop'; const injectButton = () => { if (document.getElementById(BTN_ID)) return; const btn = document.createElement('button'); btn.id = BTN_ID; btn.textContent = '🔍 搜索优化'; btn.title = 'Pixiv 小说搜索/排行工具'; Object.assign(btn.style, { position: 'fixed', right: '14px', bottom: '76px', zIndex: CONFIG.BUTTON_Z_INDEX, padding: '5px 10px', borderRadius: '14px', border: 'none', background: 'linear-gradient(135deg, #0096fa, #0066cc)', color: '#fff', fontSize: '11px', fontWeight: '600', cursor: 'pointer', boxShadow: '0 3px 8px rgba(0,0,0,0.25)', transition: 'transform 0.2s', }); btn.addEventListener('mouseenter', () => btn.style.transform = 'scale(1.05)'); btn.addEventListener('mouseleave', () => btn.style.transform = 'scale(1)'); btn.addEventListener('click', onButtonClick); document.body.appendChild(btn); }; let observer = null; const startObserver = () => { if (observer) return; observer = new MutationObserver(() => { if (!document.getElementById(BTN_ID)) injectButton(); }); observer.observe(document.body, { childList: true, subtree: true }); }; window.addEventListener('pagehide', () => { if (observer) { observer.disconnect(); observer = null; } cleanupDarkModeListeners(); }); const getThemeStyles = (theme) => { const isDark = theme === 'dark'; return { backdropBg: isDark ? 'rgba(0,0,0,0.8)' : 'rgba(0,0,0,0.6)', panelBg: isDark ? '#1a1a2e' : '#ffffff', textPrimary: isDark ? '#e8e8f0' : '#1a2634', textSecondary: isDark ? '#a0a0c0' : '#5e6f8d', textMuted: isDark ? '#6a6a8a' : '#8a9bb5', divider: isDark ? '#2a2a4a' : '#eaeef2', cardBg: isDark ? '#16162a' : '#ffffff', inputBg: isDark ? '#0f0f1f' : '#ffffff', inputBorder: isDark ? '#3a3a5a' : '#d0d7e2', inputText: isDark ? '#e8e8f0' : '#1a2634', headerBg: isDark ? '#121224' : '#f8fafc', progressBg: isDark ? '#2a2a4a' : '#eaeef2', progressGradient: isDark ? 'linear-gradient(90deg,#4a7aff,#8aafff)' : 'linear-gradient(90deg,#0096fa,#66bfff)', btnPrimary: isDark ? '#4a7aff' : '#0096fa', btnSuccess: isDark ? '#2d8f4a' : '#28a745', btnDanger: isDark ? '#cc4444' : '#ff6b6b', linkColor: isDark ? '#6a9aff' : '#0096fa', tagBg: isDark ? '#1a2a4a' : '#eaf6ff', tagText: isDark ? '#6a9aff' : '#0096fa', hintBg: isDark ? '#12291d' : '#e6f7ed', hintText: isDark ? '#7fd6a3' : '#0a6e3a', hintBorder: '#2d8f4a', warnBg: isDark ? '#31240f' : '#fff6e0', warnText: isDark ? '#e8c07d' : '#8a6100', rankBg: isDark ? '#2a1f3a' : '#f3ecff', rankText: isDark ? '#c0a0ff' : '#7a3fd0', starColor: '#ffd700', }; }; let panelThemeStyles = getThemeStyles('light'); const applyThemeToPanel = (panel, theme) => { const styles = getThemeStyles(theme); panelThemeStyles = styles; panel.style.background = styles.panelBg; panel.style.color = styles.textPrimary; const header = panel.querySelector('.px-panel-header'); if (header) { header.style.background = styles.headerBg; header.style.borderBottom = `1px solid ${styles.divider}`; const title = header.querySelector('#px-panel-title'); if (title) title.style.color = styles.textPrimary; } const body = panel.querySelector('.panel-body'); if (body) { body.style.background = styles.panelBg; body.style.color = styles.textPrimary; } }; // ==================== 键盘导航 & 面板 ==================== let keyboardNavState = null; const handlePanelKeyDown = (e) => { if (e.key === 'Escape') { closePanel(); return; } const listContainer = document.getElementById('px-list'); if (!listContainer || !keyboardNavState) return; const cards = listContainer.querySelectorAll('.px-result-card'); if (cards.length === 0) return; if (e.key === 'ArrowDown') { e.preventDefault(); keyboardNavState.highlightedIdx = Math.min(keyboardNavState.highlightedIdx + 1, cards.length - 1); updateHighlight(cards, keyboardNavState.highlightedIdx); } else if (e.key === 'ArrowUp') { e.preventDefault(); keyboardNavState.highlightedIdx = Math.max(keyboardNavState.highlightedIdx - 1, -1); updateHighlight(cards, keyboardNavState.highlightedIdx); } else if (e.key === 'Enter' && keyboardNavState.highlightedIdx >= 0) { e.preventDefault(); const link = cards[keyboardNavState.highlightedIdx].querySelector('a[href*="/novel/show.php"]'); if (link) window.open(link.href, '_blank'); } }; const updateHighlight = (cards, idx) => { cards.forEach((c, i) => c.style.outline = i === idx ? `2px solid ${panelThemeStyles.btnPrimary}` : ''); if (idx >= 0) cards[idx].scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }; const closePanel = () => { if (abortController) { abortController.abort(); abortController = null; } stopRequested = false; const backdrop = document.getElementById(BACKDROP_ID); if (backdrop) backdrop.remove(); document.body.style.overflow = ''; document.removeEventListener('keydown', handlePanelKeyDown); keyboardNavState = null; cleanupDarkModeListeners(); }; const setPanelTitle = (text) => { const el = document.getElementById('px-panel-title'); if (el) el.textContent = text; }; const ensurePanel = () => { let backdrop = document.getElementById(BACKDROP_ID); if (backdrop) { const panel = backdrop.querySelector(`#${PANEL_ID}`); if (panel) { currentTheme = isDarkMode() ? 'dark' : 'light'; applyThemeToPanel(panel, currentTheme); } return backdrop.querySelector(`#${PANEL_ID} .panel-body`); } currentTheme = isDarkMode() ? 'dark' : 'light'; const styles = getThemeStyles(currentTheme); panelThemeStyles = styles; backdrop = document.createElement('div'); backdrop.id = BACKDROP_ID; Object.assign(backdrop.style, { position: 'fixed', inset: '0', background: styles.backdropBg, zIndex: CONFIG.PANEL_Z_INDEX, display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(2px)', }); backdrop.addEventListener('click', (e) => { if (e.target === backdrop) closePanel(); }); const panel = document.createElement('div'); panel.id = PANEL_ID; Object.assign(panel.style, { width: 'min(1100px, 96vw)', maxHeight: '92vh', background: styles.panelBg, borderRadius: '16px', boxShadow: '0 20px 60px rgba(0,0,0,0.3)', display: 'flex', flexDirection: 'column', overflow: 'hidden', fontFamily: '-apple-system, "Segoe UI", "Microsoft Yahei", sans-serif', color: styles.textPrimary, }); const header = document.createElement('div'); header.className = 'px-panel-header'; Object.assign(header.style, { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px 20px', borderBottom: `1px solid ${styles.divider}`, flexShrink: '0', background: styles.headerBg, }); header.innerHTML = `

Pixiv 小说工具

`; const closeBtn = document.createElement('button'); closeBtn.textContent = '✕'; closeBtn.setAttribute('aria-label', '关闭'); Object.assign(closeBtn.style, { border: 'none', background: 'transparent', width: '36px', height: '36px', borderRadius: '50%', fontSize: '18px', cursor: 'pointer', color: styles.textSecondary, }); closeBtn.addEventListener('click', closePanel); header.appendChild(closeBtn); const body = document.createElement('div'); body.className = 'panel-body'; Object.assign(body.style, { padding: '16px 20px 20px 20px', overflowY: 'auto', flex: '1', background: styles.panelBg, color: styles.textPrimary, }); panel.appendChild(header); panel.appendChild(body); backdrop.appendChild(panel); document.body.appendChild(backdrop); document.body.style.overflow = 'hidden'; document.addEventListener('keydown', handlePanelKeyDown); cleanupDarkModeListeners(); onDarkModeChange((isDark) => { currentTheme = isDark ? 'dark' : 'light'; const panelEl = document.getElementById(PANEL_ID); if (panelEl) { applyThemeToPanel(panelEl, currentTheme); if (session && session.items.length > 0) refreshList(); } }); return body; }; // ==================== 排序 / 筛选 ==================== const SORT_FIELDS_COMMON = [ { value: 'bookmarkCount', label: '收藏' }, { value: 'textCount', label: '字数' }, { value: 'createDate', label: '发布日期' }, { value: 'viewCount', label: '浏览' }, ]; const SORT_FIELDS_RANK = [ { value: 'appearCount', label: '上榜次数' }, { value: 'bestRank', label: '最好名次' }, { value: 'latestDate', label: '最近上榜' }, ]; const sortFieldsFor = (s) => (s && s.type === 'rank' ? [...SORT_FIELDS_COMMON, ...SORT_FIELDS_RANK] : SORT_FIELDS_COMMON); const sortValue = (item, field) => { if (field === 'createDate') return new Date(item.createDate || 0).getTime(); if (field === 'latestDate') return Number(item.latestDate || 0); if (field === 'bestRank') return item.bestRank == null ? Number.MAX_SAFE_INTEGER : item.bestRank; const v = item[field]; return v == null ? -1 : v; }; const sortItems = (items, field, asc) => items.slice().sort((a, b) => { const va = sortValue(a, field); const vb = sortValue(b, field); if (va === vb) return (a.bestRank ?? 9999) - (b.bestRank ?? 9999); return asc ? va - vb : vb - va; }); const applyFilters = (items) => items.filter(item => { if (isBlocked(item)) return false; if (isLanguageBlocked(item)) return false; const bm = item.bookmarkCount; const tc = item.textCount; const date = item.createDate ? new Date(item.createDate) : null; if (filters.bookmarkMin > 0 && (bm == null || bm < filters.bookmarkMin)) return false; if (filters.bookmarkMax < Infinity && bm != null && bm > filters.bookmarkMax) return false; if (filters.textCountMin > 0 && (tc == null || tc < filters.textCountMin)) return false; if (filters.textCountMax < Infinity && tc != null && tc > filters.textCountMax) return false; if (date) { if (filters.dateFrom && date < new Date(filters.dateFrom)) return false; if (filters.dateTo && date > new Date(filters.dateTo)) return false; } return true; }); const updateDisplayData = () => (session ? sortItems(applyFilters(session.items), sortField, sortAsc) : []); // ==================== 渲染 ==================== const renderItem = (item, idx, styles) => { const link = `https://www.pixiv.net/novel/show.php?id=${item.id}`; const cover = escapeHtml(item.url || ''); const title = escapeHtml(item.title || '(无标题)'); const author = escapeHtml(item.userName || ''); const bm = item.bookmarkCount; const bmHtml = bm == null ? `` : `${bm}`; const textCount = item.textCount == null ? '-' : item.textCount; const createDate = item.createDate ? new Date(item.createDate).toLocaleDateString() : ''; const isHot = bm != null && bm > 1000; const authorHtml = item.userId ? `${author}` : `${author}`; const tagNames = item.tags || []; // 标签胶囊里的 🚫 是独立于 的按钮(不能嵌进链接里,否则点击行为会打架), // 点击通过 px-list 容器上的事件委托捕获,不在这里单独绑定监听器 const tagsHtml = tagNames.length ? `
${tagNames.slice(0, 5).map(t => `#${escapeHtml(t)}`).join('')}${tagNames.length > 5 ? `+${tagNames.length - 5}` : ''}
` : ''; const hotBadge = isHot ? `` : ''; const periodLabelOf = (d) => (session?.periodLabels?.[d]) || prettyYmd(d); const rankHtml = (item.snapshots && item.snapshots.length) ? `🏆 最好 #${item.bestRank ?? '?'} · 上榜 ${item.appearCount} 次` : ''; const viewHtml = item.viewCount != null ? `👁 ${item.viewCount}` : ''; return `
${idx + 1}
${cover ? `` : ''}
${title} ${hotBadge}
✍️ ${authorHtml} ❤️ ${bmHtml} 📄 ${textCount} ${viewHtml} ${createDate ? `📅 ${createDate}` : ''} ${rankHtml}
${tagsHtml}
`; }; const renderList = (container, items) => { if (!container) return; const styles = panelThemeStyles; if (items.length === 0) { container.innerHTML = `
没有匹配的结果
`; return; } const useAuto = !isMobile() && items.length > CONFIG.VIRTUAL_THRESHOLD; let html = ''; items.forEach((item, idx) => { html += `
${renderItem(item, idx, styles)}
`; }); container.innerHTML = html; }; const refreshList = () => { const listContainer = document.getElementById('px-list'); if (listContainer) { renderList(listContainer, updateDisplayData()); updateStats(); } }; const updateStats = () => { const statsDiv = document.getElementById('px-stats'); if (!statsDiv || !session) return; const filtered = applyFilters(session.items); const withBm = filtered.filter(i => i.bookmarkCount != null); const totalBm = withBm.reduce((s, i) => s + i.bookmarkCount, 0); const avgBm = withBm.length > 0 ? Math.round(totalBm / withBm.length) : 0; statsDiv.innerHTML = `📄 ${filtered.length}❤️ ${totalBm}📊 均 ${avgBm}`; }; const styledBtn = (text, kind) => { const s = panelThemeStyles; const b = document.createElement('button'); b.textContent = text; const bg = kind === 'danger' ? s.btnDanger : kind === 'success' ? s.btnSuccess : kind === 'ghost' ? 'transparent' : s.btnPrimary; b.style.cssText = `padding:4px 10px;border:${kind === 'ghost' ? `1px solid ${s.btnPrimary}` : 'none'};border-radius:6px;background:${bg};color:${kind === 'ghost' ? s.btnPrimary : '#fff'};font-size:13px;cursor:pointer;`; return b; }; // 还没抓、但属于本轮目标的东西 const getSearchRemaining = () => { if (!session || session.type !== 'search') return []; let rem = diffPages(session.targetPages, session.fetchedPages); if (session.maxPage != null) rem = rem.filter(p => p <= session.maxPage); const failed = new Set(session.failed); return rem.filter(p => !failed.has(p)); }; const getRankRemaining = () => { if (!session || session.type !== 'rank') return 0; if (session.noMore) return 0; return Math.max(0, session.targetPeriods - session.dates.length); }; const buildControls = () => { const styles = panelThemeStyles; const controls = document.createElement('div'); Object.assign(controls.style, { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '8px 12px', padding: '8px 0 12px 0', borderBottom: `1px solid ${styles.divider}`, flexShrink: '0', }); // 排序 const sortGroup = document.createElement('div'); sortGroup.style.cssText = 'display:flex;align-items:center;gap:4px;'; sortGroup.innerHTML = `排序`; const sortSelect = document.createElement('select'); sortSelect.style.cssText = `padding:4px 8px;border:1px solid ${styles.inputBorder};border-radius:6px;font-size:13px;background:${styles.inputBg};color:${styles.inputText};cursor:pointer;`; sortFieldsFor(session).forEach(f => { const opt = document.createElement('option'); opt.value = f.value; opt.textContent = f.label; sortSelect.appendChild(opt); }); sortSelect.value = sortField; sortSelect.addEventListener('change', () => { sortField = sortSelect.value; // 名次是越小越好,切过去时默认升序 if (sortField === 'bestRank') { sortAsc = true; orderBtn.textContent = '▲'; } refreshList(); }); const orderBtn = document.createElement('button'); orderBtn.textContent = sortAsc ? '▲' : '▼'; orderBtn.style.cssText = `padding:4px 8px;border:1px solid ${styles.inputBorder};border-radius:6px;background:${styles.inputBg};color:${styles.inputText};font-size:13px;cursor:pointer;`; orderBtn.addEventListener('click', () => { sortAsc = !sortAsc; orderBtn.textContent = sortAsc ? '▲' : '▼'; refreshList(); }); sortGroup.appendChild(sortSelect); sortGroup.appendChild(orderBtn); controls.appendChild(sortGroup); // 即时文本过滤 const textFilter = document.createElement('input'); textFilter.placeholder = '🔍 筛选标题'; textFilter.style.cssText = `padding:4px 10px;border:1px solid ${styles.inputBorder};border-radius:6px;font-size:13px;background:${styles.inputBg};color:${styles.inputText};width:120px;`; textFilter.addEventListener('input', (e) => { const val = e.target.value.toLowerCase(); document.querySelectorAll('.px-result-card').forEach(card => { const t = card.querySelector('a[href*="/novel/show.php"]')?.textContent.toLowerCase() || ''; card.style.display = t.includes(val) ? '' : 'none'; }); }); controls.appendChild(textFilter); const advancedBtn = document.createElement('button'); advancedBtn.textContent = '⚙️ 高级'; advancedBtn.style.cssText = `padding:4px 8px;border:1px solid ${styles.inputBorder};border-radius:6px;background:${styles.inputBg};color:${styles.inputText};font-size:13px;cursor:pointer;`; advancedBtn.addEventListener('click', () => toggleAdvancedFilters(controls)); controls.appendChild(advancedBtn); const statsDiv = document.createElement('div'); statsDiv.id = 'px-stats'; statsDiv.style.cssText = `font-size:12px;color:${styles.textMuted};display:flex;gap:12px;margin-left:auto;`; controls.appendChild(statsDiv); const exportHtmlBtn = styledBtn('📥 HTML', 'success'); exportHtmlBtn.addEventListener('click', () => exportHTML(updateDisplayData())); controls.appendChild(exportHtmlBtn); const exportCsvBtn = styledBtn('📊 CSV', 'success'); exportCsvBtn.addEventListener('click', () => exportCSV(updateDisplayData())); controls.appendChild(exportCsvBtn); // 排行模式:收藏数补全 if (session.type === 'rank') { const missing = session.items.filter(i => i.bookmarkCount == null).length; if (missing > 0 && !isFetching) { const enrichBtn = styledBtn(`❤️ 补全收藏数 (缺 ${missing})`, 'ghost'); enrichBtn.addEventListener('click', () => promptEnrich(missing)); controls.appendChild(enrichBtn); } } // 失败项重试 if (session.failed.length > 0 && !isFetching) { const label = session.type === 'search' ? '页' : '期'; const retryAll = styledBtn(`🔄 重试失败${label} (${session.failed.length})`, 'danger'); if (session.type === 'rank' && session.failReasons) { const lines = session.failed.map(d => `${d === '最新' ? '最新一期' : (session.periodLabels[d] || prettyYmd(d))}: ${session.failReasons[d] || '未知原因'}`); retryAll.title = lines.join('\n'); } retryAll.addEventListener('click', () => { if (session.type === 'search') runSearch(session.keyword, session.failed.slice()); else runRank({ retryDates: session.failed.slice() }); }); controls.appendChild(retryAll); } if (!isFetching) { if (session.type === 'search') { const remaining = getSearchRemaining(); if (remaining.length > 0) { const resume = styledBtn(`▶ 继续抓取剩余 ${remaining.length} 页`); resume.addEventListener('click', () => runSearch(session.keyword, session.targetPages.slice())); controls.appendChild(resume); } else if (session.noMore) { const tag = document.createElement('span'); tag.textContent = session.total != null ? `✅ 已加载全部(共 ${session.total} 条)` : '✅ 已加载全部'; tag.style.cssText = `font-size:12px;color:${styles.textMuted};`; controls.appendChild(tag); } else if (session.fetchedPages.length > 0) { const more = styledBtn(`➕ 加载更多${session.maxPage != null ? `(共 ${session.maxPage} 页)` : ''}`, 'ghost'); more.addEventListener('click', () => loadMoreSearchPages()); controls.appendChild(more); } } else { const remaining = getRankRemaining(); if (remaining > 0) { const resume = styledBtn(`▶ 继续抓取剩余 ${remaining} 期`); resume.addEventListener('click', () => runRank({ resume: true })); controls.appendChild(resume); } else if (session.noMore) { const tag = document.createElement('span'); tag.textContent = '✅ 已到最早一期'; tag.style.cssText = `font-size:12px;color:${styles.textMuted};`; controls.appendChild(tag); } else { const more = styledBtn(`➕ 再抓 ${CONFIG.RANK_LOAD_MORE_PERIODS} 期`, 'ghost'); more.addEventListener('click', () => runRank({ resume: true, extraPeriods: CONFIG.RANK_LOAD_MORE_PERIODS })); controls.appendChild(more); } } } setTimeout(updateStats, 0); return controls; }; const toggleAdvancedFilters = (controlsParent) => { const existing = document.getElementById('px-advanced-filters'); if (existing) { existing.remove(); return; } const styles = panelThemeStyles; // 语言区块只统计"确实带 language 字段"的条目——目前只有排行榜数据实测 // 确认有这个字段,搜索结果如果没有这个字段,distinctLangs 会是空的, // 语言区块自动不显示,不会出现"一堆空checkbox"这种莫名其妙的东西 const langCounts = new Map(); (session?.items || []).forEach(i => { if (i.language) langCounts.set(i.language, (langCounts.get(i.language) || 0) + 1); }); const distinctLangs = [...langCounts.keys()].sort(); const div = document.createElement('div'); div.id = 'px-advanced-filters'; div.style.cssText = `display:flex;flex-wrap:wrap;gap:10px;padding:10px;background:${styles.cardBg};border:1px solid ${styles.divider};border-radius:8px;margin:8px 0;font-size:13px;color:${styles.textSecondary};`; const langBlockHtml = distinctLangs.length > 0 ? `
语言: ${distinctLangs.map(code => ` `).join('')}
` : ''; div.innerHTML = ` ${langBlockHtml} `; controlsParent.insertAdjacentElement('afterend', div); document.getElementById('bm-min').value = filters.bookmarkMin || ''; document.getElementById('bm-max').value = filters.bookmarkMax === Infinity ? '' : filters.bookmarkMax; document.getElementById('wc-min').value = filters.textCountMin || ''; document.getElementById('wc-max').value = filters.textCountMax === Infinity ? '' : filters.textCountMax; document.getElementById('date-from').value = filters.dateFrom; document.getElementById('date-to').value = filters.dateTo; document.getElementById('apply-filters').addEventListener('click', () => { filters.bookmarkMin = parseInt(document.getElementById('bm-min').value) || 0; filters.bookmarkMax = parseInt(document.getElementById('bm-max').value) || Infinity; filters.textCountMin = parseInt(document.getElementById('wc-min').value) || 0; filters.textCountMax = parseInt(document.getElementById('wc-max').value) || Infinity; filters.dateFrom = document.getElementById('date-from').value; filters.dateTo = document.getElementById('date-to').value; const langCbs = Array.from(document.querySelectorAll('.px-lang-cb')); if (langCbs.length > 0) { const checked = langCbs.filter(cb => cb.checked).map(cb => cb.value); // 全选=不限制(这样以后新出现的、当前还没见过的语言也不会被误伤) allowedLanguages = checked.length >= langCbs.length ? null : new Set(checked); saveAllowedLanguages(); } refreshList(); }); }; const sessionTitle = () => { if (!session) return 'Pixiv 小说工具'; if (session.type === 'search') { const pageHint = session.maxPage != null ? ` · 已抓 ${session.fetchedPages.length}/${session.maxPage} 页` : ` · 已抓 ${session.fetchedPages.length} 页`; return `「${session.keyword}」· ${session.items.length} 条${pageHint}`; } const labelOf = (d) => session.periodLabels?.[d] || prettyYmd(d); const range = session.dates.length ? `${labelOf(session.dates[session.dates.length - 1])} ~ ${labelOf(session.dates[0])}` : ''; return `🏆 ${rankModeLabel(session.mode)} · ${session.dates.length} 期 · ${session.items.length} 篇${range ? ` · ${range}` : ''}`; }; const renderResult = () => { if (!document.getElementById(BACKDROP_ID)) return; const body = ensurePanel(); const styles = panelThemeStyles; setPanelTitle(sessionTitle()); const container = document.createElement('div'); container.style.cssText = `display:flex;flex-direction:column;gap:12px;height:100%;color:${styles.textPrimary};`; if (session.type === 'rank' && session.items.length === 0) { // 一条都没抓到:绝不能只给一个"没有匹配的结果"就完事,必须说清楚原因, // 否则和"这功能就是坏的"没法区分——这正是本次修复要解决的体验问题。 const notice = document.createElement('div'); notice.style.cssText = `font-size:13px;color:${styles.btnDanger};background:${styles.warnBg};border-left:4px solid ${styles.btnDanger};padding:10px 14px;border-radius:8px;`; const reasons = session.failed.map(d => `「${d === '最新' ? '最新一期' : (session.periodLabels[d] || prettyYmd(d))}」:${session.failReasons[d] || '未知原因'}`); notice.innerHTML = `❌ 一条数据都没抓到。
${reasons.length ? reasons.map(escapeHtml).join('
') : '没有记录到具体失败原因,可能是网络问题,请重试。'}`; container.appendChild(notice); body.innerHTML = ''; body.appendChild(container); const backBtn = styledBtn('← 返回重新设置', 'ghost'); backBtn.addEventListener('click', () => (isRankPage ? renderRankSetup() : renderHome())); container.appendChild(backBtn); return; } if (session.stoppedEarly) { const notice = document.createElement('div'); notice.style.cssText = `font-size:13px;color:${styles.hintText};background:${styles.hintBg};border-left:4px solid ${styles.hintBorder};padding:8px 14px;border-radius:8px;`; notice.textContent = '⏹ 已停止抓取,下面是目前已经抓到的内容(可继续抓取剩余部分)。'; container.appendChild(notice); } if (session.type === 'rank' && session.items.length > 0 && session.items.every(i => i.bookmarkCount == null)) { const notice = document.createElement('div'); notice.style.cssText = `font-size:13px;color:${styles.warnText};background:${styles.warnBg};border-left:4px solid ${styles.warnText};padding:8px 14px;border-radius:8px;`; notice.textContent = '⚠️ 排行榜接口没有返回收藏数(老接口通常只给浏览数/评分数)。点右上「补全收藏数」按需拉取真实收藏数。'; container.appendChild(notice); } if (session.type === 'rank') { const tip = document.createElement('div'); tip.style.cssText = `font-size:12px;color:${styles.textMuted};`; tip.textContent = '💡 各期收藏数为抓取当下的实时值,非历史值'; container.appendChild(tip); } const blockedCount = session.items.filter(isBlocked).length; if (blockedCount > 0) { // 数量变少必须说明原因——这条经验来自排行榜那次的教训:悄悄减少而不说明, // 用户分不清是"命中了屏蔽词"还是"抓取又出故障了" const tip = document.createElement('div'); tip.style.cssText = `font-size:12px;color:${styles.textMuted};`; tip.textContent = `🚫 已根据屏蔽词过滤掉 ${blockedCount} 条`; container.appendChild(tip); } if (allowedLanguages && allowedLanguages.size > 0) { const langBlockedCount = session.items.filter(isLanguageBlocked).length; if (langBlockedCount > 0) { const tip = document.createElement('div'); tip.style.cssText = `font-size:12px;color:${styles.textMuted};`; tip.textContent = `🌐 已仅显示:${[...allowedLanguages].map(langLabel).join('、')}(过滤掉其他语言 ${langBlockedCount} 条)`; container.appendChild(tip); } } const listContainer = document.createElement('div'); listContainer.id = 'px-list'; listContainer.style.cssText = 'flex:1;overflow-y:auto;max-height:62vh;min-height:300px;'; // 标签旁的 🚫 按钮走事件委托:renderList 每次都用 innerHTML 整体重绘, // 单独给每个按钮绑监听器会在下一次重绘时全部失效,委托到容器上一次搞定 listContainer.addEventListener('click', (e) => { const btn = e.target.closest('.px-tag-block-btn'); if (!btn) return; e.preventDefault(); const term = btn.getAttribute('data-term'); if (term && addBlockTerm(term)) renderResult(); // 整块重渲染,连"已过滤 N 条"提示一起更新 }); container.appendChild(buildControls()); container.appendChild(listContainer); body.innerHTML = ''; body.appendChild(container); renderList(listContainer, updateDisplayData()); keyboardNavState = { highlightedIdx: -1 }; }; // ==================== 导出 ==================== const exportBaseName = () => { const d = new Date().toISOString().slice(0, 10); return session.type === 'search' ? `pixiv_search_${session.keyword}_${d}` : `pixiv_rank_${session.mode}_${d}`; }; const downloadBlob = (blob, filename) => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }; const exportHTML = (items) => { const styles = panelThemeStyles; const rows = items.map((item, idx) => renderItem(item, idx, styles)).join('\n'); const html = ` ${escapeHtml(sessionTitle())}

${escapeHtml(sessionTitle())} · 导出 ${items.length} 条 · ${new Date().toLocaleString()}

${rows} `; downloadBlob(new Blob([html], { type: 'text/html;charset=utf-8;' }), `${exportBaseName()}.html`); }; const exportCSV = (items) => { const isRank = session.type === 'rank'; const header = ['序号', '标题', '作者', '收藏数', '字数', '浏览数', '发布日期', '标签', '链接']; if (isRank) header.push('上榜次数', '最好名次', '最近上榜', '上榜明细'); const rows = items.map((item, idx) => { const base = [ idx + 1, item.title || '', item.userName || '', item.bookmarkCount == null ? '' : item.bookmarkCount, item.textCount == null ? '' : item.textCount, item.viewCount == null ? '' : item.viewCount, item.createDate || '', (item.tags || []).join(';'), `https://www.pixiv.net/novel/show.php?id=${item.id}`, ]; if (isRank) { base.push( item.appearCount || 0, item.bestRank == null ? '' : item.bestRank, session.periodLabels[item.latestDate] || prettyYmd(item.latestDate), (item.snapshots || []).map(s => `${session.periodLabels[s.date] || prettyYmd(s.date)}#${s.rank ?? '?'}`).join(' ') ); } return base; }); const csv = [header, ...rows].map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n'); downloadBlob(new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }), `${exportBaseName()}.csv`); }; // ==================== 进度界面 ==================== const renderProgressSkeleton = (heading) => { const body = ensurePanel(); const styles = panelThemeStyles; setPanelTitle(heading || '抓取中'); body.innerHTML = `

⏳ 准备抓取…

0%
⏱ 已用: 0s ⏳ 预计剩余: --

`; document.getElementById('px-stop-btn').addEventListener('click', (e) => { stopRequested = true; const btn = e.currentTarget; btn.disabled = true; btn.style.opacity = '0.6'; btn.textContent = '正在停止…'; const textEl = document.getElementById('px-progress-text'); if (textEl) textEl.textContent = '⏹ 正在停止,马上展示已抓取的内容…'; abortController?.abort(); }); }; const updateProgress = (current, total, startTime, unit, sub) => { if (stopRequested) return; const pct = total > 0 ? Math.min(100, Math.round((current / total) * 100)) : 0; const bar = document.getElementById('px-progress-bar'); const text = document.getElementById('px-progress-text'); const pctEl = document.getElementById('px-progress-percent'); const timeEl = document.getElementById('px-progress-time'); const estEl = document.getElementById('px-progress-estimate'); const subEl = document.getElementById('px-progress-sub'); if (text) text.textContent = `⏳ 抓取中:${current} / ${total} ${unit || '页'}`; if (bar) bar.style.width = pct + '%'; if (pctEl) pctEl.textContent = pct + '%'; if (subEl && sub != null) subEl.textContent = sub; if (startTime) { const elapsed = (Date.now() - startTime) / 1000; if (timeEl) timeEl.textContent = `⏱ 已用: ${formatTime(elapsed)}`; if (estEl) { if (current > 0 && current < total) estEl.textContent = `⏳ 预计剩余: ${formatTime((total - current) * (elapsed / current))}`; else if (current >= total) estEl.textContent = '✅ 完成'; } } }; // ==================== 抓取:关键词搜索 ==================== const fetchSearchPages = async ({ pages, keyword, sMode, mode, signal, onPageResult }) => { const queue = [...pages].sort((a, b) => a - b); let boundary = Infinity; let authError = null; let totalCount = null; const handleBody = (page, body) => { const section = body.novel || body.novelManga || {}; const list = section.data || []; const total = Number(section.total); if (Number.isFinite(total) && total >= 0) { totalCount = total; const size = list.length > 0 ? list.length : CONFIG.PAGE_SIZE_FALLBACK; boundary = Math.min(boundary, Math.max(1, Math.ceil(total / size)) + 1); } if (list.length === 0) { boundary = Math.min(boundary, page); onPageResult({ page, status: 'empty' }); } else { onPageResult({ page, status: 'ok', items: list.map(normalizeSearchItem) }); } }; const fetchOne = async (page) => { const json = await fetchJson(buildSearchUrl(keyword, page, sMode, mode), signal); handleBody(page, json.body || {}); }; // 探测页:先拿 total,据此裁掉所有不存在的页码 if (queue.length > 0) { const probe = queue.shift(); try { await fetchOne(probe); } catch (e) { if (e instanceof AuthError) { onPageResult({ page: probe, status: 'auth-error', error: e }); return { maxPage: null, total: null }; } if (e.name === 'AbortError') { onPageResult({ page: probe, status: 'aborted' }); return { maxPage: null, total: null }; } onPageResult({ page: probe, status: 'error', error: e }); } } await runPool(queue, CONFIG.CONCURRENCY, async (page) => { if (authError) return; if (page >= boundary) { onPageResult({ page, status: 'skipped' }); return; } try { await fetchOne(page); } catch (e) { if (e instanceof AuthError) { authError = e; onPageResult({ page, status: 'auth-error', error: e }); return; } if (e.name === 'AbortError') { onPageResult({ page, status: 'aborted' }); return; } if (e instanceof NoDataError) { boundary = Math.min(boundary, page); onPageResult({ page, status: 'empty' }); return; } onPageResult({ page, status: 'error', error: e }); } }, signal); return { maxPage: boundary === Infinity ? null : boundary - 1, total: totalCount }; }; const runSearch = async (keyword, targetPages) => { if (isFetching) return; setDelayFloor(CONFIG.MIN_DELAY); const sMode = normalizeSMode(getQueryParam('s_mode')); const mode = getQueryParam('mode') || 'all'; const cacheKey = `${CONFIG.CACHE_PREFIX}${mode}:${sMode}:${keyword}`; isFetching = true; stopRequested = false; abortController = new AbortController(); const signal = abortController.signal; try { if (!session || session.type !== 'search' || session.keyword !== keyword) { session = newSearchSession(keyword, cacheKey); resetViewState(); } const s = session; const cached = await loadSearchCache(cacheKey); if (cached && s.items.length === 0) { s.items = (cached.items || []).map(i => ({ ...i })); s.items.forEach(i => s.byId.set(i.id, i)); s.fetchedPages = cached.fetchedPages.slice(); s.maxPage = Number.isFinite(cached.maxPage) ? cached.maxPage : null; s.total = Number.isFinite(cached.total) ? cached.total : null; } let targets = [...new Set(targetPages)].sort((a, b) => a - b); if (s.maxPage != null) targets = targets.filter(p => p <= s.maxPage); if (targets.length === 0) targets = [1]; s.targetPages = targets; const missing = diffPages(targets, s.fetchedPages); if (missing.length === 0) { s.failed = []; s.noMore = reachedEnd(s.fetchedPages, s.maxPage); s.stoppedEarly = false; renderResult(); return; } renderProgressSkeleton(`🔍 「${keyword}」`); const startTime = Date.now(); let completed = 0; const newFailed = []; let authErrorMsg = null; const result = await fetchSearchPages({ pages: missing, keyword, sMode, mode, signal, onPageResult: ({ page, status, items, error }) => { if (status === 'ok') { items.forEach(it => { if (!s.byId.has(it.id)) { s.byId.set(it.id, it); s.items.push(it); } }); s.fetchedPages.push(page); } else if (status === 'error') { newFailed.push(page); } else if (status === 'auth-error') { authErrorMsg = error.message; } completed++; updateProgress(completed, missing.length, startTime, '页'); }, }); if (result.maxPage != null) s.maxPage = s.maxPage == null ? result.maxPage : Math.min(s.maxPage, result.maxPage); if (result.total != null) s.total = result.total; if (s.maxPage != null) s.targetPages = s.targetPages.filter(p => p <= s.maxPage); s.failed = newFailed; s.noMore = reachedEnd(s.fetchedPages, s.maxPage); s.stoppedEarly = stopRequested; if (s.items.length > 0) { await writeCache(cacheKey, () => ({ items: s.items, fetchedPages: s.fetchedPages, maxPage: s.maxPage, total: s.total, })); } if (!document.getElementById(BACKDROP_ID)) return; renderResult(); if (authErrorMsg) alert(`⚠️ ${authErrorMsg}\n请确认已登录 Pixiv,然后重试失败页。`); } catch (e) { console.error('[Pixiv优化] 搜索抓取异常:', e); } finally { isFetching = false; stopRequested = false; abortController = null; } }; const loadMoreSearchPages = () => { const s = session; const known = s.fetchedPages.length > 0 ? Math.max(...s.fetchedPages) : 0; const target = new Set([...s.targetPages, ...s.fetchedPages]); for (let p = known + 1; p <= known + CONFIG.DEFAULT_LOAD_MORE_SIZE; p++) { if (s.maxPage != null && p > s.maxPage) break; target.add(p); } runSearch(s.keyword, [...target].sort((a, b) => a - b)); }; // ==================== 抓取:排行榜区间 ==================== // 一期 = 一个榜单期。新接口没有 prev_date,所以日期由脚本自己按步长回推, // 再用接口返回的真实日期去重,避免同一期被算两次。 // // 关键点:日榜的 body.date 是确切单日("2026年8月30日"),但周榜/月榜的 // body.date 恒为 null,真实范围放在 body.start / body.end 里(如 start= // "2026年9月5日", end="2026年9月11日")。之前只认 body.date,遇到 null // 就直接判定"无法识别日期"而整期放弃——这是周榜完全抓不到数据的根因。 // 现在按 date → end → start 的顺序取值,取不到确切单日就用该期的结束日 // 期作为这一期的"锚点日期"(用于缓存 key 和向前步进)。 const RANK_PAGE_SIZE = 50; // "2026年8月30日" / "2026-08-30" → "20260830";也兼容"2026年8月"这种只有年月、 // 没有日的写法(月榜可能这样给),此时取当月 1 号。 const parseRankDate = (raw) => { const s = String(raw || ''); const full = s.match(/(\d{4})\D+(\d{1,2})\D+(\d{1,2})/); if (full) return `${full[1]}${String(full[2]).padStart(2, '0')}${String(full[3]).padStart(2, '0')}`; const ym = s.match(/(\d{4})\D+(\d{1,2})月?$/); if (ym) return `${ym[1]}${String(ym[2]).padStart(2, '0')}01`; return ''; }; // "2026年9月5日" + "2026年9月11日" → "9/5~9/11",用于界面展示(比单个日期更准确) const formatRangeLabel = (startRaw, endRaw) => { const short = (raw) => { const m = String(raw || '').match(/(\d{1,2})\D+(\d{1,2})日/); return m ? `${m[1]}/${m[2]}` : ''; }; const a = short(startRaw), b = short(endRaw); return a && b ? `${a}~${b}` : ''; }; // 兼容 body.display_a.rank_a(当前结构)与几种可能的变体 const extractRankPage = (json) => { const body = json.body || {}; const display = body.display_a || body.display || {}; const list = Array.isArray(display.rank_a) ? display.rank_a : Array.isArray(body.rank_a) ? body.rank_a : Array.isArray(body.contents) ? body.contents : []; let maxPage = null; const pa = display.page_a || body.page_a; if (Array.isArray(pa) && pa.length) { const nums = pa.map(v => Number(v && typeof v === 'object' ? (v.page ?? v.p) : v)).filter(Number.isFinite); if (nums.length) maxPage = Math.max(...nums); } // date → end → start:日榜给确切单日;周榜/月榜的 date 恒为 null,只能退而 // 求其次用范围的结束日期当锚点 const dateLabel = body.date || body.end || body.start || ''; const rangeLabel = formatRangeLabel(body.start, body.end); return { list, maxPage, dateLabel, rangeLabel }; }; const fetchRankPeriod = async (mode, date, pagesWanted, signal) => { const first = await fetchJson(buildRankUrl(mode, date, 1), signal); const p1 = extractRankPage(first); if (p1.list.length === 0) throw new NoDataError('该期没有数据'); const actualDate = parseRankDate(p1.dateLabel); if (!actualDate) throw new NoDataError(`无法从接口返回中识别出日期(原始值: ${JSON.stringify(p1.dateLabel)}),请把这条报错反馈给开发者`); const byId = new Map(); const collect = (list) => list.forEach(raw => { const it = normalizeRankItem(raw); if (it.id) byId.set(it.id, it); }); collect(p1.list); const maxPage = p1.maxPage || null; const lastPage = maxPage ? Math.min(pagesWanted, maxPage) : pagesWanted; const pageErrors = []; // 同一期内顺序翻页:不知道总页数时靠"空页/短页"判断到底,避免多请求 for (let p = 2; p <= lastPage; p++) { if (signal?.aborted) break; try { const json = await fetchJson(buildRankUrl(mode, actualDate, p), signal); const pg = extractRankPage(json); if (pg.list.length === 0) break; collect(pg.list); if (!maxPage && pg.list.length < RANK_PAGE_SIZE) break; } catch (e) { if (e.name === 'AbortError') break; if (e instanceof NoDataError) break; pageErrors.push(p); } } return { date: actualDate, rangeLabel: p1.rangeLabel, // 周榜/月榜的展示用范围,如 "9/5~9/11";日榜为空 prevDate: null, // 新接口不提供,由 stepBackDate 推算 items: [...byId.values()], rankTotal: maxPage ? maxPage * RANK_PAGE_SIZE : null, maxPage, pageErrors, }; }; const mergePeriodIntoSession = (s, date, items) => { items.forEach(it => { const snap = { date, rank: it.rank, bookmarkCount: it.bookmarkCount, fetchedAt: Date.now() }; const exist = s.byId.get(it.id); if (!exist) { const copy = { ...it, snapshots: [snap] }; recomputeDerived(copy); s.byId.set(copy.id, copy); s.items.push(copy); } else { if (!exist.snapshots.some(x => x.date === date)) exist.snapshots.push(snap); // 缺什么补什么,已补全过的收藏数不要被排行榜的空值覆盖 if (exist.bookmarkCount == null && it.bookmarkCount != null) exist.bookmarkCount = it.bookmarkCount; if (exist.textCount == null && it.textCount != null) exist.textCount = it.textCount; if (exist.viewCount == null && it.viewCount != null) exist.viewCount = it.viewCount; if (!exist.url && it.url) exist.url = it.url; if ((!exist.tags || !exist.tags.length) && it.tags.length) exist.tags = it.tags; if (!exist.language && it.language) exist.language = it.language; recomputeDerived(exist); } }); }; // opts: { mode, pagesPerPeriod, periods, startDate } 首次 // { resume:true, extraPeriods? } 续抓 // { retryDates:[...] } 重试指定期 const runRank = async (opts) => { if (isFetching) return; setDelayFloor(CONFIG.RANK_MIN_DELAY); isFetching = true; stopRequested = false; abortController = new AbortController(); const signal = abortController.signal; try { let s; if (opts.resume || opts.retryDates) { s = session; if (!s || s.type !== 'rank') return; if (opts.extraPeriods) s.targetPeriods += opts.extraPeriods; } else { s = newRankSession(opts.mode, opts.pagesPerPeriod); s.targetPeriods = opts.periods; s.cursorDate = opts.startDate || null; // null = 从最新一期开始 session = s; resetViewState(); } const startTime = Date.now(); renderProgressSkeleton(`🏆 ${rankModeLabel(s.mode)}`); const fetchedDates = new Set(s.dates); const newFailed = []; const newFailReasons = {}; const markFailed = (key, reason) => { const k = key || '最新'; newFailed.push(k); newFailReasons[k] = reason; }; // 要抓的期:重试模式给定日期列表;否则沿 prev_date 链式回溯 const retryQueue = opts.retryDates ? opts.retryDates.slice() : null; let guard = 0; while (!signal.aborted && guard++ < CONFIG.RANK_MAX_PERIODS + 10) { let date; if (retryQueue) { if (retryQueue.length === 0) break; date = retryQueue.shift(); } else { if (s.dates.length >= s.targetPeriods) break; if (s.noMore) break; date = s.cursorDate; } const doneCount = retryQueue ? (opts.retryDates.length - retryQueue.length) : s.dates.length; const totalCount = retryQueue ? opts.retryDates.length : s.targetPeriods; const dateLabel = date ? (s.periodLabels[date] || prettyYmd(date)) : '最新一期'; updateProgress(doneCount, totalCount, startTime, '期', `正在抓取 ${dateLabel}`); // 1) 先查缓存(只有日期已知时才可能命中) let period = null; if (date) { const cached = await loadRankCache(`${CONFIG.CACHE_PREFIX}rank:${s.mode}:${date}`); if (cached) { period = { date: cached.date, rangeLabel: cached.rangeLabel || '', prevDate: cached.prevDate || null, items: cached.items, rankTotal: cached.rankTotal, maxPage: cached.maxPage, pageErrors: [], fromCache: true }; } } // 2) 缓存没有就抓 if (!period) { try { period = await fetchRankPeriod(s.mode, date, s.pagesPerPeriod, signal); } catch (e) { if (e.name === 'AbortError') break; if (e instanceof AuthError) { alert(`⚠️ ${e.message}\nR-18 榜单还需要在账号设置里开启成人内容显示。`); break; } if (e instanceof NoDataError) { // 该日期没有榜单:往前挪一格继续试,连续失败太多就收手 if (retryQueue) { markFailed(date, e.message); continue; } const next = date ? stepBackDate(s.mode, date) : null; if (!next) { s.noMore = true; markFailed(date, e.message); break; } s.cursorDate = next; if (++s.emptyStreak > 5) { s.noMore = true; markFailed(date, `已连续 ${s.emptyStreak} 期无数据(${e.message}),停止回溯`); break; } continue; } markFailed(date, e.message || '未知错误'); if (retryQueue) continue; // 网络错误拿不到下一期日期,用兜底步进继续 const next = date ? stepBackDate(s.mode, date) : null; if (!next) break; s.cursorDate = next; continue; } } s.emptyStreak = 0; // 3) 合并 + 写缓存 if (!fetchedDates.has(period.date)) { mergePeriodIntoSession(s, period.date, period.items); fetchedDates.add(period.date); s.dates.push(period.date); s.dates.sort((a, b) => String(b).localeCompare(String(a))); if (period.rangeLabel) s.periodLabels[period.date] = period.rangeLabel; } if (!period.fromCache) { await writeCache(`${CONFIG.CACHE_PREFIX}rank:${s.mode}:${period.date}`, () => ({ mode: s.mode, date: period.date, rangeLabel: period.rangeLabel || '', prevDate: period.prevDate, rankTotal: period.rankTotal, maxPage: period.maxPage, items: period.items, })); } if (period.pageErrors && period.pageErrors.length) { markFailed(period.date, `第 ${period.pageErrors.join('、')} 页请求失败,该期数据可能不完整`); } // 4) 下一期:新接口没有 prev_date,从"接口返回的真实日期"往前推一格。 // 周榜/月榜传任意日期都会落到所在那一期,所以必须以真实日期为基准,否则会原地打转。 if (!retryQueue) { let next = stepBackDate(s.mode, period.date || date); if (next && fetchedDates.has(next)) next = stepBackDate(s.mode, next); if (!next) { s.noMore = true; break; } s.cursorDate = next; } } s.failed = [...new Set(newFailed.filter(Boolean))]; s.failReasons = { ...s.failReasons, ...newFailReasons }; s.stoppedEarly = stopRequested; if (!document.getElementById(BACKDROP_ID)) return; renderResult(); } catch (e) { console.error('[Pixiv优化] 排行抓取异常:', e); } finally { isFetching = false; stopRequested = false; abortController = null; } }; // ==================== 收藏数补全 ==================== const promptEnrich = (missing) => { const input = prompt(`补全多少条的收藏数?(每条 1 次请求,缺 ${missing} 条)\n建议先按当前排序取前 100 条。`, String(Math.min(100, missing))); if (input == null) return; const n = Math.max(1, Math.min(missing, parseInt(input, 10) || 0)); if (!n) return; runEnrich(n); }; const runEnrich = async (count) => { if (isFetching || !session) return; setDelayFloor(CONFIG.RANK_MIN_DELAY); isFetching = true; stopRequested = false; abortController = new AbortController(); const signal = abortController.signal; try { // 按当前显示顺序取,前 count 个缺收藏数的 const targets = updateDisplayData().filter(i => i.bookmarkCount == null).slice(0, count); renderProgressSkeleton('❤️ 补全收藏数'); const startTime = Date.now(); let done = 0; await runPool(targets, CONFIG.ENRICH_CONCURRENCY, async (item) => { try { const json = await fetchJson(buildNovelDetailUrl(item.id), signal); const b = json.body || {}; const bm = num(pick(b.bookmarkCount, b.bookmark_count)); if (bm != null) item.bookmarkCount = bm; const tc = num(pick(b.textCount, b.characterCount, b.wordCount)); if (tc != null) item.textCount = tc; const vc = num(pick(b.viewCount, b.view_count)); if (vc != null) item.viewCount = vc; if (b.createDate) item.createDate = b.createDate; const tags = b.tags && Array.isArray(b.tags.tags) ? b.tags.tags.map(t => t.tag).filter(Boolean) : null; if (tags && tags.length) item.tags = tags; if (session.enriched) session.enriched.add(item.id); // 最新一次快照补上真实收藏数,便于导出时对照 if (item.snapshots && item.snapshots.length && bm != null) { item.snapshots[item.snapshots.length - 1].bookmarkCount = bm; } } catch (e) { if (e.name === 'AbortError') return; // 单条失败忽略,不打断整体 } done++; updateProgress(done, targets.length, startTime, '条'); }, signal); session.stoppedEarly = false; if (!document.getElementById(BACKDROP_ID)) return; renderResult(); } catch (e) { console.error('[Pixiv优化] 补全收藏数异常:', e); } finally { isFetching = false; stopRequested = false; abortController = null; } }; // ==================== 入口界面 ==================== const renderHome = () => { const body = ensurePanel(); const styles = panelThemeStyles; setPanelTitle('Pixiv 小说工具'); const keyword = extractKeyword(); body.innerHTML = `

选择要做什么

🏆 排行榜区间抓取
连续抓取多期日榜/周榜,跨期聚合,统计上榜次数与最好名次
🚫 关键词屏蔽
${blocklist.length > 0 ? `已屏蔽 ${blocklist.length} 个关键词` : '还没有屏蔽词'}
`; if (keyword) { document.getElementById('px-home-search').addEventListener('click', () => renderSearchSetup(keyword)); } document.getElementById('px-home-rank').addEventListener('click', () => renderRankSetup()); document.getElementById('px-home-block').addEventListener('click', () => renderBlocklistManager()); }; const renderBlocklistManager = () => { const body = ensurePanel(); const styles = panelThemeStyles; setPanelTitle('🚫 关键词屏蔽'); body.innerHTML = `

标题或标签命中即隐藏,不看作者名。搜索、排行、原生页面通用,增删立即生效。

`; const renderChips = () => { const wrap = document.getElementById('px-block-list'); const countEl = document.getElementById('px-block-count'); if (!wrap || !countEl) return; countEl.textContent = `已屏蔽的关键词(${blocklist.length})`; if (blocklist.length === 0) { wrap.style.border = 'none'; wrap.innerHTML = `
还没有屏蔽词,在上面输入后点添加
`; return; } wrap.style.border = `1px solid ${styles.inputBorder}`; wrap.innerHTML = blocklist.map((term, idx) => `
${escapeHtml(term)}
`).join(''); }; renderChips(); const input = document.getElementById('px-block-input'); const doAdd = () => { if (addBlockTerm(input.value)) { input.value = ''; renderChips(); } else if (input.value.trim()) { input.value = ''; } input.focus(); }; document.getElementById('px-block-add').addEventListener('click', doAdd); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') doAdd(); }); document.getElementById('px-block-list').addEventListener('click', (e) => { const btn = e.target.closest('.px-block-remove'); if (!btn) return; removeBlockTerm(btn.getAttribute('data-term')); renderChips(); }); document.getElementById('px-back-btn').addEventListener('click', renderHome); }; const renderSearchSetup = async (keyword) => { const body = ensurePanel(); const styles = panelThemeStyles; setPanelTitle('🔍 关键词搜索'); const sMode = normalizeSMode(getQueryParam('s_mode')); const mode = getQueryParam('mode') || 'all'; const cacheKey = `${CONFIG.CACHE_PREFIX}${mode}:${sMode}:${keyword}`; const cached = await loadSearchCache(cacheKey); const knownMaxPage = cached && Number.isFinite(cached.maxPage) ? cached.maxPage : null; const cacheHint = cached ? `
💾 缓存有效 · 已抓 ${cached.fetchedPages.length} 页 (${cached.items.length} 条)${knownMaxPage != null ? ` · 共 ${knownMaxPage} 页` : ''} · ${Math.max(0, Math.round((Date.now() - cached.timestamp) / 60000))} 分钟前
` : ''; body.innerHTML = `

关键词 「${escapeHtml(keyword)}」

${cacheHint}

选择抓取页数

`; const start = (pageCount) => { const limit = knownMaxPage != null ? Math.min(pageCount, knownMaxPage) : pageCount; runSearch(keyword, Array.from({ length: Math.max(1, limit) }, (_, i) => i + 1)); }; [10, 20, 50, 100, 200].forEach(p => { const b = document.createElement('button'); b.textContent = `${p} 页`; b.style.cssText = `padding:6px 18px;border:1px solid ${styles.btnPrimary};border-radius:6px;background:transparent;color:${styles.btnPrimary};font-size:13px;cursor:pointer;`; b.addEventListener('click', () => start(p)); document.getElementById('px-preset-wrap').appendChild(b); }); document.getElementById('px-custom-btn').addEventListener('click', () => { const v = Math.max(1, Math.min(200, parseInt(document.getElementById('px-custom-input').value, 10) || 0)); if (!v) { alert('请输入 1~200 之间的页数'); return; } start(v); }); document.getElementById('px-back-btn').addEventListener('click', renderHome); document.getElementById('px-clear-cache-btn')?.addEventListener('click', () => { if (confirm('清除该关键词的缓存?')) { localStorage.removeItem(cacheKey); renderSearchSetup(keyword); } }); }; const renderRankSetup = () => { const body = ensurePanel(); const styles = panelThemeStyles; setPanelTitle('🏆 排行榜区间抓取'); const urlMode = getQueryParam('mode'); const defaultMode = RANK_MODES.some(m => m.value === urlMode) ? urlMode : 'daily'; body.innerHTML = `
`; const modeEl = document.getElementById('px-rank-mode'); const periodsEl = document.getElementById('px-rank-periods'); const pagesEl = document.getElementById('px-rank-pages'); const refreshEstimate = () => { const periods = Math.max(1, Math.min(CONFIG.RANK_MAX_PERIODS, parseInt(periodsEl.value, 10) || 1)); const pages = Math.max(1, Math.min(CONFIG.RANK_MAX_PAGES_PER_PERIOD, parseInt(pagesEl.value, 10) || 1)); const reqs = periods * pages; const secs = Math.round(reqs * (CONFIG.RANK_MIN_DELAY + 400) / 1000); document.getElementById('px-rank-estimate').textContent = `⚠️ 约 ${reqs} 次请求,预计 ${formatTime(secs)},最多 ${reqs * 50} 条(去重前)`; document.getElementById('px-rank-cache').textContent = `💾 该榜已缓存 ${countRankCache(modeEl.value)} 期(历史期永不过期,会直接复用不再请求)`; }; [modeEl, periodsEl, pagesEl].forEach(el => el.addEventListener('input', refreshEstimate)); modeEl.addEventListener('change', refreshEstimate); refreshEstimate(); document.getElementById('px-rank-start').addEventListener('click', () => { const periods = Math.max(1, Math.min(CONFIG.RANK_MAX_PERIODS, parseInt(periodsEl.value, 10) || 1)); const pages = Math.max(1, Math.min(CONFIG.RANK_MAX_PAGES_PER_PERIOD, parseInt(pagesEl.value, 10) || 1)); const dateVal = document.getElementById('px-rank-date').value; const startDate = dateVal ? dateVal.replace(/-/g, '') : null; runRank({ mode: modeEl.value, periods, pagesPerPeriod: pages, startDate }); }); document.getElementById('px-rank-clear').addEventListener('click', () => { if (confirm(`清除「${rankModeLabel(modeEl.value)}」的全部缓存?`)) { clearRankCache(modeEl.value); refreshEstimate(); } }); document.getElementById('px-back-btn').addEventListener('click', renderHome); }; const onButtonClick = () => { if (isFetching) return; renderHome(); // 不再按页面类型自动跳转,永远弹三个并列选项,自己选 }; // 启动 injectButton(); startObserver(); startNativeBlocking(); // 独立于面板,平时正常刷页面就生效 renderTagQuickBlockButton(); // 如果一进来就是标签页,直接显示快速屏蔽按钮 })();