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

⏳ 准备抓取…

0%
⏱ 已用: 0s ⏳ 预计剩余: --
`; } 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 ? `` : ''}
${title} ${hotBadge}
✍️ ${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 ? `
${tagNames.slice(0, 5).map((t) => `#${escapeHtml(t)}` ).join('')} ${tagNames.length > 5 ? `+${tagNames.length - 5}` : ''}
` : ''; return `
${idx + 1}
${coverHtml}
${title} ${hotBadge}
✍️ ${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}

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

⚡ 并发抓取 · 缓存 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(); })();