// ==UserScript== // @name Pixiv小说搜索/排行优化 // @namespace local.pixiv.novel.enhanced.pro // @version 3.1 // @description 关键词搜索抓取 + 日榜/周榜区间抓取(按prev_date链式回溯,跨期聚合上榜次数)+收藏数补全+排序筛选+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) { /* 清理失败不影响主流程 */ } }; 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 || ''), }); 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)), }; }; 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) cursorDate: null, // 下一期要抓的日期,来自上一期的 prev_date targetPeriods: 0, failed: [], // 失败的期 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 = isRankPage ? '🏆 排行抓取' : '🔍 搜索优化'; btn.title = 'Pixiv 小说搜索/排行工具'; Object.assign(btn.style, { position: 'fixed', right: '16px', bottom: '80px', zIndex: CONFIG.BUTTON_Z_INDEX, padding: '8px 14px', borderRadius: '20px', border: 'none', background: 'linear-gradient(135deg, #0096fa, #0066cc)', color: '#fff', fontSize: '13px', fontWeight: '600', cursor: 'pointer', boxShadow: '0 4px 12px 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 => { 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 || []; const tagsHtml = tagNames.length ? `
${tagNames.slice(0, 5).map(t => `#${escapeHtml(t)}`).join('')}${tagNames.length > 5 ? `+${tagNames.length - 5}` : ''}
` : ''; const hotBadge = isHot ? `` : ''; 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'); 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; 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};`; div.innerHTML = ` `; 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; 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 range = session.dates.length ? `${prettyYmd(session.dates[session.dates.length - 1])} ~ ${prettyYmd(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.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 listContainer = document.createElement('div'); listContainer.id = 'px-list'; listContainer.style.cssText = 'flex:1;overflow-y:auto;max-height:62vh;min-height:300px;'; 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, prettyYmd(item.latestDate), (item.snapshots || []).map(s => `${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)去重,避免同一期被算两次。 const RANK_PAGE_SIZE = 50; // "2026年8月30日" / "2026-08-30" → "20260830" const parseRankDate = (raw, fallback) => { const m = String(raw || '').match(/(\d{4})\D+(\d{1,2})\D+(\d{1,2})/); if (m) return `${m[1]}${String(m[2]).padStart(2, '0')}${String(m[3]).padStart(2, '0')}`; return fallback || ''; }; // 兼容 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); } return { list, maxPage, dateLabel: body.date }; }; 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, date); if (!actualDate) throw new NoDataError('无法识别榜单日期'); 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, 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; 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 = []; // 要抓的期:重试模式给定日期列表;否则沿 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; updateProgress(doneCount, totalCount, startTime, '期', date ? `正在抓取 ${prettyYmd(date)}` : '正在抓取最新一期'); // 1) 先查缓存(只有日期已知时才可能命中) let period = null; if (date) { const cached = await loadRankCache(`${CONFIG.CACHE_PREFIX}rank:${s.mode}:${date}`); if (cached) { period = { date: cached.date, 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) { newFailed.push(date); continue; } const next = date ? stepBackDate(s.mode, date) : null; if (!next) { s.noMore = true; break; } s.cursorDate = next; if (++s.emptyStreak > 5) { s.noMore = true; break; } continue; } newFailed.push(date || '最新'); if (retryQueue) continue; // 网络错误拿不到 prev_date,用兜底步进继续 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.fromCache) { await writeCache(`${CONFIG.CACHE_PREFIX}rank:${s.mode}:${period.date}`, () => ({ mode: s.mode, date: period.date, prevDate: period.prevDate, rankTotal: period.rankTotal, maxPage: period.maxPage, items: period.items, })); } if (period.pageErrors && period.pageErrors.length) newFailed.push(period.date); // 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.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 = `

选择要做什么

🏆 排行榜区间抓取
连续抓取多期日榜/周榜,跨期聚合,统计上榜次数与最好名次
`; if (keyword) { document.getElementById('px-home-search').addEventListener('click', () => renderSearchSetup(keyword)); } document.getElementById('px-home-rank').addEventListener('click', () => renderRankSetup()); }; 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}

选择抓取页数(每页约 24~30 条,超出实际页数的部分会自动跳过)

`; 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 = `

起始日期留空 = 从最新一期开始,向过去回溯(日榜每次退 1 天,周榜退 7 天,月榜退 30 天)。落到同一期的重复日期会自动跳过。

`; 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; if (isRankPage) renderRankSetup(); else renderHome(); }; // 启动 injectButton(); startObserver(); })();