// ==UserScript==
// @name Pixiv搜索优化
// @namespace local.pixiv.novel.enhanced
// @version 1.0
// @description 并发抓取 + 升降序排序 + HTML导出(含封面/热门标记)+ 作者主页链接 + 深色模式适配 + 智能限速 + 缓存压缩 + 虚拟滚动 + 实时筛选 + 数据统计 + 优化UI
// @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';
// ────────────────────── 工具函数 ──────────────────────
function getQueryParam(name) {
return new URL(location.href).searchParams.get(name);
}
function 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;
}
function 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';
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
}
function 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`;
}
// ─── 封面 URL 规范化 ───
function 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;
}
// ────────────────────── 深色模式检测 ──────────────────────
function isDarkMode() {
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
}
let darkModeListeners = [];
function 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 });
}
function cleanupDarkModeListeners() {
darkModeListeners.forEach(({ mql, handler }) => {
mql.removeEventListener('change', handler);
});
darkModeListeners = [];
}
// ────────────────────── 缓存管理(压缩) ──────────────────────
const CACHE_PREFIX = 'pxNovelEnhanced:';
const CACHE_TTL_MS = 30 * 60 * 1000;
const MAX_CACHE_SIZE_BYTES = 4 * 1024 * 1024;
function getCacheKey(keyword, sMode, mode) {
return `${CACHE_PREFIX}${mode}:${sMode}:${keyword}`;
}
function loadCache(key) {
try {
const raw = localStorage.getItem(key);
if (!raw) return null;
const decompressed = LZString.decompressFromUTF16(raw);
if (!decompressed) return null;
const data = JSON.parse(decompressed);
if (!data || Date.now() - data.timestamp > CACHE_TTL_MS) {
localStorage.removeItem(key);
return null;
}
return data;
} catch (e) {
return null;
}
}
function saveCache(key, payload) {
try {
const serialized = JSON.stringify({ ...payload, timestamp: Date.now() });
const compressed = LZString.compressToUTF16(serialized);
if (compressed.length > MAX_CACHE_SIZE_BYTES) {
cleanOldCaches(key);
const retryCompressed = LZString.compressToUTF16(JSON.stringify({ ...payload, timestamp: Date.now() }));
if (retryCompressed.length > MAX_CACHE_SIZE_BYTES) {
console.warn('[Pixiv搜索优化] 单条缓存过大,放弃保存');
return;
}
localStorage.setItem(key, retryCompressed);
} else {
localStorage.setItem(key, compressed);
}
} catch (e) {
console.warn('[Pixiv搜索优化] 缓存写入失败:', e);
}
}
function cleanOldCaches(excludeKey) {
try {
const keys = Object.keys(localStorage).filter(k => k.startsWith(CACHE_PREFIX));
if (keys.length < 2) return;
const items = keys.map(k => {
try {
const raw = localStorage.getItem(k);
if (!raw) return { key: k, timestamp: 0 };
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 = {};
for (const k of keys) {
const raw = localStorage.getItem(k);
if (raw) sizeMap[k] = raw.length;
totalSize += raw ? raw.length : 0;
}
for (const item of items) {
if (item.key === excludeKey) continue;
if (totalSize <= MAX_CACHE_SIZE_BYTES) break;
const size = sizeMap[item.key] || 0;
localStorage.removeItem(item.key);
totalSize -= size;
}
} catch (e) {}
}
// ────────────────────── 抓取(智能限速 + 并发) ──────────────────────
const REQUEST_HISTORY = [];
let currentDelay = 300;
async function fetchPageWithRetry(keyword, page, sMode, mode, retries = 3) {
let lastError;
for (let attempt = 1; attempt <= retries; attempt++) {
const start = Date.now();
try {
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' },
});
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 < retries) {
const wait = (currentDelay + 500) * Math.pow(2, attempt - 1);
await sleep(wait);
}
}
}
throw lastError;
}
function updateDelay(elapsed) {
REQUEST_HISTORY.push(elapsed);
if (REQUEST_HISTORY.length > 5) REQUEST_HISTORY.shift();
if (REQUEST_HISTORY.length >= 3) {
const avg = REQUEST_HISTORY.reduce((a, b) => a + b, 0) / REQUEST_HISTORY.length;
if (avg > 2000) currentDelay = Math.min(1500, currentDelay + 100);
else if (avg < 800) currentDelay = Math.max(200, currentDelay - 50);
currentDelay = Math.min(1500, Math.max(200, currentDelay));
}
}
// ────────────────────── 页面匹配 ──────────────────────
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 BTN_ID = 'px-enhanced-btn';
// ────────────────────── UI 管理 ──────────────────────
let currentItems = [];
let currentKeyword = '';
let currentFailedPages = [];
let currentFetchedPages = 0;
let currentTotalItems = 0;
let sortField = 'bookmarkCount';
let sortAsc = false;
let virtualScroll = {
enabled: false,
itemHeight: 80,
visibleCount: 0,
startIndex: 0,
container: null,
scrollTop: 0,
};
let currentTheme = 'light';
function 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: 999999,
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;
function 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 PANEL_ID = 'px-enhanced-panel';
const BACKDROP_ID = 'px-enhanced-backdrop';
function closePanel() {
const backdrop = document.getElementById(BACKDROP_ID);
if (backdrop) backdrop.remove();
document.removeEventListener('keydown', onEscKey);
cleanupDarkModeListeners();
}
function onEscKey(e) {
if (e.key === 'Escape') closePanel();
}
function 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 = {};
function 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;
}
}
function 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: 999998,
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.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-virtual-list');
if (listContainer) {
renderVirtualList(listContainer, currentItems);
}
}
}
});
return body;
}
function getCurrentStyles() {
return panelThemeStyles || getThemeStyles(currentTheme);
}
// ─── 渲染函数 ───
function renderProgressSkeleton() {
const body = ensurePanel();
const styles = getCurrentStyles();
body.innerHTML = `
`;
}
function updateProgress(current, total, startTime) {
const text = document.getElementById('px-progress-text');
const bar = document.getElementById('px-progress-bar');
const pctEl = document.getElementById('px-progress-percent');
const timeEl = document.getElementById('px-progress-time');
const estEl = document.getElementById('px-progress-estimate');
const pct = total > 0 ? Math.min(100, Math.round((current / total) * 100)) : 0;
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) {
const avg = elapsed / current;
const remain = (total - current) * avg;
if (estEl) estEl.textContent = `⏳ 预计剩余: ${formatTime(remain)}`;
} else if (current >= total) {
if (estEl) estEl.textContent = '✅ 完成';
}
}
}
// ─── 排序核心函数 ───
function 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();
}
if (asc) {
return va - vb;
} else {
return vb - va;
}
});
}
// ─── 渲染结果(优化UI版) ───
function renderResult(keyword, items, fetchedPages, hadError, fromCache, failedPages = []) {
currentItems = items.slice();
currentKeyword = keyword;
currentFailedPages = failedPages.slice();
currentFetchedPages = fetchedPages;
currentTotalItems = items.length;
sortField = 'bookmarkCount';
sortAsc = false;
currentItems = sortItems(currentItems, sortField, sortAsc);
const body = ensurePanel();
const styles = getCurrentStyles();
const titleEl = document.getElementById('px-panel-title');
if (titleEl) titleEl.textContent = `「${keyword}」 · ${items.length} 条结果`;
const container = document.createElement('div');
container.style.display = 'flex';
container.style.flexDirection = 'column';
container.style.gap = '12px';
container.style.height = '100%';
container.style.color = styles.textPrimary;
// ─── 控制栏 ───
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',
outline: 'none',
minWidth: '70px',
});
const options = [
{ value: 'bookmarkCount', label: '收藏' },
{ value: 'textCount', label: '字数' },
{ value: 'createDate', label: '日期' }
];
options.forEach(opt => {
const op = document.createElement('option');
op.value = opt.value;
op.textContent = opt.label;
sortSelect.appendChild(op);
});
sortSelect.value = sortField;
sortSelect.addEventListener('change', () => {
sortField = sortSelect.value;
currentItems = sortItems(currentItems, sortField, sortAsc);
renderVirtualList(listContainer, currentItems);
updateSortIndicator();
});
sortGroup.appendChild(sortSelect);
const orderBtn = document.createElement('button');
orderBtn.id = 'px-order-btn';
orderBtn.textContent = '▼';
Object.assign(orderBtn.style, {
padding: '4px 8px',
border: `1px solid ${styles.inputBorder}`,
borderRadius: '6px',
background: styles.inputBg,
color: styles.inputText,
fontSize: '13px',
cursor: 'pointer',
lineHeight: '1',
});
orderBtn.addEventListener('click', () => {
sortAsc = !sortAsc;
currentItems = sortItems(currentItems, sortField, sortAsc);
renderVirtualList(listContainer, currentItems);
updateSortIndicator();
});
function updateSortIndicator() {
orderBtn.textContent = sortAsc ? '▲' : '▼';
}
updateSortIndicator();
sortGroup.appendChild(orderBtn);
controls.appendChild(sortGroup);
// 实时筛选输入框
const filterInput = document.createElement('input');
filterInput.placeholder = '🔍 筛选标题/作者';
Object.assign(filterInput.style, {
padding: '4px 10px',
border: `1px solid ${styles.inputBorder}`,
borderRadius: '6px',
fontSize: '13px',
background: styles.inputBg,
color: styles.inputText,
outline: 'none',
width: '150px',
transition: 'border 0.2s',
});
filterInput.addEventListener('focus', () => { filterInput.style.borderColor = styles.btnPrimary; });
filterInput.addEventListener('blur', () => { filterInput.style.borderColor = styles.inputBorder; });
filterInput.addEventListener('input', (e) => {
const keyword = e.target.value.toLowerCase();
const cards = listContainer.querySelectorAll('.px-result-card-wrapper');
cards.forEach(card => {
const text = card.textContent.toLowerCase();
card.style.display = text.includes(keyword) ? '' : 'none';
});
});
controls.appendChild(filterInput);
// 统计信息
const totalBm = items.reduce((sum, i) => sum + (i.bookmarkCount || 0), 0);
const avgBm = items.length > 0 ? Math.round(totalBm / items.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}
📄 ${items.length}
`;
controls.appendChild(statsDiv);
// 导出按钮
const exportBtn = document.createElement('button');
exportBtn.textContent = '📥 导出HTML';
Object.assign(exportBtn.style, {
padding: '4px 12px',
border: 'none',
borderRadius: '6px',
background: styles.btnSuccess,
color: styles.btnSuccessText,
fontSize: '13px',
cursor: 'pointer',
});
exportBtn.addEventListener('click', () => {
exportHTML(currentItems, currentKeyword, sortField, sortAsc);
});
controls.appendChild(exportBtn);
// 重试失败页按钮
if (failedPages.length > 0) {
const retryBtn = document.createElement('button');
retryBtn.textContent = `🔄 重试 (${failedPages.length})`;
Object.assign(retryBtn.style, {
padding: '4px 12px',
border: 'none',
borderRadius: '6px',
background: styles.btnDanger,
color: styles.btnDangerText,
fontSize: '13px',
cursor: 'pointer',
});
retryBtn.addEventListener('click', () => {
const sMode = normalizeSMode(getQueryParam('s_mode'));
const mode = getQueryParam('mode') || 'all';
const cacheKey = getCacheKey(currentKeyword, sMode, mode);
startFetch(currentKeyword, 0, sMode, mode, cacheKey, null, failedPages, true);
});
controls.appendChild(retryBtn);
}
const info = document.createElement('div');
info.style.fontSize = '12px';
info.style.color = styles.textMuted;
info.textContent = `${fromCache ? '💾 缓存 ' : ''}${fetchedPages} 页${hadError ? ' ⚠️ 部分失败' : ''}`;
controls.appendChild(info);
container.appendChild(controls);
// ─── 列表容器 ───
const listContainer = document.createElement('div');
Object.assign(listContainer.style, {
flex: '1',
overflowY: 'auto',
maxHeight: '68vh',
minHeight: '350px',
position: 'relative',
});
listContainer.id = 'px-virtual-list';
container.appendChild(listContainer);
body.innerHTML = '';
body.appendChild(container);
// 虚拟滚动设置
const total = currentItems.length;
if (total > 50) {
virtualScroll.enabled = true;
virtualScroll.itemHeight = 80;
virtualScroll.visibleCount = Math.ceil((listContainer.clientHeight || 400) / virtualScroll.itemHeight) + 6;
virtualScroll.container = listContainer;
listContainer.addEventListener('scroll', () => {
virtualScroll.scrollTop = listContainer.scrollTop;
renderVirtualList(listContainer, currentItems);
});
} else {
virtualScroll.enabled = false;
}
renderVirtualList(listContainer, currentItems);
}
// ─── 虚拟滚动渲染(优化UI版) ───
function renderVirtualList(listContainer, items) {
if (!listContainer) return;
const total = items.length;
const styles = getCurrentStyles();
if (total === 0) {
listContainer.innerHTML = `没有找到结果
`;
return;
}
if (!virtualScroll.enabled || total <= 50) {
listContainer.innerHTML = items.map((item, idx) =>
`${renderItem(item, idx, styles)}
`
).join('');
listContainer.style.height = 'auto';
return;
}
const height = virtualScroll.itemHeight;
const containerHeight = listContainer.clientHeight || 400;
const visibleCount = Math.ceil(containerHeight / height) + 6;
const start = Math.max(0, Math.floor((virtualScroll.scrollTop || 0) / height) - 3);
const end = Math.min(total, start + visibleCount);
const offset = start * height;
listContainer.style.height = (total * height) + 'px';
listContainer.style.position = 'relative';
listContainer.style.overflowY = 'auto';
let html = ``;
html += ``;
for (let i = start; i < end && i < total; i++) {
html += `
${renderItem(items[i], i, styles)}
`;
}
html += '
';
listContainer.innerHTML = html;
}
// ─── 单项渲染(优化UI版 + 标题行数限制) ───
function 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}
`;
}
// ─── 导出 HTML(修复图片防盗链) ───
function exportHTML(items, keyword, field, asc) {
if (!items || items.length === 0) {
alert('没有数据可导出。');
return;
}
const fieldLabels = {
bookmarkCount: '收藏数',
textCount: '字数',
createDate: '发布日期'
};
const orderLabel = asc ? '升序' : '降序';
const sortDesc = `${fieldLabels[field] || field} ${orderLabel}`;
const rows = items.map((item, idx) => {
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('zh-CN') : '';
const userId = item.userId || '';
const isHot = bm > 1000;
const coverHtml = cover
? `
`
: `无封面`;
const authorHtml = userId
? `${author}`
: `${author}`;
const hotBadge = isHot
? `⭐ 热门`
: '';
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
? ``
: '';
return `
${idx + 1}
${coverHtml}
✍️ ${authorHtml}
❤️ ${bm}
📄 ${textCount}
${createDate ? `📅 ${createDate}` : ''}
${tagsHtml}
`;
}).join('');
const htmlContent = `
「${keyword}」搜索结果
📊 共 ${items.length} 条
导出时间: ${new Date().toLocaleString('zh-CN')}
${rows}
`;
const blob = new Blob([htmlContent], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `pixiv_${keyword}_${sortDesc.replace(/ /g, '_')}_${new Date().toISOString().slice(0,10)}.html`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// ────────────────────── 选择页数界面(优化UI版) ──────────────────────
function renderPageCountSelector(keyword, onSelect, cacheInfo, cacheKey) {
const body = ensurePanel();
const styles = getCurrentStyles();
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');
const styles2 = getCurrentStyles();
presets.forEach(p => {
const b = document.createElement('button');
b.textContent = `${p} 页`;
Object.assign(b.style, {
padding: '6px 18px',
border: `1px solid ${styles2.btnPrimary}`,
borderRadius: '6px',
background: 'transparent',
color: styles2.btnPrimary,
fontSize: '13px',
cursor: 'pointer',
transition: 'all 0.2s',
});
b.addEventListener('mouseenter', () => { b.style.background = styles2.tagBg; });
b.addEventListener('mouseleave', () => { b.style.background = 'transparent'; });
b.addEventListener('click', () => onSelect(p));
wrap.appendChild(b);
});
const customBtn = document.getElementById('px-custom-btn');
const customInput = document.getElementById('px-custom-input');
const submitCustom = () => {
const v = Math.max(1, Math.min(200, parseInt(customInput.value, 10) || 0));
if (!v) { alert('请输入 1~200 之间的页数'); return; }
onSelect(v);
};
customBtn.addEventListener('click', submitCustom);
customInput.addEventListener('keydown', e => { if (e.key === 'Enter') submitCustom(); });
customInput.addEventListener('focus', () => { customInput.style.borderColor = styles2.btnPrimary; });
customInput.addEventListener('blur', () => { customInput.style.borderColor = styles2.inputBorder; });
const clearBtn = document.getElementById('px-clear-cache-btn');
if (clearBtn) {
clearBtn.addEventListener('click', () => {
if (confirm('清除该关键词的缓存?')) {
localStorage.removeItem(cacheKey);
renderPageCountSelector(keyword, onSelect, null, cacheKey);
}
});
}
}
// ────────────────────── 主逻辑 ──────────────────────
async function onButtonClick() {
const keyword = extractKeyword();
if (!keyword) {
alert('请先在 Pixiv 小说搜索结果页使用此工具。');
return;
}
const sMode = normalizeSMode(getQueryParam('s_mode'));
const mode = getQueryParam('mode') || 'all';
const cacheKey = getCacheKey(keyword, sMode, mode);
const cached = loadCache(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
);
}
// ─── startFetch(并发抓取) ───
async function startFetch(keyword, pageCount, sMode, mode, cacheKey, cached, retryPages = null, isRetry = false) {
if (isRetry && retryPages && retryPages.length > 0) {
const latest = loadCache(cacheKey);
let existingItems = latest ? latest.items : [];
let seenIds = new Set(existingItems.map(i => i.id));
let allItems = existingItems.slice();
let completedPages = latest ? latest.fetchedPages : 0;
let noMorePages = latest ? latest.noMorePages : false;
renderProgressSkeleton();
const startTime = Date.now();
updateProgress(0, retryPages.length, startTime);
let hadError = false;
const failedAgain = [];
const concurrency = 3;
const pages = retryPages.slice();
let completed = 0;
const total = pages.length;
async function worker() {
while (pages.length > 0) {
const page = pages.shift();
try {
const body = await fetchPageWithRetry(keyword, page, sMode, mode, 3);
const section = body.novel || body.novelManga || {};
const list = section.data || [];
if (list.length === 0) {
failedAgain.push(page);
continue;
}
for (const item of list) {
if (!seenIds.has(item.id)) {
seenIds.add(item.id);
allItems.push(item);
}
}
} catch (e) {
failedAgain.push(page);
hadError = true;
} finally {
completed++;
updateProgress(completed, total, startTime);
await sleep(100 + Math.random() * 200);
}
}
}
const workers = Array.from({ length: concurrency }, () => worker());
await Promise.all(workers);
if (!hadError || allItems.length > 0) {
saveCache(cacheKey, {
items: allItems,
fetchedPages: completedPages + (total - failedAgain.length),
noMorePages,
});
}
const finalItems = allItems.slice();
renderResult(keyword, finalItems, completedPages + (total - failedAgain.length), hadError, false, failedAgain);
return;
}
const latest = loadCache(cacheKey);
cached = latest || null;
let allItems = [];
let seenIds = new Set();
let completedPages = 0;
let noMorePages = false;
let failedPages = [];
if (cached) {
allItems = cached.items.slice();
seenIds = new Set(allItems.map(i => i.id));
completedPages = cached.fetchedPages;
noMorePages = !!cached.noMorePages;
if (completedPages >= pageCount || noMorePages) {
const finalItems = allItems.slice();
renderResult(keyword, finalItems, completedPages, false, true, []);
return;
}
}
renderProgressSkeleton();
const startTime = Date.now();
let toFetch = [];
for (let p = completedPages + 1; p <= pageCount; p++) {
toFetch.push(p);
}
if (toFetch.length === 0) {
const finalItems = allItems.slice();
renderResult(keyword, finalItems, completedPages, false, true, []);
return;
}
updateProgress(0, toFetch.length, startTime);
let hadError = false;
const concurrency = 3;
const pages = toFetch.slice();
let completed = 0;
const total = pages.length;
async function worker() {
while (pages.length > 0) {
const page = pages.shift();
try {
const body = await fetchPageWithRetry(keyword, page, sMode, mode, 3);
const section = body.novel || body.novelManga || {};
const list = section.data || [];
const totalCount = typeof section.total === 'number' ? section.total : null;
if (list.length === 0) {
if (totalCount !== null && seenIds.size >= totalCount) {
noMorePages = true;
pages.length = 0;
break;
} else {
failedPages.push(page);
hadError = true;
continue;
}
}
for (const item of list) {
if (!seenIds.has(item.id)) {
seenIds.add(item.id);
allItems.push(item);
}
}
} catch (e) {
failedPages.push(page);
hadError = true;
console.error(`[Pixiv搜索优化] 第 ${page} 页失败:`, e);
} finally {
completed++;
updateProgress(completed, total, startTime);
await sleep(100 + Math.random() * 200);
}
}
}
const workers = Array.from({ length: concurrency }, () => worker());
await Promise.all(workers);
if (!hadError || allItems.length > 0) {
saveCache(cacheKey, {
items: allItems,
fetchedPages: completedPages + (total - failedPages.length),
noMorePages,
});
}
const finalItems = allItems.slice();
renderResult(keyword, finalItems, completedPages + (total - failedPages.length), hadError, false, failedPages);
}
// ────────────────────── 启动 ──────────────────────
injectButton();
startObserver();
})();