// ==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 ? `` : ''}
${title} ${hotBadge}
✍️ ${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}

选择抓取页数(每页约 24~30 条)

⚡ 并发抓取 · 缓存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 = `

⏳ 准备抓取…

0%
⏱ 已用: 0s ⏳ 预计剩余: --
`; 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(); })();