// ==UserScript== // @name AO3 阅读历史增强导出器(稳定优化版) // @namespace https://github.com/KWzhabing/ao3-reading-exporter // @version 2.0 // @description 稳定导出AO3阅读历史为JSON,含智能限流、错误重试、进度保存功能 // @author KWzhabing // @match https://archiveofourown.org/users/*/readings* // @icon https://archiveofourown.org/favicon.ico // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_notification // @license MIT // ==/UserScript== (function() { 'use strict'; // ========== 配置参数 ========== const CONFIG = { // 请求控制(保守设置避免被限制) MAX_CONCURRENT: 2, // 并发数(降低到2,更稳定) DELAY_BETWEEN_REQUESTS: 1200, // 单个请求间隔(毫秒) DELAY_BETWEEN_BATCHES: 3000, // 批次间隔(毫秒) REQUEST_TIMEOUT: 15000, // 请求超时(15秒) // 重试机制 MAX_RETRIES: 2, // 最大重试次数 RETRY_DELAY: 2000, // 重试延迟(毫秒) // 进度保存 AUTO_SAVE_INTERVAL: 10, // 每处理N个项目自动保存 SESSION_EXPIRY_HOURS: 24, // 会话过期时间(小时) // 用户体验 NOTIFY_ON_COMPLETE: true, // 完成时通知 SHOW_DETAILED_LOGS: false // 显示详细日志(调试用) }; // ========== 状态管理 ========== const STATE = { isRunning: false, currentSessionId: null, totalItems: 0, processedItems: 0, failedItems: 0, startTime: null, pauseRequested: false }; // ========== DOM元素引用 ========== let exportContainer, basicBtn, fullBtn, pauseBtn, resumeBtn, cancelBtn, progressBar, statusText, logsContainer; // ========== 工具函数 ========== function log(message, type = 'info') { if (!CONFIG.SHOW_DETAILED_LOGS && type === 'debug') return; const timestamp = new Date().toISOString().split('T')[1].split('.')[0]; const prefix = `[${timestamp}]`; const colors = { info: '#2196F3', success: '#4CAF50', warning: '#FF9800', error: '#F44336', debug: '#9C27B0' }; console.log(`%c${prefix} ${message}`, `color: ${colors[type] || '#000'}`); // 添加到日志容器 if (logsContainer) { const logEntry = document.createElement('div'); logEntry.textContent = `${prefix} ${message}`; logEntry.style.color = colors[type] || '#000'; logEntry.style.fontSize = '12px'; logEntry.style.margin = '2px 0'; logsContainer.appendChild(logEntry); // 保持日志容器最新 logsContainer.scrollTop = logsContainer.scrollHeight; } } // ========== 存储管理 ========== function saveProgress(sessionId, data) { try { const progress = { sessionId, data, timestamp: Date.now(), totalItems: STATE.totalItems, processedItems: STATE.processedItems }; GM_setValue(`ao3_export_progress_${sessionId}`, JSON.stringify(progress)); log(`进度已保存 (${STATE.processedItems}/${STATE.totalItems})`, 'debug'); } catch (e) { log(`保存进度失败: ${e.message}`, 'warning'); } } function loadProgress(sessionId) { try { const saved = GM_getValue(`ao3_export_progress_${sessionId}`); if (saved) { const progress = JSON.parse(saved); // 检查是否过期 const expiryMs = CONFIG.SESSION_EXPIRY_HOURS * 60 * 60 * 1000; if (Date.now() - progress.timestamp < expiryMs) { return progress; } else { log('保存的进度已过期,重新开始', 'warning'); GM_deleteValue(`ao3_export_progress_${sessionId}`); } } } catch (e) { log(`加载进度失败: ${e.message}`, 'warning'); } return null; } // ========== 请求工具 ========== async function fetchWithRetry(url, options = {}, retryCount = 0) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), CONFIG.REQUEST_TIMEOUT); try { const response = await fetch(url, { credentials: 'include', signal: controller.signal, ...options }); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } return response; } catch (error) { clearTimeout(timeoutId); if (retryCount < CONFIG.MAX_RETRIES) { const delay = CONFIG.RETRY_DELAY * (retryCount + 1); log(`请求失败,${delay}ms后重试 (${retryCount + 1}/${CONFIG.MAX_RETRIES}): ${url}`, 'warning'); await delayAsync(delay); return fetchWithRetry(url, options, retryCount + 1); } throw error; } } function delayAsync(ms) { return new Promise(resolve => { // 允许用户暂停 const checkPause = () => { if (STATE.pauseRequested) { setTimeout(checkPause, 100); } else { setTimeout(resolve, ms); } }; checkPause(); }); } // ========== 数据提取函数 ========== function extractWorkIdFromUrl(url) { const match = url.match(/\/works\/(\d+)/); return match ? match[1] : null; } async function checkIfCommented(workId, username) { if (!workId) return false; try { const commentUrl = `https://archiveofourown.org/comments/show_comments?work_id=${workId}`; const response = await fetchWithRetry(commentUrl); const html = await response.text(); // 多种方式检查用户名 const lowerHtml = html.toLowerCase(); const lowerUser = username.toLowerCase(); const hasCommented = lowerHtml.includes(`>${lowerUser}<`) || lowerHtml.includes(`by ${lowerUser}`) || lowerHtml.includes(`comment by ${lowerUser}`) || lowerHtml.includes(`author="${lowerUser}"`) || lowerHtml.includes(`comment_${lowerUser}`); log(`评论检查: ${workId} - ${hasCommented ? '已评论' : '未评论'}`, 'debug'); return hasCommented; } catch (error) { log(`评论检查失败 (work_id=${workId}): ${error.message}`, 'warning'); return false; } } async function fetchWorkDetails(reading, username) { const workId = extractWorkIdFromUrl(reading.url); try { // 1. 获取作品页面 const response = await fetchWithRetry(reading.url); const html = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); // 2. 提取字数 let words = null; const wordSelectors = [ 'dd.words', 'dl.stats dd:nth-child(2)', 'p.meta:contains("Words:")', 'li:contains("Words:")', 'dd:contains("words")' ]; for (const selector of wordSelectors) { const element = doc.querySelector(selector); if (element) { const text = element.textContent; const match = text.match(/(\d[\d,]*)/); if (match) { words = parseInt(match[1].replace(/,/g, ''), 10); break; } } } // 3. 提取标签 const tags = []; const tagSelectors = [ '.tag', '.tags a', '.work-tags a', 'li.tag a' ]; tagSelectors.forEach(selector => { doc.querySelectorAll(selector).forEach(el => { const tag = el.textContent.trim(); if (tag && tag !== 'No tags' && !tags.includes(tag)) { tags.push(tag); } }); }); // 4. 检查是否给过kudos let kudos_given = false; const kudosElement = doc.querySelector(`#kudos a[href*="/users/${username}"]`); if (kudosElement) { kudos_given = true; } // 5. 检查是否评论过(使用独立请求) const commented = await checkIfCommented(workId, username); log(`获取成功: ${reading.title} (${words || 'N/A'}字)`, 'success'); return { ...reading, words, tags, kudos_given, commented }; } catch (error) { log(`获取失败: ${reading.title} - ${error.message}`, 'error'); // 返回基础信息,标记为失败 return { ...reading, words: null, tags: [], kudos_given: false, commented: false, _fetchError: error.message }; } } // ========== 导出函数 ========== async function exportBasicReadingHistory() { if (STATE.isRunning) return; STATE.isRunning = true; STATE.startTime = Date.now(); updateUI('running'); try { const username = window.location.pathname.split('/')[2]; const baseUrl = `https://archiveofourown.org/users/${username}/readings`; const parser = new DOMParser(); const allReadings = []; let page = 1; while (STATE.isRunning) { const url = page === 1 ? baseUrl : `${baseUrl}?page=${page}`; log(`获取第 ${page} 页...`, 'info'); try { const response = await fetchWithRetry(url); const text = await response.text(); const doc = parser.parseFromString(text, 'text/html'); const readings = Array.from(doc.querySelectorAll('li.reading, .reading')).map(item => { const titleEl = item.querySelector('h4 a, .heading a'); const authorEl = item.querySelector('.byline a, .authors a'); const dateEl = item.querySelector('.datetime, .read-at'); if (!titleEl) return null; return { title: titleEl.textContent.trim(), url: titleEl.href.startsWith('http') ? titleEl.href : `https://archiveofourown.org${titleEl.getAttribute('href')}`, authors: authorEl ? authorEl.textContent.trim() : '', date: dateEl ? (dateEl.title || dateEl.textContent.trim()) : 'Unknown' }; }).filter(Boolean); if (readings.length === 0) break; allReadings.push(...readings); updateStatus(`已获取 ${allReadings.length} 条记录...`); page++; } catch (error) { log(`获取第 ${page} 页失败: ${error.message}`, 'error'); break; } } if (!STATE.isRunning) { log('导出被用户取消', 'warning'); return; } downloadJson(allReadings, 'ao3_reading_history_basic'); log(`快速导出完成,共 ${allReadings.length} 条记录`, 'success'); } catch (error) { log(`快速导出失败: ${error.message}`, 'error'); showError('快速导出失败', error.message); } finally { resetState(); updateUI('idle'); } } async function exportFullReadingHistory() { if (STATE.isRunning) return; STATE.isRunning = true; STATE.startTime = Date.now(); updateUI('running'); try { const username = window.location.pathname.split('/')[2]; if (!username) throw new Error('无法获取用户名'); // 生成会话ID STATE.currentSessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // 1. 获取所有阅读记录(基础信息) log('开始获取阅读记录列表...', 'info'); const allReadings = await fetchAllReadings(username); if (!allReadings || allReadings.length === 0) { throw new Error('未找到任何阅读记录'); } STATE.totalItems = allReadings.length; updateStatus(`准备处理 ${STATE.totalItems} 条记录...`); // 2. 检查是否有保存的进度 let results = []; let startIndex = 0; const savedProgress = loadProgress(STATE.currentSessionId); if (savedProgress && savedProgress.data) { const resume = confirm(`发现未完成的导出进度 (${savedProgress.processedItems}/${savedProgress.totalItems}),是否继续?`); if (resume) { results = savedProgress.data; startIndex = savedProgress.processedItems; STATE.processedItems = savedProgress.processedItems; log(`从进度点 ${startIndex} 恢复导出`, 'info'); } } // 3. 分批获取详细信息 for (let i = startIndex; i < allReadings.length && STATE.isRunning; i += CONFIG.MAX_CONCURRENT) { if (STATE.pauseRequested) { log('导出已暂停', 'warning'); while (STATE.pauseRequested && STATE.isRunning) { await delayAsync(1000); } if (!STATE.isRunning) break; log('导出已恢复', 'info'); } const batch = allReadings.slice(i, i + CONFIG.MAX_CONCURRENT); updateStatus(`处理中... ${STATE.processedItems}/${STATE.totalItems}`); // 处理批次 const batchPromises = batch.map(async (reading, idx) => { await delayAsync(idx * CONFIG.DELAY_BETWEEN_REQUESTS); return fetchWorkDetails(reading, username); }); const batchResults = await Promise.allSettled(batchPromises); // 处理结果 batchResults.forEach((result, idx) => { if (result.status === 'fulfilled') { results.push(result.value); STATE.processedItems++; } else { log(`作品处理失败: ${batch[idx]?.title || 'Unknown'} - ${result.reason}`, 'error'); results.push({ ...batch[idx], words: null, tags: [], kudos_given: false, commented: false, _error: result.reason?.message || 'Unknown error' }); STATE.failedItems++; STATE.processedItems++; } }); // 更新进度 updateProgress(); // 定期保存进度 if (STATE.processedItems % CONFIG.AUTO_SAVE_INTERVAL === 0) { saveProgress(STATE.currentSessionId, results); } // 批次间隔 if (i + CONFIG.MAX_CONCURRENT < allReadings.length) { await delayAsync(CONFIG.DELAY_BETWEEN_BATCHES); } } if (!STATE.isRunning) { // 用户取消,保存进度 if (STATE.processedItems > 0) { saveProgress(STATE.currentSessionId, results); log(`导出已暂停,进度已保存 (${STATE.processedItems}/${STATE.totalItems})`, 'warning'); } return; } // 4. 完成导出 updateStatus(`处理完成,成功: ${results.length - STATE.failedItems}, 失败: ${STATE.failedItems}`); // 清理进度保存 GM_deleteValue(`ao3_export_progress_${STATE.currentSessionId}`); // 下载结果 downloadJson(results, 'ao3_reading_history_enhanced'); // 显示统计信息 const timeTaken = ((Date.now() - STATE.startTime) / 1000).toFixed(1); log(`完整导出完成! 总计: ${results.length}, 成功: ${results.length - STATE.failedItems}, 失败: ${STATE.failedItems}, 耗时: ${timeTaken}秒`, 'success'); if (CONFIG.NOTIFY_ON_COMPLETE) { showNotification('导出完成', `成功导出 ${results.length} 条记录,耗时 ${timeTaken} 秒`); } } catch (error) { log(`完整导出失败: ${error.message}`, 'error'); showError('完整导出失败', error.message); } finally { resetState(); updateUI('idle'); } } async function fetchAllReadings(username) { const baseUrl = `https://archiveofourown.org/users/${username}/readings`; const parser = new DOMParser(); const allReadings = []; let page = 1; while (true) { const url = page === 1 ? baseUrl : `${baseUrl}?page=${page}`; try { const response = await fetchWithRetry(url); const text = await response.text(); const doc = parser.parseFromString(text, 'text/html'); const readings = Array.from(doc.querySelectorAll('li.reading, .reading')).map(item => { const titleEl = item.querySelector('h4 a, .heading a'); const authorEl = item.querySelector('.byline a, .authors a'); const dateEl = item.querySelector('.datetime, .read-at'); if (!titleEl) return null; return { title: titleEl.textContent.trim(), url: titleEl.href.startsWith('http') ? titleEl.href : `https://archiveofourown.org${titleEl.getAttribute('href')}`, authors: authorEl ? authorEl.textContent.trim() : '', date: dateEl ? (dateEl.title || dateEl.textContent.trim()) : 'Unknown', _page: page }; }).filter(Boolean); if (readings.length === 0) break; allReadings.push(...readings); log(`第 ${page} 页获取到 ${readings.length} 条记录,总计 ${allReadings.length}`, 'info'); page++; } catch (error) { log(`获取第 ${page} 页失败: ${error.message}`, 'error'); break; } } return allReadings; } // ========== UI相关函数 ========== function createUI() { // 移除已存在的UI const existingUI = document.getElementById('ao3-export-ui'); if (existingUI) existingUI.remove(); // 创建主容器 const container = document.createElement('div'); container.id = 'ao3-export-ui'; container.style.cssText = ` margin: 1.5rem 0; padding: 1rem; background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; `; // 标题 const title = document.createElement('h3'); title.textContent = 'AO3 阅读历史导出器 (v2.0)'; title.style.margin = '0 0 1rem 0'; title.style.color = '#2c3e50'; container.appendChild(title); // 按钮容器 const buttonContainer = document.createElement('div'); buttonContainer.style.cssText = ` display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 1rem; `; // 创建按钮 basicBtn = createButton('📤 快速导出', '#4CAF50', '仅基础数据,速度快'); fullBtn = createButton('🚀 完整导出', '#2196F3', '含字数/标签/Kudos/评论'); pauseBtn = createButton('⏸️ 暂停', '#FF9800', '暂停当前导出'); resumeBtn = createButton('▶️ 继续', '#4CAF50', '继续导出'); cancelBtn = createButton('❌ 取消', '#F44336', '取消导出'); pauseBtn.style.display = 'none'; resumeBtn.style.display = 'none'; cancelBtn.style.display = 'none'; buttonContainer.appendChild(basicBtn); buttonContainer.appendChild(fullBtn); buttonContainer.appendChild(pauseBtn); buttonContainer.appendChild(resumeBtn); buttonContainer.appendChild(cancelBtn); container.appendChild(buttonContainer); // 进度条 progressBar = document.createElement('div'); progressBar.style.cssText = ` width: 100%; height: 6px; background: #e0e0e0; border-radius: 3px; margin-bottom: 0.5rem; overflow: hidden; display: none; `; const progressFill = document.createElement('div'); progressFill.id = 'ao3-progress-fill'; progressFill.style.cssText = ` width: 0%; height: 100%; background: linear-gradient(90deg, #4CAF50, #8BC34A); transition: width 0.3s ease; `; progressBar.appendChild(progressFill); container.appendChild(progressBar); // 状态文本 statusText = document.createElement('div'); statusText.id = 'ao3-status-text'; statusText.style.cssText = ` font-size: 14px; color: #666; margin-bottom: 0.5rem; min-height: 20px; display: none; `; container.appendChild(statusText); // 日志容器 logsContainer = document.createElement('div'); logsContainer.id = 'ao3-logs-container'; logsContainer.style.cssText = ` max-height: 200px; overflow-y: auto; background: white; border: 1px solid #ddd; border-radius: 4px; padding: 8px; font-size: 12px; font-family: monospace; display: none; `; container.appendChild(logsContainer); // 日志开关 const logToggle = document.createElement('button'); logToggle.textContent = '📋 显示日志'; logToggle.style.cssText = ` margin-top: 0.5rem; padding: 4px 8px; background: #6c757d; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; `; logToggle.onclick = () => { logsContainer.style.display = logsContainer.style.display === 'none' ? 'block' : 'none'; logToggle.textContent = logsContainer.style.display === 'none' ? '📋 显示日志' : '📋 隐藏日志'; }; container.appendChild(logToggle); // 插入到页面 const mainContent = document.querySelector('#main'); if (mainContent) { mainContent.insertBefore(container, mainContent.firstChild); } // 添加事件监听 basicBtn.addEventListener('click', exportBasicReadingHistory); fullBtn.addEventListener('click', exportFullReadingHistory); pauseBtn.addEventListener('click', () => { STATE.pauseRequested = true; pauseBtn.style.display = 'none'; resumeBtn.style.display = 'inline-block'; updateStatus('已暂停...'); }); resumeBtn.addEventListener('click', () => { STATE.pauseRequested = false; pauseBtn.style.display = 'inline-block'; resumeBtn.style.display = 'none'; updateStatus('恢复中...'); }); cancelBtn.addEventListener('click', () => { STATE.isRunning = false; STATE.pauseRequested = false; updateStatus('正在取消...'); }); } function createButton(text, color, title) { const btn = document.createElement('button'); btn.textContent = text; btn.title = title; btn.style.cssText = ` padding: 8px 16px; background: ${color}; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; transition: opacity 0.2s; flex: 1; min-width: 120px; `; btn.onmouseover = () => btn.style.opacity = '0.9'; btn.onmouseout = () => btn.style.opacity = '1'; return btn; } function updateUI(state) { switch(state) { case 'idle': basicBtn.disabled = false; fullBtn.disabled = false; pauseBtn.style.display = 'none'; resumeBtn.style.display = 'none'; cancelBtn.style.display = 'none'; progressBar.style.display = 'none'; statusText.style.display = 'none'; break; case 'running': basicBtn.disabled = true; fullBtn.disabled = true; pauseBtn.style.display = 'inline-block'; resumeBtn.style.display = 'none'; cancelBtn.style.display = 'inline-block'; progressBar.style.display = 'block'; statusText.style.display = 'block'; logsContainer.style.display = 'block'; break; case 'paused': pauseBtn.style.display = 'none'; resumeBtn.style.display = 'inline-block'; break; } } function updateStatus(message) { if (statusText) { statusText.textContent = message; statusText.style.display = 'block'; } } function updateProgress() { if (progressBar && STATE.totalItems > 0) { const percent = Math.min(100, Math.round((STATE.processedItems / STATE.totalItems) * 100)); const progressFill = document.getElementById('ao3-progress-fill'); if (progressFill) { progressFill.style.width = `${percent}%`; } // 更新状态文本 const timeElapsed = ((Date.now() - STATE.startTime) / 1000).toFixed(0); updateStatus(`进度: ${percent}% (${STATE.processedItems}/${STATE.totalItems}) | 失败: ${STATE.failedItems} | 用时: ${timeElapsed}s`); } } function resetState() { STATE.isRunning = false; STATE.pauseRequested = false; STATE.totalItems = 0; STATE.processedItems = 0; STATE.failedItems = 0; STATE.startTime = null; STATE.currentSessionId = null; } // ========== 下载和通知 ========== function downloadJson(data, prefix) { try { const jsonStr = JSON.stringify(data, null, 2); const blob = new Blob([jsonStr], { type: 'application/json;charset=utf-8' }); const url = URL.createObjectURL(blob); const filename = `${prefix}_${new Date().toISOString().slice(0, 10)}.json`; // 尝试自动下载 const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); // 备用方案 setTimeout(() => { const fallback = document.createElement('div'); fallback.style.cssText = ` position: fixed; bottom: 20px; left: 20px; background: #ff9800; color: white; padding: 10px 16px; border-radius: 4px; z-index: 10000; box-shadow: 0 2px 10px rgba(0,0,0,0.2); `; fallback.innerHTML = ` 下载提示
如果文件未自动下载:
点击此处手动下载 `; document.body.appendChild(fallback); setTimeout(() => { if (fallback.parentNode) { fallback.parentNode.removeChild(fallback); } URL.revokeObjectURL(url); }, 10000); }, 1000); } catch (error) { log(`下载文件失败: ${error.message}`, 'error'); } } function showNotification(title, message) { if (typeof GM_notification === 'function') { GM_notification({ title: title, text: message, timeout: 5000 }); } else { // 浏览器原生通知 if ('Notification' in window && Notification.permission === 'granted') { new Notification(title, { body: message }); } } } function showError(title, message) { const errorDiv = document.createElement('div'); errorDiv.style.cssText = ` position: fixed; top: 20px; right: 20px; background: #f44336; color: white; padding: 12px 16px; border-radius: 4px; z-index: 10000; box-shadow: 0 2px 10px rgba(0,0,0,0.2); max-width: 300px; `; errorDiv.innerHTML = ` ${title}
${message} `; document.body.appendChild(errorDiv); setTimeout(() => { if (errorDiv.parentNode) { errorDiv.parentNode.removeChild(errorDiv); } }, 10000); } // ========== 初始化 ========== function init() { // 等待页面加载完成 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', createUI); } else { createUI(); } // 请求通知权限 if ('Notification' in window && Notification.permission === 'default') { Notification.requestPermission(); } log('AO3 导出器已加载 (稳定优化版 v2.0)', 'success'); } // 启动 init(); })();