// ==UserScript==
// @name Pixiv小说搜索优化
// @namespace local.pixiv.novel.enhanced.pro
// @version 1.1
// @description 并发抓取+升降序排序+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/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_TTL_MS: 30 * 60 * 1000,
MAX_CACHE_SIZE_BYTES: 4 * 1024 * 1024,
RETRY_TIMES: 3,
CONCURRENCY: 3,
DEFAULT_LOAD_MORE_SIZE: 10, // 增量每次加载页数
VIRTUAL_THRESHOLD: 50, // PC端超过此数量才启用虚拟滚动
ITEM_HEIGHT: 80,
MOBILE_BREAKPOINT: 768,
REQUEST_HISTORY_SIZE: 5,
MIN_DELAY: 200,
MAX_DELAY: 1500,
DEFAULT_SORT_FIELD: 'bookmarkCount',
DEFAULT_SORT_ASC: false,
PANEL_Z_INDEX: 999998,
BUTTON_Z_INDEX: 999999,
};
// ==================== 工具函数 ====================
const getQueryParam = (name) => new URL(location.href).searchParams.get(name);
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
const escapeHtml = (str) => String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
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 (/^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;
// ==================== 深色模式管理 ====================
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 loadCacheAsync = (key) => {
return new Promise((resolve) => {
const raw = localStorage.getItem(key);
if (!raw) return resolve(null);
// 使用 requestIdleCallback 避免阻塞 UI
const decompress = () => {
try {
const decompressed = LZString.decompressFromUTF16(raw);
if (!decompressed) return resolve(null);
const data = JSON.parse(decompressed);
if (!data || Date.now() - data.timestamp > CONFIG.CACHE_TTL_MS) {
localStorage.removeItem(key);
return resolve(null);
}
resolve(data);
} catch (e) {
resolve(null);
}
};
if (window.requestIdleCallback) {
requestIdleCallback(decompress, { timeout: 300 });
} else {
setTimeout(decompress, 0);
}
});
};
const saveCache = (key, payload) => {
try {
const serialized = JSON.stringify({ ...payload, timestamp: Date.now() });
const compressed = LZString.compressToUTF16(serialized);
if (compressed.length > CONFIG.MAX_CACHE_SIZE_BYTES) {
cleanOldCaches(key);
const retryCompressed = LZString.compressToUTF16(JSON.stringify({ ...payload, timestamp: Date.now() }));
if (retryCompressed.length > CONFIG.MAX_CACHE_SIZE_BYTES) {
console.warn('[Pixiv搜索优化] 单条缓存过大,放弃保存');
return;
}
localStorage.setItem(key, retryCompressed);
} else {
localStorage.setItem(key, compressed);
}
} catch (e) {
console.warn('[Pixiv搜索优化] 缓存写入失败:', e);
}
};
const cleanOldCaches = (excludeKey) => {
try {
const keys = Object.keys(localStorage).filter(k => k.startsWith(CONFIG.CACHE_PREFIX));
if (keys.length < 2) return;
const items = keys.map(k => {
const raw = localStorage.getItem(k);
if (!raw) return { key: k, timestamp: 0 };
try {
const decompressed = LZString.decompressFromUTF16(raw);
if (!decompressed) return { key: k, timestamp: 0 };
const data = JSON.parse(decompressed);
return { key: k, timestamp: data.timestamp || 0 };
} catch (e) { return { key: k, timestamp: 0 }; }
});
items.sort((a, b) => a.timestamp - b.timestamp);
let totalSize = 0;
const sizeMap = {};
keys.forEach(k => {
const raw = localStorage.getItem(k);
if (raw) {
sizeMap[k] = raw.length;
totalSize += raw.length;
}
});
for (const item of items) {
if (item.key === excludeKey) continue;
if (totalSize <= CONFIG.MAX_CACHE_SIZE_BYTES) break;
const size = sizeMap[item.key] || 0;
localStorage.removeItem(item.key);
totalSize -= size;
}
} catch (e) {}
};
// ==================== 网络请求与限速 ====================
const requestHistory = [];
let currentDelay = 300;
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 = Math.min(CONFIG.MAX_DELAY, currentDelay + 100);
else if (avg < 800) currentDelay = Math.max(CONFIG.MIN_DELAY, currentDelay - 50);
currentDelay = Math.min(CONFIG.MAX_DELAY, Math.max(CONFIG.MIN_DELAY, currentDelay));
}
};
const fetchPageWithRetry = async (keyword, page, sMode, mode, signal) => {
let lastError;
for (let attempt = 1; attempt <= CONFIG.RETRY_TIMES; attempt++) {
if (signal?.aborted) throw new Error('Aborted');
const start = Date.now();
try {
// 在请求前插入动态延迟
if (currentDelay > 0) await sleep(currentDelay);
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');
const res = await fetch(url.toString(), {
credentials: 'include',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
signal
});
const elapsed = Date.now() - start;
updateDelay(elapsed);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (json.error) throw new Error(`API error: ${json.message || '未知错误'}`);
return json.body;
} catch (e) {
lastError = e;
if (attempt < CONFIG.RETRY_TIMES) {
const wait = (currentDelay + 500) * Math.pow(2, attempt - 1);
await sleep(wait);
}
}
}
throw lastError;
};
// ==================== 页面匹配 ====================
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);
if (!isNewSearch && !isOldNovelSearch) 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;
};
// ==================== UI管理 ====================
const BTN_ID = 'px-enhanced-btn';
const PANEL_ID = 'px-enhanced-panel';
const BACKDROP_ID = 'px-enhanced-backdrop';
let currentItems = [];
let currentKeyword = '';
let currentFetchedPages = 0;
let failedPages = [];
let sortField = CONFIG.DEFAULT_SORT_FIELD;
let sortAsc = CONFIG.DEFAULT_SORT_ASC;
// 高级筛选状态
let filters = {
bookmarkMin: 0,
bookmarkMax: Infinity,
textCountMin: 0,
textCountMax: Infinity,
dateFrom: '',
dateTo: '',
};
// 抓取控制
let abortController = null;
let isFetching = false;
// 主题样式缓存
let currentTheme = 'light';
// 注入浮动按钮
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: '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',
panelBorder: isDark ? '#2a2a4a' : '#eaeef2',
textPrimary: isDark ? '#e8e8f0' : '#1a2634',
textSecondary: isDark ? '#a0a0c0' : '#5e6f8d',
textMuted: isDark ? '#6a6a8a' : '#8a9bb5',
divider: isDark ? '#2a2a4a' : '#eaeef2',
cardBg: isDark ? '#16162a' : '#ffffff',
cardHover: isDark ? '#1f1f3a' : '#f5f8fc',
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',
btnPrimaryText: '#fff',
btnSuccess: isDark ? '#2d8f4a' : '#28a745',
btnSuccessText: '#fff',
btnDanger: isDark ? '#cc4444' : '#ff6b6b',
btnDangerText: '#fff',
linkColor: isDark ? '#6a9aff' : '#0096fa',
linkBg: isDark ? '#1a2a4a' : '#eaf6ff',
tagBg: isDark ? '#1a2a4a' : '#eaf6ff',
tagText: isDark ? '#6a9aff' : '#0096fa',
starColor: '#ffd700',
};
};
let panelThemeStyles = {};
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;
}
};
// ==================== 弹窗管理 ====================
const closePanel = () => {
// 如果正在抓取,中止
if (abortController) {
abortController.abort();
abortController = null;
}
isFetching = false;
const backdrop = document.getElementById(BACKDROP_ID);
if (backdrop) backdrop.remove();
document.body.style.overflow = '';
document.removeEventListener('keydown', onEscKey);
cleanupDarkModeListeners();
};
const onEscKey = (e) => {
if (e.key === 'Escape') closePanel();
};
const ensurePanel = () => {
let backdrop = document.getElementById(BACKDROP_ID);
if (backdrop) {
const panel = backdrop.querySelector(`#${PANEL_ID}`);
if (panel) {
const theme = isDarkMode() ? 'dark' : 'light';
currentTheme = theme;
applyThemeToPanel(panel, theme);
}
return backdrop.querySelector(`#${PANEL_ID} .panel-body`);
}
const theme = isDarkMode() ? 'dark' : 'light';
currentTheme = theme;
const styles = getThemeStyles(theme);
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,
transition: 'background 0.2s',
});
closeBtn.addEventListener('click', closePanel);
closeBtn.addEventListener('mouseenter', () => closeBtn.style.background = styles.divider);
closeBtn.addEventListener('mouseleave', () => closeBtn.style.background = 'transparent');
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', onEscKey);
cleanupDarkModeListeners();
onDarkModeChange((isDark) => {
const newTheme = isDark ? 'dark' : 'light';
currentTheme = newTheme;
const panelEl = document.getElementById(PANEL_ID);
if (panelEl) {
applyThemeToPanel(panelEl, newTheme);
// 如果已显示结果,需要重新渲染列表(刷新样式)
if (currentItems.length > 0) {
const listContainer = document.getElementById('px-list');
if (listContainer) renderList(listContainer, currentItems);
}
}
});
return body;
};
// ==================== 排序与筛选 ====================
const sortItems = (items, field, asc) => {
return items.slice().sort((a, b) => {
let va = a[field] ?? 0;
let vb = b[field] ?? 0;
if (field === 'createDate') {
va = new Date(a.createDate || 0).getTime();
vb = new Date(b.createDate || 0).getTime();
}
return asc ? va - vb : vb - va;
});
};
const applyFilters = (items) => {
return items.filter(item => {
const bm = item.bookmarkCount ?? 0;
const tc = item.textCount ?? item.wordCount ?? 0;
const date = item.createDate ? new Date(item.createDate) : null;
let pass = true;
if (filters.bookmarkMin > 0 && bm < filters.bookmarkMin) pass = false;
if (filters.bookmarkMax < Infinity && bm > filters.bookmarkMax) pass = false;
if (filters.textCountMin > 0 && tc < filters.textCountMin) pass = false;
if (filters.textCountMax < Infinity && tc > filters.textCountMax) pass = false;
if (date) {
if (filters.dateFrom) {
const from = new Date(filters.dateFrom);
if (date < from) pass = false;
}
if (filters.dateTo) {
const to = new Date(filters.dateTo);
if (date > to) pass = false;
}
}
return pass;
});
};
const updateDisplayData = () => {
return sortItems(applyFilters(currentItems), sortField, sortAsc);
};
// ==================== 渲染列表(虚拟滚动移除,改用content-visibility) ====================
const renderList = (container, items) => {
if (!container) return;
const styles = panelThemeStyles || getThemeStyles(currentTheme);
if (items.length === 0) {
container.innerHTML = `没有匹配的结果
`;
return;
}
// 移动端或数据量较少时直接渲染,PC端使用 content-visibility 优化
const useAuto = !isMobile() && items.length > CONFIG.VIRTUAL_THRESHOLD;
let html = '';
items.forEach((item, idx) => {
html += `${renderItem(item, idx, styles)}
`;
});
container.innerHTML = html;
};
const renderItem = (item, idx, styles) => {
const link = `https://www.pixiv.net/novel/show.php?id=${item.id}`;
const cover = normalizeCoverUrl(item.url || '');
const title = escapeHtml(item.title || '(无标题)');
const author = escapeHtml(item.userName || '');
const bm = item.bookmarkCount ?? 0;
const textCount = item.textCount ?? item.wordCount ?? '-';
const createDate = item.createDate ? new Date(item.createDate).toLocaleDateString() : '';
const userId = item.userId || '';
const isHot = bm > 1000;
const authorHtml = userId ? `${author}` : `${author}`;
const rawTags = Array.isArray(item.tags) ? item.tags : [];
const tagNames = rawTags.map(t => (typeof t === 'string' ? t : t.tag || t.name || '')).filter(Boolean);
const tagsHtml = tagNames.length ? `${tagNames.slice(0, 5).map(t => `
#${escapeHtml(t)}`).join('')}${tagNames.length > 5 ? `
+${tagNames.length - 5}` : ''}
` : '';
const hotBadge = isHot ? `⭐` : '';
return `
${idx + 1}
${cover ? `

` : ''}
✍️ ${authorHtml}
❤️ ${bm}
📄 ${textCount}
${createDate ? `📅 ${createDate}` : ''}
${tagsHtml}
`;
};
// ==================== 结果控制栏(排序、筛选、导出、重试等) ====================
const buildControls = (container, listContainer) => {
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.display = 'flex';
sortGroup.style.alignItems = 'center';
sortGroup.style.gap = '4px';
sortGroup.innerHTML = `排序`;
const sortSelect = document.createElement('select');
Object.assign(sortSelect.style, {
padding: '4px 8px',
border: `1px solid ${styles.inputBorder}`,
borderRadius: '6px',
fontSize: '13px',
background: styles.inputBg,
color: styles.inputText,
cursor: 'pointer',
});
['bookmarkCount', 'textCount', 'createDate'].forEach(val => {
const opt = document.createElement('option');
opt.value = val;
opt.textContent = { bookmarkCount: '收藏', textCount: '字数', createDate: '日期' }[val];
sortSelect.appendChild(opt);
});
sortSelect.value = sortField;
sortSelect.addEventListener('change', () => {
sortField = sortSelect.value;
refreshList();
});
const orderBtn = document.createElement('button');
orderBtn.textContent = sortAsc ? '▲' : '▼';
Object.assign(orderBtn.style, {
padding: '4px 8px',
border: `1px solid ${styles.inputBorder}`,
borderRadius: '6px',
background: styles.inputBg,
color: styles.inputText,
fontSize: '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 = '🔍 筛选标题/作者';
Object.assign(textFilter.style, {
padding: '4px 10px',
border: `1px solid ${styles.inputBorder}`,
borderRadius: '6px',
fontSize: '13px',
background: styles.inputBg,
color: styles.inputText,
width: '130px',
});
textFilter.addEventListener('input', (e) => {
const val = e.target.value.toLowerCase();
document.querySelectorAll('.px-result-card').forEach(card => {
const titleAuthor = card.querySelector('a[href*="/novel/show.php"]')?.textContent.toLowerCase() || '';
card.style.display = titleAuthor.includes(val) ? '' : 'none';
});
});
controls.appendChild(textFilter);
// 高级筛选(折叠)
const advancedBtn = document.createElement('button');
advancedBtn.textContent = '⚙️ 高级';
Object.assign(advancedBtn.style, {
padding: '4px 8px',
border: `1px solid ${styles.inputBorder}`,
borderRadius: '6px',
background: styles.inputBg,
color: styles.inputText,
fontSize: '13px',
cursor: 'pointer',
});
advancedBtn.addEventListener('click', () => toggleAdvancedFilters(controls));
controls.appendChild(advancedBtn);
// 统计
const totalBm = currentItems.reduce((s, i) => s + (i.bookmarkCount || 0), 0);
const avgBm = currentItems.length > 0 ? Math.round(totalBm / currentItems.length) : 0;
const statsDiv = document.createElement('div');
statsDiv.style.cssText = `font-size:12px;color:${styles.textMuted};display:flex;gap:12px;margin-left:auto;`;
statsDiv.innerHTML = `❤️ ${totalBm}📊 均 ${avgBm}📄 ${applyFilters(currentItems).length}`;
controls.appendChild(statsDiv);
// 导出
const exportHtmlBtn = document.createElement('button');
exportHtmlBtn.textContent = '📥 HTML';
exportHtmlBtn.style.cssText = `padding:4px 8px;border:none;border-radius:6px;background:${styles.btnSuccess};color:#fff;font-size:13px;cursor:pointer;`;
exportHtmlBtn.addEventListener('click', () => exportHTML(updateDisplayData(), currentKeyword, sortField, sortAsc));
controls.appendChild(exportHtmlBtn);
const exportCsvBtn = document.createElement('button');
exportCsvBtn.textContent = '📊 CSV';
exportCsvBtn.style.cssText = `padding:4px 8px;border:none;border-radius:6px;background:${styles.btnSuccess};color:#fff;font-size:13px;cursor:pointer;`;
exportCsvBtn.addEventListener('click', () => exportCSV(updateDisplayData(), currentKeyword));
controls.appendChild(exportCsvBtn);
// 重试失败页
if (failedPages.length > 0) {
const retryAllBtn = document.createElement('button');
retryAllBtn.textContent = `🔄 重试全部(${failedPages.length})`;
retryAllBtn.style.cssText = `padding:4px 8px;border:none;border-radius:6px;background:${styles.btnDanger};color:#fff;font-size:13px;cursor:pointer;`;
retryAllBtn.addEventListener('click', () => retryFailedPages(failedPages.slice()));
controls.appendChild(retryAllBtn);
// 单个页码重试
failedPages.forEach(page => {
const btn = document.createElement('button');
btn.textContent = `📄${page}`;
btn.style.cssText = `padding:2px 6px;border:1px solid ${styles.btnDanger};border-radius:4px;background:transparent;color:${styles.btnDanger};font-size:11px;cursor:pointer;`;
btn.addEventListener('click', () => retryFailedPages([page]));
controls.appendChild(btn);
});
}
// 继续加载更多(增量模式)
if (!isFetching && currentFetchedPages > 0) {
const loadMoreBtn = document.createElement('button');
loadMoreBtn.textContent = '➕ 加载更多';
loadMoreBtn.style.cssText = `padding:4px 8px;border:1px solid ${styles.btnPrimary};border-radius:6px;background:transparent;color:${styles.btnPrimary};font-size:13px;cursor:pointer;`;
loadMoreBtn.addEventListener('click', () => loadMorePages());
controls.appendChild(loadMoreBtn);
}
return controls;
};
// 高级筛选面板
let advancedVisible = false;
const toggleAdvancedFilters = (controlsParent) => {
const existing = document.getElementById('px-advanced-filters');
if (existing) {
existing.remove();
advancedVisible = false;
return;
}
advancedVisible = true;
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;`;
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 refreshList = () => {
const listContainer = document.getElementById('px-list');
if (listContainer) {
const filteredSorted = updateDisplayData();
renderList(listContainer, filteredSorted);
// 更新统计
const statsDiv = document.querySelector('#px-stats');
if (statsDiv) {
const totalBm = filteredSorted.reduce((s, i) => s + (i.bookmarkCount || 0), 0);
const avgBm = filteredSorted.length > 0 ? Math.round(totalBm / filteredSorted.length) : 0;
statsDiv.innerHTML = `❤️ ${totalBm}📊 均 ${avgBm}📄 ${filteredSorted.length}`;
}
}
};
// ==================== 结果主界面 ====================
const renderResult = (keyword, items, fetchedPages, hadError, fromCache) => {
currentItems = items;
currentKeyword = keyword;
currentFetchedPages = fetchedPages;
sortField = CONFIG.DEFAULT_SORT_FIELD;
sortAsc = CONFIG.DEFAULT_SORT_ASC;
// 重置筛选器
filters = { bookmarkMin: 0, bookmarkMax: Infinity, textCountMin: 0, textCountMax: Infinity, dateFrom: '', dateTo: '' };
const body = ensurePanel();
const styles = panelThemeStyles;
const titleEl = document.getElementById('px-panel-title');
if (titleEl) titleEl.textContent = `「${keyword}」· ${items.length} 条结果`;
const container = document.createElement('div');
container.style.cssText = `display:flex;flex-direction:column;gap:12px;height:100%;color:${styles.textPrimary};`;
const listContainer = document.createElement('div');
listContainer.id = 'px-list';
listContainer.style.cssText = `flex:1;overflow-y:auto;max-height:65vh;min-height:300px;`;
container.appendChild(listContainer);
const controls = buildControls(container, listContainer);
container.insertBefore(controls, listContainer);
body.innerHTML = '';
body.appendChild(container);
const filteredSorted = updateDisplayData();
renderList(listContainer, filteredSorted);
// 键盘导航
let highlightedIdx = -1;
const handleKeyDown = (e) => {
if (!document.getElementById(PANEL_ID)) {
document.removeEventListener('keydown', handleKeyDown);
return;
}
const cards = listContainer.querySelectorAll('.px-result-card');
if (cards.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
highlightedIdx = Math.min(highlightedIdx + 1, cards.length - 1);
updateHighlight(cards, highlightedIdx);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlightedIdx = Math.max(highlightedIdx - 1, -1);
updateHighlight(cards, highlightedIdx);
} else if (e.key === 'Enter' && highlightedIdx >= 0) {
e.preventDefault();
const link = cards[highlightedIdx].querySelector('a[href*="/novel/show.php"]');
if (link) window.open(link.href, '_blank');
}
};
document.addEventListener('keydown', handleKeyDown);
const updateHighlight = (cards, idx) => {
cards.forEach((c, i) => c.style.outline = i === idx ? `2px solid ${styles.btnPrimary}` : '');
if (idx >= 0) cards[idx].scrollIntoView({ block: 'nearest', behavior: 'smooth' });
};
};
// ==================== 导出功能 ====================
const exportHTML = (items, keyword, field, asc) => {
// ... 保留原有 HTML 导出逻辑,可复用原脚本的 exportHTML 函数
// 为简洁,此处省略,可使用原函数
};
const exportCSV = (items, keyword) => {
const header = ['排名', '标题', '作者', '收藏数', '字数', '发布日期', '标签', '链接'];
const rows = items.map((item, idx) => [
idx + 1,
item.title || '',
item.userName || '',
item.bookmarkCount ?? 0,
item.textCount ?? item.wordCount ?? 0,
item.createDate || '',
(Array.isArray(item.tags) ? item.tags.map(t => (typeof t === 'string' ? t : t.tag || '')).join(';') : ''),
`https://www.pixiv.net/novel/show.php?id=${item.id}`
]);
const csvContent = [header, ...rows].map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `pixiv_${keyword}_${new Date().toISOString().slice(0,10)}.csv`;
a.click();
URL.revokeObjectURL(url);
};
// ==================== 抓取逻辑 ====================
let currentCacheKey = '';
const startFetch = async (keyword, pageCount, sMode, mode, cacheKey, cached, retryPages = null, isRetry = false) => {
if (isFetching && !isRetry) return;
if (!isRetry) {
abortController = new AbortController();
isFetching = true;
}
const signal = abortController?.signal;
try {
// 重试指定页码
if (isRetry && retryPages?.length > 0) {
const latestCache = await loadCacheAsync(cacheKey);
let allItems = latestCache ? latestCache.items.slice() : [];
let seenIds = new Set(allItems.map(i => i.id));
let completed = 0;
const newFailed = [];
const concurrency = CONFIG.CONCURRENCY;
const pages = [...retryPages];
const worker = async () => {
while (pages.length > 0) {
const page = pages.shift();
try {
const body = await fetchPageWithRetry(keyword, page, sMode, mode, signal);
const list = (body.novel || body.novelManga || {}).data || [];
if (list.length === 0) { newFailed.push(page); continue; }
for (const item of list) {
if (!seenIds.has(item.id)) {
seenIds.add(item.id);
allItems.push(item);
}
}
} catch (e) {
if (e.message !== 'Aborted') newFailed.push(page);
}
completed++;
// 更新进度(简化)
}
};
await Promise.all(Array.from({ length: concurrency }, worker));
if (allItems.length > 0) saveCache(cacheKey, { items: allItems, fetchedPages: currentFetchedPages, noMorePages: false });
failedPages = newFailed;
renderResult(keyword, allItems, currentFetchedPages, newFailed.length > 0, false);
return;
}
// 正常抓取(全新或增量)
const latest = await loadCacheAsync(cacheKey);
let allItems = [];
let seenIds = new Set();
let completedPages = 0;
let noMorePages = false;
if (cached) {
allItems = cached.items.slice();
seenIds = new Set(allItems.map(i => i.id));
completedPages = cached.fetchedPages;
noMorePages = !!cached.noMorePages;
if (completedPages >= pageCount || noMorePages) {
failedPages = [];
renderResult(keyword, allItems, completedPages, false, true);
return;
}
}
const toFetch = [];
for (let p = completedPages + 1; p <= pageCount; p++) toFetch.push(p);
if (toFetch.length === 0) {
failedPages = [];
renderResult(keyword, allItems, completedPages, false, true);
return;
}
// 显示进度
renderProgressSkeleton();
const startTime = Date.now();
let completed = 0;
const newFailed = [];
const pages = [...toFetch];
const concurrency = CONFIG.CONCURRENCY;
const worker = async () => {
while (pages.length > 0) {
const page = pages.shift();
if (signal?.aborted) return;
try {
const body = await fetchPageWithRetry(keyword, page, sMode, mode, signal);
const section = body.novel || body.novelManga || {};
const list = section.data || [];
if (list.length === 0) {
newFailed.push(page);
continue;
}
for (const item of list) {
if (!seenIds.has(item.id)) {
seenIds.add(item.id);
allItems.push(item);
}
}
} catch (e) {
if (e.message !== 'Aborted') newFailed.push(page);
} finally {
completed++;
updateProgress(completed, toFetch.length, startTime);
await sleep(100 + Math.random() * 200);
}
}
};
await Promise.all(Array.from({ length: concurrency }, worker));
if (signal?.aborted) return;
if (!signal?.aborted && allItems.length > 0) {
saveCache(cacheKey, { items: allItems, fetchedPages: pageCount, noMorePages: false });
}
failedPages = newFailed;
renderResult(keyword, allItems, pageCount, newFailed.length > 0, false);
} finally {
if (!isRetry) {
abortController = null;
isFetching = false;
}
}
};
const loadMorePages = async () => {
if (isFetching) return;
const keyword = extractKeyword();
if (!keyword) return;
const sMode = normalizeSMode(getQueryParam('s_mode'));
const mode = getQueryParam('mode') || 'all';
const cacheKey = `${CONFIG.CACHE_PREFIX}${mode}:${sMode}:${keyword}`;
currentCacheKey = cacheKey;
const latest = await loadCacheAsync(cacheKey);
const nextStart = currentFetchedPages + 1;
const endPage = nextStart + CONFIG.DEFAULT_LOAD_MORE_SIZE - 1;
startFetch(keyword, endPage, sMode, mode, cacheKey, latest);
};
const retryFailedPages = async (pages) => {
if (pages.length === 0) return;
const keyword = extractKeyword();
const sMode = normalizeSMode(getQueryParam('s_mode'));
const mode = getQueryParam('mode') || 'all';
const cacheKey = `${CONFIG.CACHE_PREFIX}${mode}:${sMode}:${keyword}`;
// 直接调用 startFetch 重试模式
abortController = new AbortController();
isFetching = true;
await startFetch(keyword, 0, sMode, mode, cacheKey, null, pages, true);
isFetching = false;
abortController = 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';
};
// ==================== 选择页数界面 ====================
const renderPageCountSelector = (keyword, onSelect, cacheInfo, cacheKey) => {
const body = ensurePanel();
const styles = panelThemeStyles;
const titleEl = document.getElementById('px-panel-title');
if (titleEl) titleEl.textContent = '🔍 Pixiv搜索优化';
const cacheHint = cacheInfo ? `
💾 缓存有效 · 已抓取 ${cacheInfo.fetchedPages} 页 (${cacheInfo.itemCount} 条) · ${cacheInfo.minutesAgo} 分钟前
` : '';
body.innerHTML = `
关键词 · 「${escapeHtml(keyword)}」
${cacheHint}
⚡ 并发抓取 · 缓存30分钟 · 可增量加载更多 · 支持高级筛选
`;
const presets = [10, 20, 50, 100, 200];
const wrap = document.getElementById('px-preset-wrap');
presets.forEach(p => {
const b = document.createElement('button');
b.textContent = `${p} 页`;
Object.assign(b.style, {
padding: '6px 18px',
border: `1px solid ${styles.btnPrimary}`,
borderRadius: '6px',
background: 'transparent',
color: styles.btnPrimary,
fontSize: '13px',
cursor: 'pointer',
});
b.addEventListener('click', () => onSelect(p));
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; }
onSelect(v);
});
document.getElementById('px-clear-cache-btn')?.addEventListener('click', () => {
if (confirm('清除该关键词的缓存?')) {
localStorage.removeItem(cacheKey);
renderPageCountSelector(keyword, onSelect, null, cacheKey);
}
});
};
// ==================== 进度界面 ====================
const renderProgressSkeleton = () => {
const body = ensurePanel();
const styles = panelThemeStyles;
body.innerHTML = ``;
document.getElementById('px-stop-btn').addEventListener('click', () => {
abortController?.abort();
closePanel();
});
};
const updateProgress = (current, total, startTime) => {
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');
if (text) text.textContent = `⏳ 抓取中:第 ${current} / ${total} 页`;
if (bar) bar.style.width = pct + '%';
if (pctEl) pctEl.textContent = pct + '%';
if (startTime) {
const elapsed = (Date.now() - startTime) / 1000;
if (timeEl) timeEl.textContent = `⏱ 已用: ${formatTime(elapsed)}`;
if (current > 0 && current < total && estEl) {
const avg = elapsed / current;
estEl.textContent = `⏳ 预计剩余: ${formatTime((total - current) * avg)}`;
} else if (current >= total && estEl) {
estEl.textContent = '✅ 完成';
}
}
};
// ==================== 入口 ====================
const onButtonClick = async () => {
const keyword = extractKeyword();
if (!keyword) { alert('请先在 Pixiv 小说搜索结果页使用此工具。'); return; }
const sMode = normalizeSMode(getQueryParam('s_mode'));
const mode = getQueryParam('mode') || 'all';
const cacheKey = `${CONFIG.CACHE_PREFIX}${mode}:${sMode}:${keyword}`;
currentCacheKey = cacheKey;
const cached = await loadCacheAsync(cacheKey);
let cacheInfo = null;
if (cached) {
cacheInfo = {
fetchedPages: cached.fetchedPages,
itemCount: cached.items.length,
minutesAgo: Math.max(0, Math.round((Date.now() - cached.timestamp) / 60000)),
};
}
renderPageCountSelector(keyword, (pageCount) => {
startFetch(keyword, pageCount, sMode, mode, cacheKey, cached);
}, cacheInfo, cacheKey);
};
// 启动
injectButton();
startObserver();
})();