// ==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 ? `

` : ''}
✍️ ${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 = `
⏱ 已用: 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 = `
选择要做什么
🔍 关键词 / 标签搜索
${keyword ? `当前关键词:「${escapeHtml(keyword)}」,按收藏数重排` : '当前页面没有搜索关键词,请到小说搜索结果页使用'}
🏆 排行榜区间抓取
连续抓取多期日榜/周榜,跨期聚合,统计上榜次数与最好名次
`;
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 = ``;
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();
})();