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

` : ''}
✍️ ${authorHtml}
❤️ ${bmHtml}
📄 ${textCount}
${viewHtml}
${createDate ? `📅 ${createDate}` : ''}
${rankHtml}
${tagsHtml}
`;
};
const renderList = (container, items) => {
if (!container) return;
const styles = panelThemeStyles;
if (items.length === 0) {
container.innerHTML = `没有匹配的结果
`;
return;
}
const useAuto = !isMobile() && items.length > CONFIG.VIRTUAL_THRESHOLD;
let html = '';
items.forEach((item, idx) => {
html += `${renderItem(item, idx, styles)}
`;
});
container.innerHTML = html;
};
const refreshList = () => {
const listContainer = document.getElementById('px-list');
if (listContainer) {
renderList(listContainer, updateDisplayData());
updateStats();
}
};
const updateStats = () => {
const statsDiv = document.getElementById('px-stats');
if (!statsDiv || !session) return;
const filtered = applyFilters(session.items);
const withBm = filtered.filter(i => i.bookmarkCount != null);
const totalBm = withBm.reduce((s, i) => s + i.bookmarkCount, 0);
const avgBm = withBm.length > 0 ? Math.round(totalBm / withBm.length) : 0;
statsDiv.innerHTML = `📄 ${filtered.length}❤️ ${totalBm}📊 均 ${avgBm}`;
};
const styledBtn = (text, kind) => {
const s = panelThemeStyles;
const b = document.createElement('button');
b.textContent = text;
const bg = kind === 'danger' ? s.btnDanger : kind === 'success' ? s.btnSuccess : kind === 'ghost' ? 'transparent' : s.btnPrimary;
b.style.cssText = `padding:4px 10px;border:${kind === 'ghost' ? `1px solid ${s.btnPrimary}` : 'none'};border-radius:6px;background:${bg};color:${kind === 'ghost' ? s.btnPrimary : '#fff'};font-size:13px;cursor:pointer;`;
return b;
};
// 还没抓、但属于本轮目标的东西
const getSearchRemaining = () => {
if (!session || session.type !== 'search') return [];
let rem = diffPages(session.targetPages, session.fetchedPages);
if (session.maxPage != null) rem = rem.filter(p => p <= session.maxPage);
const failed = new Set(session.failed);
return rem.filter(p => !failed.has(p));
};
const getRankRemaining = () => {
if (!session || session.type !== 'rank') return 0;
if (session.noMore) return 0;
return Math.max(0, session.targetPeriods - session.dates.length);
};
const buildControls = () => {
const styles = panelThemeStyles;
const controls = document.createElement('div');
Object.assign(controls.style, {
display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '8px 12px',
padding: '8px 0 12px 0', borderBottom: `1px solid ${styles.divider}`, flexShrink: '0',
});
// 排序
const sortGroup = document.createElement('div');
sortGroup.style.cssText = 'display:flex;align-items:center;gap:4px;';
sortGroup.innerHTML = `排序`;
const sortSelect = document.createElement('select');
sortSelect.style.cssText = `padding:4px 8px;border:1px solid ${styles.inputBorder};border-radius:6px;font-size:13px;background:${styles.inputBg};color:${styles.inputText};cursor:pointer;`;
sortFieldsFor(session).forEach(f => {
const opt = document.createElement('option');
opt.value = f.value;
opt.textContent = f.label;
sortSelect.appendChild(opt);
});
sortSelect.value = sortField;
sortSelect.addEventListener('change', () => {
sortField = sortSelect.value;
// 名次是越小越好,切过去时默认升序
if (sortField === 'bestRank') { sortAsc = true; orderBtn.textContent = '▲'; }
refreshList();
});
const orderBtn = document.createElement('button');
orderBtn.textContent = sortAsc ? '▲' : '▼';
orderBtn.style.cssText = `padding:4px 8px;border:1px solid ${styles.inputBorder};border-radius:6px;background:${styles.inputBg};color:${styles.inputText};font-size:13px;cursor:pointer;`;
orderBtn.addEventListener('click', () => {
sortAsc = !sortAsc;
orderBtn.textContent = sortAsc ? '▲' : '▼';
refreshList();
});
sortGroup.appendChild(sortSelect);
sortGroup.appendChild(orderBtn);
controls.appendChild(sortGroup);
// 即时文本过滤
const textFilter = document.createElement('input');
textFilter.placeholder = '🔍 筛选标题';
textFilter.style.cssText = `padding:4px 10px;border:1px solid ${styles.inputBorder};border-radius:6px;font-size:13px;background:${styles.inputBg};color:${styles.inputText};width:120px;`;
textFilter.addEventListener('input', (e) => {
const val = e.target.value.toLowerCase();
document.querySelectorAll('.px-result-card').forEach(card => {
const t = card.querySelector('a[href*="/novel/show.php"]')?.textContent.toLowerCase() || '';
card.style.display = t.includes(val) ? '' : 'none';
});
});
controls.appendChild(textFilter);
const advancedBtn = document.createElement('button');
advancedBtn.textContent = '⚙️ 高级';
advancedBtn.style.cssText = `padding:4px 8px;border:1px solid ${styles.inputBorder};border-radius:6px;background:${styles.inputBg};color:${styles.inputText};font-size:13px;cursor:pointer;`;
advancedBtn.addEventListener('click', () => toggleAdvancedFilters(controls));
controls.appendChild(advancedBtn);
const statsDiv = document.createElement('div');
statsDiv.id = 'px-stats';
statsDiv.style.cssText = `font-size:12px;color:${styles.textMuted};display:flex;gap:12px;margin-left:auto;`;
controls.appendChild(statsDiv);
const exportHtmlBtn = styledBtn('📥 HTML', 'success');
exportHtmlBtn.addEventListener('click', () => exportHTML(updateDisplayData()));
controls.appendChild(exportHtmlBtn);
const exportCsvBtn = styledBtn('📊 CSV', 'success');
exportCsvBtn.addEventListener('click', () => exportCSV(updateDisplayData()));
controls.appendChild(exportCsvBtn);
// 排行模式:收藏数补全
if (session.type === 'rank') {
const missing = session.items.filter(i => i.bookmarkCount == null).length;
if (missing > 0 && !isFetching) {
const enrichBtn = styledBtn(`❤️ 补全收藏数 (缺 ${missing})`, 'ghost');
enrichBtn.addEventListener('click', () => promptEnrich(missing));
controls.appendChild(enrichBtn);
}
}
// 失败项重试
if (session.failed.length > 0 && !isFetching) {
const label = session.type === 'search' ? '页' : '期';
const retryAll = styledBtn(`🔄 重试失败${label} (${session.failed.length})`, 'danger');
if (session.type === 'rank' && session.failReasons) {
const lines = session.failed.map(d => `${d === '最新' ? '最新一期' : (session.periodLabels[d] || prettyYmd(d))}: ${session.failReasons[d] || '未知原因'}`);
retryAll.title = lines.join('\n');
}
retryAll.addEventListener('click', () => {
if (session.type === 'search') runSearch(session.keyword, session.failed.slice());
else runRank({ retryDates: session.failed.slice() });
});
controls.appendChild(retryAll);
}
if (!isFetching) {
if (session.type === 'search') {
const remaining = getSearchRemaining();
if (remaining.length > 0) {
const resume = styledBtn(`▶ 继续抓取剩余 ${remaining.length} 页`);
resume.addEventListener('click', () => runSearch(session.keyword, session.targetPages.slice()));
controls.appendChild(resume);
} else if (session.noMore) {
const tag = document.createElement('span');
tag.textContent = session.total != null ? `✅ 已加载全部(共 ${session.total} 条)` : '✅ 已加载全部';
tag.style.cssText = `font-size:12px;color:${styles.textMuted};`;
controls.appendChild(tag);
} else if (session.fetchedPages.length > 0) {
const more = styledBtn(`➕ 加载更多${session.maxPage != null ? `(共 ${session.maxPage} 页)` : ''}`, 'ghost');
more.addEventListener('click', () => loadMoreSearchPages());
controls.appendChild(more);
}
} else {
const remaining = getRankRemaining();
if (remaining > 0) {
const resume = styledBtn(`▶ 继续抓取剩余 ${remaining} 期`);
resume.addEventListener('click', () => runRank({ resume: true }));
controls.appendChild(resume);
} else if (session.noMore) {
const tag = document.createElement('span');
tag.textContent = '✅ 已到最早一期';
tag.style.cssText = `font-size:12px;color:${styles.textMuted};`;
controls.appendChild(tag);
} else {
const more = styledBtn(`➕ 再抓 ${CONFIG.RANK_LOAD_MORE_PERIODS} 期`, 'ghost');
more.addEventListener('click', () => runRank({ resume: true, extraPeriods: CONFIG.RANK_LOAD_MORE_PERIODS }));
controls.appendChild(more);
}
}
}
setTimeout(updateStats, 0);
return controls;
};
const toggleAdvancedFilters = (controlsParent) => {
const existing = document.getElementById('px-advanced-filters');
if (existing) { existing.remove(); return; }
const styles = panelThemeStyles;
// 语言区块只统计"确实带 language 字段"的条目——目前只有排行榜数据实测
// 确认有这个字段,搜索结果如果没有这个字段,distinctLangs 会是空的,
// 语言区块自动不显示,不会出现"一堆空checkbox"这种莫名其妙的东西
const langCounts = new Map();
(session?.items || []).forEach(i => {
if (i.language) langCounts.set(i.language, (langCounts.get(i.language) || 0) + 1);
});
const distinctLangs = [...langCounts.keys()].sort();
const div = document.createElement('div');
div.id = 'px-advanced-filters';
div.style.cssText = `display:flex;flex-wrap:wrap;gap:10px;padding:10px;background:${styles.cardBg};border:1px solid ${styles.divider};border-radius:8px;margin:8px 0;font-size:13px;color:${styles.textSecondary};`;
const langBlockHtml = distinctLangs.length > 0 ? `
语言:
${distinctLangs.map(code => `
`).join('')}
` : '';
div.innerHTML = `
${langBlockHtml}
`;
controlsParent.insertAdjacentElement('afterend', div);
document.getElementById('bm-min').value = filters.bookmarkMin || '';
document.getElementById('bm-max').value = filters.bookmarkMax === Infinity ? '' : filters.bookmarkMax;
document.getElementById('wc-min').value = filters.textCountMin || '';
document.getElementById('wc-max').value = filters.textCountMax === Infinity ? '' : filters.textCountMax;
document.getElementById('date-from').value = filters.dateFrom;
document.getElementById('date-to').value = filters.dateTo;
document.getElementById('apply-filters').addEventListener('click', () => {
filters.bookmarkMin = parseInt(document.getElementById('bm-min').value) || 0;
filters.bookmarkMax = parseInt(document.getElementById('bm-max').value) || Infinity;
filters.textCountMin = parseInt(document.getElementById('wc-min').value) || 0;
filters.textCountMax = parseInt(document.getElementById('wc-max').value) || Infinity;
filters.dateFrom = document.getElementById('date-from').value;
filters.dateTo = document.getElementById('date-to').value;
const langCbs = Array.from(document.querySelectorAll('.px-lang-cb'));
if (langCbs.length > 0) {
const checked = langCbs.filter(cb => cb.checked).map(cb => cb.value);
// 全选=不限制(这样以后新出现的、当前还没见过的语言也不会被误伤)
allowedLanguages = checked.length >= langCbs.length ? null : new Set(checked);
saveAllowedLanguages();
}
refreshList();
});
};
const sessionTitle = () => {
if (!session) return 'Pixiv 小说工具';
if (session.type === 'search') {
const pageHint = session.maxPage != null
? ` · 已抓 ${session.fetchedPages.length}/${session.maxPage} 页`
: ` · 已抓 ${session.fetchedPages.length} 页`;
return `「${session.keyword}」· ${session.items.length} 条${pageHint}`;
}
const labelOf = (d) => session.periodLabels?.[d] || prettyYmd(d);
const range = session.dates.length
? `${labelOf(session.dates[session.dates.length - 1])} ~ ${labelOf(session.dates[0])}`
: '';
return `🏆 ${rankModeLabel(session.mode)} · ${session.dates.length} 期 · ${session.items.length} 篇${range ? ` · ${range}` : ''}`;
};
const renderResult = () => {
if (!document.getElementById(BACKDROP_ID)) return;
const body = ensurePanel();
const styles = panelThemeStyles;
setPanelTitle(sessionTitle());
const container = document.createElement('div');
container.style.cssText = `display:flex;flex-direction:column;gap:12px;height:100%;color:${styles.textPrimary};`;
if (session.type === 'rank' && session.items.length === 0) {
// 一条都没抓到:绝不能只给一个"没有匹配的结果"就完事,必须说清楚原因,
// 否则和"这功能就是坏的"没法区分——这正是本次修复要解决的体验问题。
const notice = document.createElement('div');
notice.style.cssText = `font-size:13px;color:${styles.btnDanger};background:${styles.warnBg};border-left:4px solid ${styles.btnDanger};padding:10px 14px;border-radius:8px;`;
const reasons = session.failed.map(d => `「${d === '最新' ? '最新一期' : (session.periodLabels[d] || prettyYmd(d))}」:${session.failReasons[d] || '未知原因'}`);
notice.innerHTML = `❌ 一条数据都没抓到。
${reasons.length ? reasons.map(escapeHtml).join('
') : '没有记录到具体失败原因,可能是网络问题,请重试。'}`;
container.appendChild(notice);
body.innerHTML = '';
body.appendChild(container);
const backBtn = styledBtn('← 返回重新设置', 'ghost');
backBtn.addEventListener('click', () => (isRankPage ? renderRankSetup() : renderHome()));
container.appendChild(backBtn);
return;
}
if (session.stoppedEarly) {
const notice = document.createElement('div');
notice.style.cssText = `font-size:13px;color:${styles.hintText};background:${styles.hintBg};border-left:4px solid ${styles.hintBorder};padding:8px 14px;border-radius:8px;`;
notice.textContent = '⏹ 已停止抓取,下面是目前已经抓到的内容(可继续抓取剩余部分)。';
container.appendChild(notice);
}
if (session.type === 'rank' && session.items.length > 0 && session.items.every(i => i.bookmarkCount == null)) {
const notice = document.createElement('div');
notice.style.cssText = `font-size:13px;color:${styles.warnText};background:${styles.warnBg};border-left:4px solid ${styles.warnText};padding:8px 14px;border-radius:8px;`;
notice.textContent = '⚠️ 排行榜接口没有返回收藏数(老接口通常只给浏览数/评分数)。点右上「补全收藏数」按需拉取真实收藏数。';
container.appendChild(notice);
}
if (session.type === 'rank') {
const tip = document.createElement('div');
tip.style.cssText = `font-size:12px;color:${styles.textMuted};`;
tip.textContent = '💡 各期收藏数为抓取当下的实时值,非历史值';
container.appendChild(tip);
}
const blockedCount = session.items.filter(isBlocked).length;
if (blockedCount > 0) {
// 数量变少必须说明原因——这条经验来自排行榜那次的教训:悄悄减少而不说明,
// 用户分不清是"命中了屏蔽词"还是"抓取又出故障了"
const tip = document.createElement('div');
tip.style.cssText = `font-size:12px;color:${styles.textMuted};`;
tip.textContent = `🚫 已根据屏蔽词过滤掉 ${blockedCount} 条`;
container.appendChild(tip);
}
if (allowedLanguages && allowedLanguages.size > 0) {
const langBlockedCount = session.items.filter(isLanguageBlocked).length;
if (langBlockedCount > 0) {
const tip = document.createElement('div');
tip.style.cssText = `font-size:12px;color:${styles.textMuted};`;
tip.textContent = `🌐 已仅显示:${[...allowedLanguages].map(langLabel).join('、')}(过滤掉其他语言 ${langBlockedCount} 条)`;
container.appendChild(tip);
}
}
const listContainer = document.createElement('div');
listContainer.id = 'px-list';
listContainer.style.cssText = 'flex:1;overflow-y:auto;max-height:62vh;min-height:300px;';
// 标签旁的 🚫 按钮走事件委托:renderList 每次都用 innerHTML 整体重绘,
// 单独给每个按钮绑监听器会在下一次重绘时全部失效,委托到容器上一次搞定
listContainer.addEventListener('click', (e) => {
const btn = e.target.closest('.px-tag-block-btn');
if (!btn) return;
e.preventDefault();
const term = btn.getAttribute('data-term');
if (term && addBlockTerm(term)) renderResult(); // 整块重渲染,连"已过滤 N 条"提示一起更新
});
container.appendChild(buildControls());
container.appendChild(listContainer);
body.innerHTML = '';
body.appendChild(container);
renderList(listContainer, updateDisplayData());
keyboardNavState = { highlightedIdx: -1 };
};
// ==================== 导出 ====================
const exportBaseName = () => {
const d = new Date().toISOString().slice(0, 10);
return session.type === 'search'
? `pixiv_search_${session.keyword}_${d}`
: `pixiv_rank_${session.mode}_${d}`;
};
const downloadBlob = (blob, filename) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
const exportHTML = (items) => {
const styles = panelThemeStyles;
const rows = items.map((item, idx) => renderItem(item, idx, styles)).join('\n');
const html = `
${escapeHtml(sessionTitle())}
${escapeHtml(sessionTitle())} · 导出 ${items.length} 条 · ${new Date().toLocaleString()}
${rows}
`;
downloadBlob(new Blob([html], { type: 'text/html;charset=utf-8;' }), `${exportBaseName()}.html`);
};
const exportCSV = (items) => {
const isRank = session.type === 'rank';
const header = ['序号', '标题', '作者', '收藏数', '字数', '浏览数', '发布日期', '标签', '链接'];
if (isRank) header.push('上榜次数', '最好名次', '最近上榜', '上榜明细');
const rows = items.map((item, idx) => {
const base = [
idx + 1,
item.title || '',
item.userName || '',
item.bookmarkCount == null ? '' : item.bookmarkCount,
item.textCount == null ? '' : item.textCount,
item.viewCount == null ? '' : item.viewCount,
item.createDate || '',
(item.tags || []).join(';'),
`https://www.pixiv.net/novel/show.php?id=${item.id}`,
];
if (isRank) {
base.push(
item.appearCount || 0,
item.bestRank == null ? '' : item.bestRank,
session.periodLabels[item.latestDate] || prettyYmd(item.latestDate),
(item.snapshots || []).map(s => `${session.periodLabels[s.date] || prettyYmd(s.date)}#${s.rank ?? '?'}`).join(' ')
);
}
return base;
});
const csv = [header, ...rows].map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n');
downloadBlob(new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }), `${exportBaseName()}.csv`);
};
// ==================== 进度界面 ====================
const renderProgressSkeleton = (heading) => {
const body = ensurePanel();
const styles = panelThemeStyles;
setPanelTitle(heading || '抓取中');
body.innerHTML = `
⏱ 已用: 0s
⏳ 预计剩余: --
`;
document.getElementById('px-stop-btn').addEventListener('click', (e) => {
stopRequested = true;
const btn = e.currentTarget;
btn.disabled = true;
btn.style.opacity = '0.6';
btn.textContent = '正在停止…';
const textEl = document.getElementById('px-progress-text');
if (textEl) textEl.textContent = '⏹ 正在停止,马上展示已抓取的内容…';
abortController?.abort();
});
};
const updateProgress = (current, total, startTime, unit, sub) => {
if (stopRequested) return;
const pct = total > 0 ? Math.min(100, Math.round((current / total) * 100)) : 0;
const bar = document.getElementById('px-progress-bar');
const text = document.getElementById('px-progress-text');
const pctEl = document.getElementById('px-progress-percent');
const timeEl = document.getElementById('px-progress-time');
const estEl = document.getElementById('px-progress-estimate');
const subEl = document.getElementById('px-progress-sub');
if (text) text.textContent = `⏳ 抓取中:${current} / ${total} ${unit || '页'}`;
if (bar) bar.style.width = pct + '%';
if (pctEl) pctEl.textContent = pct + '%';
if (subEl && sub != null) subEl.textContent = sub;
if (startTime) {
const elapsed = (Date.now() - startTime) / 1000;
if (timeEl) timeEl.textContent = `⏱ 已用: ${formatTime(elapsed)}`;
if (estEl) {
if (current > 0 && current < total) estEl.textContent = `⏳ 预计剩余: ${formatTime((total - current) * (elapsed / current))}`;
else if (current >= total) estEl.textContent = '✅ 完成';
}
}
};
// ==================== 抓取:关键词搜索 ====================
const fetchSearchPages = async ({ pages, keyword, sMode, mode, signal, onPageResult }) => {
const queue = [...pages].sort((a, b) => a - b);
let boundary = Infinity;
let authError = null;
let totalCount = null;
const handleBody = (page, body) => {
const section = body.novel || body.novelManga || {};
const list = section.data || [];
const total = Number(section.total);
if (Number.isFinite(total) && total >= 0) {
totalCount = total;
const size = list.length > 0 ? list.length : CONFIG.PAGE_SIZE_FALLBACK;
boundary = Math.min(boundary, Math.max(1, Math.ceil(total / size)) + 1);
}
if (list.length === 0) {
boundary = Math.min(boundary, page);
onPageResult({ page, status: 'empty' });
} else {
onPageResult({ page, status: 'ok', items: list.map(normalizeSearchItem) });
}
};
const fetchOne = async (page) => {
const json = await fetchJson(buildSearchUrl(keyword, page, sMode, mode), signal);
handleBody(page, json.body || {});
};
// 探测页:先拿 total,据此裁掉所有不存在的页码
if (queue.length > 0) {
const probe = queue.shift();
try {
await fetchOne(probe);
} catch (e) {
if (e instanceof AuthError) {
onPageResult({ page: probe, status: 'auth-error', error: e });
return { maxPage: null, total: null };
}
if (e.name === 'AbortError') { onPageResult({ page: probe, status: 'aborted' }); return { maxPage: null, total: null }; }
onPageResult({ page: probe, status: 'error', error: e });
}
}
await runPool(queue, CONFIG.CONCURRENCY, async (page) => {
if (authError) return;
if (page >= boundary) { onPageResult({ page, status: 'skipped' }); return; }
try {
await fetchOne(page);
} catch (e) {
if (e instanceof AuthError) { authError = e; onPageResult({ page, status: 'auth-error', error: e }); return; }
if (e.name === 'AbortError') { onPageResult({ page, status: 'aborted' }); return; }
if (e instanceof NoDataError) { boundary = Math.min(boundary, page); onPageResult({ page, status: 'empty' }); return; }
onPageResult({ page, status: 'error', error: e });
}
}, signal);
return { maxPage: boundary === Infinity ? null : boundary - 1, total: totalCount };
};
const runSearch = async (keyword, targetPages) => {
if (isFetching) return;
setDelayFloor(CONFIG.MIN_DELAY);
const sMode = normalizeSMode(getQueryParam('s_mode'));
const mode = getQueryParam('mode') || 'all';
const cacheKey = `${CONFIG.CACHE_PREFIX}${mode}:${sMode}:${keyword}`;
isFetching = true;
stopRequested = false;
abortController = new AbortController();
const signal = abortController.signal;
try {
if (!session || session.type !== 'search' || session.keyword !== keyword) {
session = newSearchSession(keyword, cacheKey);
resetViewState();
}
const s = session;
const cached = await loadSearchCache(cacheKey);
if (cached && s.items.length === 0) {
s.items = (cached.items || []).map(i => ({ ...i }));
s.items.forEach(i => s.byId.set(i.id, i));
s.fetchedPages = cached.fetchedPages.slice();
s.maxPage = Number.isFinite(cached.maxPage) ? cached.maxPage : null;
s.total = Number.isFinite(cached.total) ? cached.total : null;
}
let targets = [...new Set(targetPages)].sort((a, b) => a - b);
if (s.maxPage != null) targets = targets.filter(p => p <= s.maxPage);
if (targets.length === 0) targets = [1];
s.targetPages = targets;
const missing = diffPages(targets, s.fetchedPages);
if (missing.length === 0) {
s.failed = [];
s.noMore = reachedEnd(s.fetchedPages, s.maxPage);
s.stoppedEarly = false;
renderResult();
return;
}
renderProgressSkeleton(`🔍 「${keyword}」`);
const startTime = Date.now();
let completed = 0;
const newFailed = [];
let authErrorMsg = null;
const result = await fetchSearchPages({
pages: missing, keyword, sMode, mode, signal,
onPageResult: ({ page, status, items, error }) => {
if (status === 'ok') {
items.forEach(it => {
if (!s.byId.has(it.id)) { s.byId.set(it.id, it); s.items.push(it); }
});
s.fetchedPages.push(page);
} else if (status === 'error') {
newFailed.push(page);
} else if (status === 'auth-error') {
authErrorMsg = error.message;
}
completed++;
updateProgress(completed, missing.length, startTime, '页');
},
});
if (result.maxPage != null) s.maxPage = s.maxPage == null ? result.maxPage : Math.min(s.maxPage, result.maxPage);
if (result.total != null) s.total = result.total;
if (s.maxPage != null) s.targetPages = s.targetPages.filter(p => p <= s.maxPage);
s.failed = newFailed;
s.noMore = reachedEnd(s.fetchedPages, s.maxPage);
s.stoppedEarly = stopRequested;
if (s.items.length > 0) {
await writeCache(cacheKey, () => ({
items: s.items, fetchedPages: s.fetchedPages, maxPage: s.maxPage, total: s.total,
}));
}
if (!document.getElementById(BACKDROP_ID)) return;
renderResult();
if (authErrorMsg) alert(`⚠️ ${authErrorMsg}\n请确认已登录 Pixiv,然后重试失败页。`);
} catch (e) {
console.error('[Pixiv优化] 搜索抓取异常:', e);
} finally {
isFetching = false;
stopRequested = false;
abortController = null;
}
};
const loadMoreSearchPages = () => {
const s = session;
const known = s.fetchedPages.length > 0 ? Math.max(...s.fetchedPages) : 0;
const target = new Set([...s.targetPages, ...s.fetchedPages]);
for (let p = known + 1; p <= known + CONFIG.DEFAULT_LOAD_MORE_SIZE; p++) {
if (s.maxPage != null && p > s.maxPage) break;
target.add(p);
}
runSearch(s.keyword, [...target].sort((a, b) => a - b));
};
// ==================== 抓取:排行榜区间 ====================
// 一期 = 一个榜单期。新接口没有 prev_date,所以日期由脚本自己按步长回推,
// 再用接口返回的真实日期去重,避免同一期被算两次。
//
// 关键点:日榜的 body.date 是确切单日("2026年8月30日"),但周榜/月榜的
// body.date 恒为 null,真实范围放在 body.start / body.end 里(如 start=
// "2026年9月5日", end="2026年9月11日")。之前只认 body.date,遇到 null
// 就直接判定"无法识别日期"而整期放弃——这是周榜完全抓不到数据的根因。
// 现在按 date → end → start 的顺序取值,取不到确切单日就用该期的结束日
// 期作为这一期的"锚点日期"(用于缓存 key 和向前步进)。
const RANK_PAGE_SIZE = 50;
// "2026年8月30日" / "2026-08-30" → "20260830";也兼容"2026年8月"这种只有年月、
// 没有日的写法(月榜可能这样给),此时取当月 1 号。
const parseRankDate = (raw) => {
const s = String(raw || '');
const full = s.match(/(\d{4})\D+(\d{1,2})\D+(\d{1,2})/);
if (full) return `${full[1]}${String(full[2]).padStart(2, '0')}${String(full[3]).padStart(2, '0')}`;
const ym = s.match(/(\d{4})\D+(\d{1,2})月?$/);
if (ym) return `${ym[1]}${String(ym[2]).padStart(2, '0')}01`;
return '';
};
// "2026年9月5日" + "2026年9月11日" → "9/5~9/11",用于界面展示(比单个日期更准确)
const formatRangeLabel = (startRaw, endRaw) => {
const short = (raw) => {
const m = String(raw || '').match(/(\d{1,2})\D+(\d{1,2})日/);
return m ? `${m[1]}/${m[2]}` : '';
};
const a = short(startRaw), b = short(endRaw);
return a && b ? `${a}~${b}` : '';
};
// 兼容 body.display_a.rank_a(当前结构)与几种可能的变体
const extractRankPage = (json) => {
const body = json.body || {};
const display = body.display_a || body.display || {};
const list = Array.isArray(display.rank_a) ? display.rank_a
: Array.isArray(body.rank_a) ? body.rank_a
: Array.isArray(body.contents) ? body.contents : [];
let maxPage = null;
const pa = display.page_a || body.page_a;
if (Array.isArray(pa) && pa.length) {
const nums = pa.map(v => Number(v && typeof v === 'object' ? (v.page ?? v.p) : v)).filter(Number.isFinite);
if (nums.length) maxPage = Math.max(...nums);
}
// date → end → start:日榜给确切单日;周榜/月榜的 date 恒为 null,只能退而
// 求其次用范围的结束日期当锚点
const dateLabel = body.date || body.end || body.start || '';
const rangeLabel = formatRangeLabel(body.start, body.end);
return { list, maxPage, dateLabel, rangeLabel };
};
const fetchRankPeriod = async (mode, date, pagesWanted, signal) => {
const first = await fetchJson(buildRankUrl(mode, date, 1), signal);
const p1 = extractRankPage(first);
if (p1.list.length === 0) throw new NoDataError('该期没有数据');
const actualDate = parseRankDate(p1.dateLabel);
if (!actualDate) throw new NoDataError(`无法从接口返回中识别出日期(原始值: ${JSON.stringify(p1.dateLabel)}),请把这条报错反馈给开发者`);
const byId = new Map();
const collect = (list) => list.forEach(raw => {
const it = normalizeRankItem(raw);
if (it.id) byId.set(it.id, it);
});
collect(p1.list);
const maxPage = p1.maxPage || null;
const lastPage = maxPage ? Math.min(pagesWanted, maxPage) : pagesWanted;
const pageErrors = [];
// 同一期内顺序翻页:不知道总页数时靠"空页/短页"判断到底,避免多请求
for (let p = 2; p <= lastPage; p++) {
if (signal?.aborted) break;
try {
const json = await fetchJson(buildRankUrl(mode, actualDate, p), signal);
const pg = extractRankPage(json);
if (pg.list.length === 0) break;
collect(pg.list);
if (!maxPage && pg.list.length < RANK_PAGE_SIZE) break;
} catch (e) {
if (e.name === 'AbortError') break;
if (e instanceof NoDataError) break;
pageErrors.push(p);
}
}
return {
date: actualDate,
rangeLabel: p1.rangeLabel, // 周榜/月榜的展示用范围,如 "9/5~9/11";日榜为空
prevDate: null, // 新接口不提供,由 stepBackDate 推算
items: [...byId.values()],
rankTotal: maxPage ? maxPage * RANK_PAGE_SIZE : null,
maxPage,
pageErrors,
};
};
const mergePeriodIntoSession = (s, date, items) => {
items.forEach(it => {
const snap = { date, rank: it.rank, bookmarkCount: it.bookmarkCount, fetchedAt: Date.now() };
const exist = s.byId.get(it.id);
if (!exist) {
const copy = { ...it, snapshots: [snap] };
recomputeDerived(copy);
s.byId.set(copy.id, copy);
s.items.push(copy);
} else {
if (!exist.snapshots.some(x => x.date === date)) exist.snapshots.push(snap);
// 缺什么补什么,已补全过的收藏数不要被排行榜的空值覆盖
if (exist.bookmarkCount == null && it.bookmarkCount != null) exist.bookmarkCount = it.bookmarkCount;
if (exist.textCount == null && it.textCount != null) exist.textCount = it.textCount;
if (exist.viewCount == null && it.viewCount != null) exist.viewCount = it.viewCount;
if (!exist.url && it.url) exist.url = it.url;
if ((!exist.tags || !exist.tags.length) && it.tags.length) exist.tags = it.tags;
if (!exist.language && it.language) exist.language = it.language;
recomputeDerived(exist);
}
});
};
// opts: { mode, pagesPerPeriod, periods, startDate } 首次
// { resume:true, extraPeriods? } 续抓
// { retryDates:[...] } 重试指定期
const runRank = async (opts) => {
if (isFetching) return;
setDelayFloor(CONFIG.RANK_MIN_DELAY);
isFetching = true;
stopRequested = false;
abortController = new AbortController();
const signal = abortController.signal;
try {
let s;
if (opts.resume || opts.retryDates) {
s = session;
if (!s || s.type !== 'rank') return;
if (opts.extraPeriods) s.targetPeriods += opts.extraPeriods;
} else {
s = newRankSession(opts.mode, opts.pagesPerPeriod);
s.targetPeriods = opts.periods;
s.cursorDate = opts.startDate || null; // null = 从最新一期开始
session = s;
resetViewState();
}
const startTime = Date.now();
renderProgressSkeleton(`🏆 ${rankModeLabel(s.mode)}`);
const fetchedDates = new Set(s.dates);
const newFailed = [];
const newFailReasons = {};
const markFailed = (key, reason) => { const k = key || '最新'; newFailed.push(k); newFailReasons[k] = reason; };
// 要抓的期:重试模式给定日期列表;否则沿 prev_date 链式回溯
const retryQueue = opts.retryDates ? opts.retryDates.slice() : null;
let guard = 0;
while (!signal.aborted && guard++ < CONFIG.RANK_MAX_PERIODS + 10) {
let date;
if (retryQueue) {
if (retryQueue.length === 0) break;
date = retryQueue.shift();
} else {
if (s.dates.length >= s.targetPeriods) break;
if (s.noMore) break;
date = s.cursorDate;
}
const doneCount = retryQueue ? (opts.retryDates.length - retryQueue.length) : s.dates.length;
const totalCount = retryQueue ? opts.retryDates.length : s.targetPeriods;
const dateLabel = date ? (s.periodLabels[date] || prettyYmd(date)) : '最新一期';
updateProgress(doneCount, totalCount, startTime, '期', `正在抓取 ${dateLabel}`);
// 1) 先查缓存(只有日期已知时才可能命中)
let period = null;
if (date) {
const cached = await loadRankCache(`${CONFIG.CACHE_PREFIX}rank:${s.mode}:${date}`);
if (cached) {
period = { date: cached.date, rangeLabel: cached.rangeLabel || '', prevDate: cached.prevDate || null, items: cached.items, rankTotal: cached.rankTotal, maxPage: cached.maxPage, pageErrors: [], fromCache: true };
}
}
// 2) 缓存没有就抓
if (!period) {
try {
period = await fetchRankPeriod(s.mode, date, s.pagesPerPeriod, signal);
} catch (e) {
if (e.name === 'AbortError') break;
if (e instanceof AuthError) {
alert(`⚠️ ${e.message}\nR-18 榜单还需要在账号设置里开启成人内容显示。`);
break;
}
if (e instanceof NoDataError) {
// 该日期没有榜单:往前挪一格继续试,连续失败太多就收手
if (retryQueue) { markFailed(date, e.message); continue; }
const next = date ? stepBackDate(s.mode, date) : null;
if (!next) { s.noMore = true; markFailed(date, e.message); break; }
s.cursorDate = next;
if (++s.emptyStreak > 5) { s.noMore = true; markFailed(date, `已连续 ${s.emptyStreak} 期无数据(${e.message}),停止回溯`); break; }
continue;
}
markFailed(date, e.message || '未知错误');
if (retryQueue) continue;
// 网络错误拿不到下一期日期,用兜底步进继续
const next = date ? stepBackDate(s.mode, date) : null;
if (!next) break;
s.cursorDate = next;
continue;
}
}
s.emptyStreak = 0;
// 3) 合并 + 写缓存
if (!fetchedDates.has(period.date)) {
mergePeriodIntoSession(s, period.date, period.items);
fetchedDates.add(period.date);
s.dates.push(period.date);
s.dates.sort((a, b) => String(b).localeCompare(String(a)));
if (period.rangeLabel) s.periodLabels[period.date] = period.rangeLabel;
}
if (!period.fromCache) {
await writeCache(`${CONFIG.CACHE_PREFIX}rank:${s.mode}:${period.date}`, () => ({
mode: s.mode, date: period.date, rangeLabel: period.rangeLabel || '', prevDate: period.prevDate,
rankTotal: period.rankTotal, maxPage: period.maxPage, items: period.items,
}));
}
if (period.pageErrors && period.pageErrors.length) {
markFailed(period.date, `第 ${period.pageErrors.join('、')} 页请求失败,该期数据可能不完整`);
}
// 4) 下一期:新接口没有 prev_date,从"接口返回的真实日期"往前推一格。
// 周榜/月榜传任意日期都会落到所在那一期,所以必须以真实日期为基准,否则会原地打转。
if (!retryQueue) {
let next = stepBackDate(s.mode, period.date || date);
if (next && fetchedDates.has(next)) next = stepBackDate(s.mode, next);
if (!next) { s.noMore = true; break; }
s.cursorDate = next;
}
}
s.failed = [...new Set(newFailed.filter(Boolean))];
s.failReasons = { ...s.failReasons, ...newFailReasons };
s.stoppedEarly = stopRequested;
if (!document.getElementById(BACKDROP_ID)) return;
renderResult();
} catch (e) {
console.error('[Pixiv优化] 排行抓取异常:', e);
} finally {
isFetching = false;
stopRequested = false;
abortController = null;
}
};
// ==================== 收藏数补全 ====================
const promptEnrich = (missing) => {
const input = prompt(`补全多少条的收藏数?(每条 1 次请求,缺 ${missing} 条)\n建议先按当前排序取前 100 条。`, String(Math.min(100, missing)));
if (input == null) return;
const n = Math.max(1, Math.min(missing, parseInt(input, 10) || 0));
if (!n) return;
runEnrich(n);
};
const runEnrich = async (count) => {
if (isFetching || !session) return;
setDelayFloor(CONFIG.RANK_MIN_DELAY);
isFetching = true;
stopRequested = false;
abortController = new AbortController();
const signal = abortController.signal;
try {
// 按当前显示顺序取,前 count 个缺收藏数的
const targets = updateDisplayData().filter(i => i.bookmarkCount == null).slice(0, count);
renderProgressSkeleton('❤️ 补全收藏数');
const startTime = Date.now();
let done = 0;
await runPool(targets, CONFIG.ENRICH_CONCURRENCY, async (item) => {
try {
const json = await fetchJson(buildNovelDetailUrl(item.id), signal);
const b = json.body || {};
const bm = num(pick(b.bookmarkCount, b.bookmark_count));
if (bm != null) item.bookmarkCount = bm;
const tc = num(pick(b.textCount, b.characterCount, b.wordCount));
if (tc != null) item.textCount = tc;
const vc = num(pick(b.viewCount, b.view_count));
if (vc != null) item.viewCount = vc;
if (b.createDate) item.createDate = b.createDate;
const tags = b.tags && Array.isArray(b.tags.tags) ? b.tags.tags.map(t => t.tag).filter(Boolean) : null;
if (tags && tags.length) item.tags = tags;
if (session.enriched) session.enriched.add(item.id);
// 最新一次快照补上真实收藏数,便于导出时对照
if (item.snapshots && item.snapshots.length && bm != null) {
item.snapshots[item.snapshots.length - 1].bookmarkCount = bm;
}
} catch (e) {
if (e.name === 'AbortError') return;
// 单条失败忽略,不打断整体
}
done++;
updateProgress(done, targets.length, startTime, '条');
}, signal);
session.stoppedEarly = false;
if (!document.getElementById(BACKDROP_ID)) return;
renderResult();
} catch (e) {
console.error('[Pixiv优化] 补全收藏数异常:', e);
} finally {
isFetching = false;
stopRequested = false;
abortController = null;
}
};
// ==================== 入口界面 ====================
const renderHome = () => {
const body = ensurePanel();
const styles = panelThemeStyles;
setPanelTitle('Pixiv 小说工具');
const keyword = extractKeyword();
body.innerHTML = `
选择要做什么
🔍 关键词 / 标签搜索
${keyword ? `当前关键词:「${escapeHtml(keyword)}」,按收藏数重排` : '当前页面没有搜索关键词,请到小说搜索结果页使用'}
🏆 排行榜区间抓取
连续抓取多期日榜/周榜,跨期聚合,统计上榜次数与最好名次
🚫 关键词屏蔽
${blocklist.length > 0 ? `已屏蔽 ${blocklist.length} 个关键词` : '还没有屏蔽词'}
`;
if (keyword) {
document.getElementById('px-home-search').addEventListener('click', () => renderSearchSetup(keyword));
}
document.getElementById('px-home-rank').addEventListener('click', () => renderRankSetup());
document.getElementById('px-home-block').addEventListener('click', () => renderBlocklistManager());
};
const renderBlocklistManager = () => {
const body = ensurePanel();
const styles = panelThemeStyles;
setPanelTitle('🚫 关键词屏蔽');
body.innerHTML = ``;
const renderChips = () => {
const wrap = document.getElementById('px-block-list');
const countEl = document.getElementById('px-block-count');
if (!wrap || !countEl) return;
countEl.textContent = `已屏蔽的关键词(${blocklist.length})`;
if (blocklist.length === 0) {
wrap.style.border = 'none';
wrap.innerHTML = `还没有屏蔽词,在上面输入后点添加
`;
return;
}
wrap.style.border = `1px solid ${styles.inputBorder}`;
wrap.innerHTML = blocklist.map((term, idx) => `
${escapeHtml(term)}
`).join('');
};
renderChips();
const input = document.getElementById('px-block-input');
const doAdd = () => {
if (addBlockTerm(input.value)) {
input.value = '';
renderChips();
} else if (input.value.trim()) {
input.value = '';
}
input.focus();
};
document.getElementById('px-block-add').addEventListener('click', doAdd);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') doAdd(); });
document.getElementById('px-block-list').addEventListener('click', (e) => {
const btn = e.target.closest('.px-block-remove');
if (!btn) return;
removeBlockTerm(btn.getAttribute('data-term'));
renderChips();
});
document.getElementById('px-back-btn').addEventListener('click', renderHome);
};
const renderSearchSetup = async (keyword) => {
const body = ensurePanel();
const styles = panelThemeStyles;
setPanelTitle('🔍 关键词搜索');
const sMode = normalizeSMode(getQueryParam('s_mode'));
const mode = getQueryParam('mode') || 'all';
const cacheKey = `${CONFIG.CACHE_PREFIX}${mode}:${sMode}:${keyword}`;
const cached = await loadSearchCache(cacheKey);
const knownMaxPage = cached && Number.isFinite(cached.maxPage) ? cached.maxPage : null;
const cacheHint = cached ? `
💾 缓存有效 · 已抓 ${cached.fetchedPages.length} 页 (${cached.items.length} 条)${knownMaxPage != null ? ` · 共 ${knownMaxPage} 页` : ''} · ${Math.max(0, Math.round((Date.now() - cached.timestamp) / 60000))} 分钟前
` : '';
body.innerHTML = `
关键词 「${escapeHtml(keyword)}」
${cacheHint}
`;
const start = (pageCount) => {
const limit = knownMaxPage != null ? Math.min(pageCount, knownMaxPage) : pageCount;
runSearch(keyword, Array.from({ length: Math.max(1, limit) }, (_, i) => i + 1));
};
[10, 20, 50, 100, 200].forEach(p => {
const b = document.createElement('button');
b.textContent = `${p} 页`;
b.style.cssText = `padding:6px 18px;border:1px solid ${styles.btnPrimary};border-radius:6px;background:transparent;color:${styles.btnPrimary};font-size:13px;cursor:pointer;`;
b.addEventListener('click', () => start(p));
document.getElementById('px-preset-wrap').appendChild(b);
});
document.getElementById('px-custom-btn').addEventListener('click', () => {
const v = Math.max(1, Math.min(200, parseInt(document.getElementById('px-custom-input').value, 10) || 0));
if (!v) { alert('请输入 1~200 之间的页数'); return; }
start(v);
});
document.getElementById('px-back-btn').addEventListener('click', renderHome);
document.getElementById('px-clear-cache-btn')?.addEventListener('click', () => {
if (confirm('清除该关键词的缓存?')) { localStorage.removeItem(cacheKey); renderSearchSetup(keyword); }
});
};
const renderRankSetup = () => {
const body = ensurePanel();
const styles = panelThemeStyles;
setPanelTitle('🏆 排行榜区间抓取');
const urlMode = getQueryParam('mode');
const defaultMode = RANK_MODES.some(m => m.value === urlMode) ? urlMode : 'daily';
body.innerHTML = ``;
const modeEl = document.getElementById('px-rank-mode');
const periodsEl = document.getElementById('px-rank-periods');
const pagesEl = document.getElementById('px-rank-pages');
const refreshEstimate = () => {
const periods = Math.max(1, Math.min(CONFIG.RANK_MAX_PERIODS, parseInt(periodsEl.value, 10) || 1));
const pages = Math.max(1, Math.min(CONFIG.RANK_MAX_PAGES_PER_PERIOD, parseInt(pagesEl.value, 10) || 1));
const reqs = periods * pages;
const secs = Math.round(reqs * (CONFIG.RANK_MIN_DELAY + 400) / 1000);
document.getElementById('px-rank-estimate').textContent =
`⚠️ 约 ${reqs} 次请求,预计 ${formatTime(secs)},最多 ${reqs * 50} 条(去重前)`;
document.getElementById('px-rank-cache').textContent =
`💾 该榜已缓存 ${countRankCache(modeEl.value)} 期(历史期永不过期,会直接复用不再请求)`;
};
[modeEl, periodsEl, pagesEl].forEach(el => el.addEventListener('input', refreshEstimate));
modeEl.addEventListener('change', refreshEstimate);
refreshEstimate();
document.getElementById('px-rank-start').addEventListener('click', () => {
const periods = Math.max(1, Math.min(CONFIG.RANK_MAX_PERIODS, parseInt(periodsEl.value, 10) || 1));
const pages = Math.max(1, Math.min(CONFIG.RANK_MAX_PAGES_PER_PERIOD, parseInt(pagesEl.value, 10) || 1));
const dateVal = document.getElementById('px-rank-date').value;
const startDate = dateVal ? dateVal.replace(/-/g, '') : null;
runRank({ mode: modeEl.value, periods, pagesPerPeriod: pages, startDate });
});
document.getElementById('px-rank-clear').addEventListener('click', () => {
if (confirm(`清除「${rankModeLabel(modeEl.value)}」的全部缓存?`)) { clearRankCache(modeEl.value); refreshEstimate(); }
});
document.getElementById('px-back-btn').addEventListener('click', renderHome);
};
const onButtonClick = () => {
if (isFetching) return;
renderHome(); // 不再按页面类型自动跳转,永远弹三个并列选项,自己选
};
// 启动
injectButton();
startObserver();
startNativeBlocking(); // 独立于面板,平时正常刷页面就生效
renderTagQuickBlockButton(); // 如果一进来就是标签页,直接显示快速屏蔽按钮
})();