// ==UserScript== // @name AO3 阅读历史增强导出器(设备一致版) // @namespace https://github.com/KWzhabing/ao3-reading-exporter // @version 2.3 // @description 一键导出 AO3 阅读历史为 JSON,确保设备间数据一致性 // @author KWzhabing // @match https://archiveofourown.org/users/*/readings* // @icon https://archiveofourown.org/favicon.ico // @grant none // @license MIT // ==/UserScript== (function () { 'use strict'; console.log('AO3导出器启动 - 设备一致性优化版'); console.log('User Agent:', navigator.userAgent); const username = window.location.pathname.split('/')[2]; if (!username) return; // 创建按钮容器 const container = document.createElement('div'); container.style.margin = '1rem 0'; container.innerHTML = `
`; const readingsList = document.querySelector('#main > dl, #main > ul, .reading-list'); if (readingsList) { readingsList.parentNode.insertBefore(container, readingsList); } else { document.querySelector('#main')?.prepend(container); } // ========== 工具函数:提取 work_id ========== function extractWorkIdFromUrl(url) { const match = url.match(/\/works\/(\d+)/); return match ? match[1] : null; } // ========== 工具函数:检测是否给过kudos ========== function detectKudosGiven(workDoc, username) { try { // 方法1:查找包含用户名的kudos链接 const kudosLinks = workDoc.querySelectorAll('#kudos a, .kudos a, a[href*="kudos"]'); for (const link of kudosLinks) { if (link.textContent.toLowerCase().includes(username.toLowerCase()) || link.getAttribute('href')?.includes(`/users/${username}`)) { return true; } } // 方法2:查找用户是否在kudos列表中 const kudosText = workDoc.querySelector('#kudos, .kudos')?.textContent || ''; if (kudosText.toLowerCase().includes(username.toLowerCase())) { return true; } // 方法3:检查kudos表单是否已提交(如果页面有kudos表单) const kudosForm = workDoc.querySelector('form.new_kudo'); if (kudosForm) { // 查找隐藏的input,如果有值表示已给过kudos const hiddenInputs = kudosForm.querySelectorAll('input[type="hidden"]'); for (const input of hiddenInputs) { if (input.value && input.value.includes(username)) { return true; } } } return false; } catch (err) { console.warn('检测kudos失败:', err.message); return false; } } // ========== 工具函数:检测是否评论过 ========== async function detectCommentsGiven(workId, username) { if (!workId) return false; try { // 方法1:使用评论API const commentUrl = `https://archiveofourown.org/comments/show_comments?work_id=${workId}`; const res = await fetch(commentUrl, { credentials: 'include' }); if (!res.ok) return false; const text = await res.text(); const lowerText = text.toLowerCase(); const lowerUser = username.toLowerCase(); // 多种可能的评论标识 const patterns = [ `>${lowerUser}<`, `by ${lowerUser}`, `comment by ${lowerUser}`, `user:${lowerUser}`, `"${lowerUser}"` ]; for (const pattern of patterns) { if (lowerText.includes(pattern)) { return true; } } // 方法2:如果API失败,尝试直接解析作品页面中的评论部分 const workUrl = `https://archiveofourown.org/works/${workId}`; const workRes = await fetch(workUrl, { credentials: 'include' }); if (workRes.ok) { const workText = await workRes.text(); const workLower = workText.toLowerCase(); // 在作品页面中查找评论 if (workLower.includes(`comment by ${lowerUser}`) || workLower.includes(` { const author = link.textContent.trim(); if (author && author !== '[Anonymous]') { authors.add(author); } }); // 方法2:如果没有找到,尝试从heading中提取 if (authors.size === 0) { const heading = item.querySelector('h4.heading'); if (heading) { // 查找所有链接,排除标题链接 const allLinks = heading.querySelectorAll('a'); const titleLink = heading.querySelector('a:first-child'); allLinks.forEach(link => { if (link !== titleLink && !link.href.includes('/tags/') && !link.href.includes('/works/') && !link.textContent.includes('Anonymous')) { const author = link.textContent.trim(); if (author) authors.add(author); } }); } } // 方法3:如果还是没有,尝试文本解析 if (authors.size === 0) { const heading = item.querySelector('h4.heading'); if (heading) { const text = heading.textContent; const byIndex = text.toLowerCase().indexOf(' by '); if (byIndex > -1) { const authorsText = text.substring(byIndex + 4); // 处理多种分隔符 const separators = [', ', ' and ', ' & ']; let parts = [authorsText]; for (const sep of separators) { parts = parts.flatMap(part => part.split(sep)); } parts.forEach(part => { const author = part.trim(); if (author && author !== 'Anonymous') { authors.add(author); } }); } } } // 如果没有作者,设为匿名 return authors.size > 0 ? Array.from(authors) : ['匿名']; } // ========== 工具函数:提取标签 ========== function extractTags(workDoc) { const tags = new Set(); // 多种可能的标签选择器 const tagSelectors = [ '.tag', '.tags a', '.tag-list a', '[rel="tag"]', '.work-tags a', '.fandoms a', '.relationship a', '.character a', '.freeform a' ]; tagSelectors.forEach(selector => { workDoc.querySelectorAll(selector).forEach(element => { const text = element.textContent.trim(); if (text && text !== 'No tags' && !text.includes('Choose Not To Use Archive Warnings') && !text.includes('No Archive Warnings Apply')) { tags.add(text); } }); }); return Array.from(tags); } // ========== 工具函数:提取字数 ========== function extractWords(workDoc) { // 多种可能的字数位置 const wordSelectors = [ 'dd.words', 'dl.stats dd:first-child', 'li.words', 'p.meta:contains("Words:")', 'li:contains("Words:")', '.work-meta:contains("Words:")', '.meta:contains("Words:")' ]; for (const selector of wordSelectors) { const element = workDoc.querySelector(selector); if (element) { const text = element.textContent; const match = text.match(/\b\d[\d,]*\b/); if (match) { return parseInt(match[0].replace(/,/g, ''), 10); } } } // 尝试在元数据块中搜索 const metaBlocks = workDoc.querySelectorAll('.meta, .stats, .work-meta'); for (const block of metaBlocks) { const text = block.textContent; if (text.includes('Words:')) { const match = text.match(/Words:\s*([\d,]+)/i); if (match) { return parseInt(match[1].replace(/,/g, ''), 10); } } } return null; } // ========== 更新状态显示 ========== function updateStatus(message) { const statusEl = document.getElementById('export-status'); if (statusEl) { statusEl.textContent = message; statusEl.style.color = '#333'; } console.log(message); } // ========== 快速导出 ========== async function exportBasicReadingHistory(btnEl) { const originalText = btnEl.textContent; btnEl.disabled = true; btnEl.textContent = '导出中...'; updateStatus('开始快速导出...'); try { const baseUrl = `https://archiveofourown.org/users/${username}/readings`; const parser = new DOMParser(); let allReadings = []; let page = 1; while (true) { const url = page === 1 ? baseUrl : `${baseUrl}?page=${page}`; updateStatus(`正在获取第 ${page} 页...`); const res = await fetch(url, { credentials: 'include' }); if (!res.ok) throw new Error(`列表页请求失败: ${res.status}`); const text = await res.text(); const doc = parser.parseFromString(text, 'text/html'); const readings = Array.from(doc.querySelectorAll('li.reading')).map(item => { const titleEl = item.querySelector('h4.heading a'); const dateEl = item.querySelector('.datetime'); if (!titleEl || !dateEl) return null; const authors = extractAuthors(item); return { title: titleEl.textContent.trim(), url: 'https://archiveofourown.org' + titleEl.getAttribute('href'), authors: authors, date: dateEl.title || dateEl.textContent.trim() }; }).filter(Boolean); if (readings.length === 0) break; allReadings.push(...readings); updateStatus(`已获取 ${allReadings.length} 条记录...`); page++; } downloadJson(allReadings, 'ao3_reading_history_basic'); updateStatus(`快速导出完成,共 ${allReadings.length} 条记录`); } catch (err) { console.error(err); alert('快速导出失败:' + err.message); updateStatus('导出失败: ' + err.message); } finally { btnEl.disabled = false; btnEl.textContent = originalText; } } // ========== 完整导出 ========== async function exportFullReadingHistory(btnEl) { const originalText = btnEl.textContent; btnEl.disabled = true; btnEl.textContent = '准备中...'; updateStatus('开始完整导出...'); try { const baseUrl = `https://archiveofourown.org/users/${username}/readings`; const parser = new DOMParser(); let allReadings = []; let page = 1; while (true) { const url = page === 1 ? baseUrl : `${baseUrl}?page=${page}`; updateStatus(`正在获取阅读历史第 ${page} 页...`); const res = await fetch(url, { credentials: 'include' }); if (!res.ok) throw new Error(`列表页请求失败: ${res.status}`); const text = await res.text(); const doc = parser.parseFromString(text, 'text/html'); const readings = Array.from(doc.querySelectorAll('li.reading')).map(item => { const titleEl = item.querySelector('h4.heading a'); const dateEl = item.querySelector('.datetime'); if (!titleEl || !dateEl) return null; const authors = extractAuthors(item); return { title: titleEl.textContent.trim(), url: 'https://archiveofourown.org' + titleEl.getAttribute('href'), authors: authors, date: dateEl.title || dateEl.textContent.trim() }; }).filter(Boolean); if (readings.length === 0) break; allReadings.push(...readings); page++; } if (allReadings.length === 0) { alert('未找到任何阅读记录'); updateStatus('未找到阅读记录'); return; } updateStatus(`共找到 ${allReadings.length} 条记录,开始提取详细信息...`); const MAX_CONCURRENT = 6; const DELAY_PER_BATCH = 800; const total = allReadings.length; let completed = 0; const updateProgress = () => { const percent = Math.min(100, Math.round((completed / total) * 100)); btnEl.textContent = `导出中... ${percent}%`; updateStatus(`处理中: ${completed}/${total} (${percent}%)`); }; async function fetchWorkDetails(reading, index) { try { updateStatus(`处理 ${index + 1}/${total}: ${reading.title}`); const workRes = await fetch(reading.url, { credentials: 'include' }); if (!workRes.ok) throw new Error(`HTTP ${workRes.status}`); const workText = await workRes.text(); const workDoc = new DOMParser().parseFromString(workText, 'text/html'); // 提取字数 const words = extractWords(workDoc); // 提取标签 const tags = extractTags(workDoc); // 检测是否给过kudos const kudos_given = detectKudosGiven(workDoc, username); // 检测是否评论过 const workId = extractWorkIdFromUrl(reading.url); const commented = workId ? await detectCommentsGiven(workId, username) : false; return { ...reading, words, tags, kudos_given, commented, _processed: true }; } catch (err) { console.warn(`跳过作品 ${reading.url}:`, err.message); return { ...reading, words: null, tags: [], kudos_given: false, commented: false, _error: err.message }; } finally { completed++; updateProgress(); } } const queue = [...allReadings]; const results = []; let batchNumber = 1; while (queue.length > 0) { updateStatus(`处理批次 ${batchNumber} (剩余 ${queue.length} 条)`); const batch = queue.splice(0, MAX_CONCURRENT); const promises = batch.map((reading, i) => fetchWorkDetails(reading, results.length + i) ); const batchResults = await Promise.all(promises); results.push(...batchResults); if (queue.length > 0) { await new Promise(r => setTimeout(r, DELAY_PER_BATCH)); } batchNumber++; } // 统计结果 const successful = results.filter(r => r._processed && !r._error).length; const failed = results.filter(r => r._error).length; const withKudos = results.filter(r => r.kudos_given).length; const withComments = results.filter(r => r.commented).length; updateStatus(`导出完成: ${successful} 成功, ${failed} 失败, ${withKudos} 个kudos, ${withComments} 条评论`); // 移除调试字段 const cleanResults = results.map(({ _processed, _error, ...rest }) => rest); downloadJson(cleanResults, 'ao3_reading_history_enhanced'); } catch (err) { console.error(err); alert('完整导出失败:' + err.message); updateStatus('导出失败: ' + err.message); } finally { btnEl.disabled = false; btnEl.textContent = originalText; } } // ========== 安全下载函数 ========== function downloadJson(data, prefix) { const jsonStr = JSON.stringify(data, null, 2); const blob = new Blob([jsonStr], { type: 'application/json;charset=utf-8' }); const url = URL.createObjectURL(blob); const now = new Date(); const dateStr = now.toISOString().slice(0, 10); const timeStr = now.toTimeString().slice(0, 8).replace(/:/g, '-'); const filename = `${prefix}_${dateStr}_${timeStr}.json`; const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); const fallbackLink = document.createElement('a'); fallbackLink.href = url; fallbackLink.download = filename; fallbackLink.textContent = `如果未自动下载,请点此保存:${filename}`; fallbackLink.style.cssText = ` position: fixed; bottom: 20px; left: 20px; padding: 10px 16px; background: #ff9800; color: white; border-radius: 4px; z-index: 10000; text-decoration: none; font-family: sans-serif; box-shadow: 0 2px 8px rgba(0,0,0,0.3); `; document.body.appendChild(fallbackLink); setTimeout(() => { if (fallbackLink.parentNode) fallbackLink.parentNode.removeChild(fallbackLink); URL.revokeObjectURL(url); }, 5000); } // ========== 事件绑定 ========== document.getElementById('ao3-export-basic').addEventListener('click', function () { exportBasicReadingHistory(this); }); document.getElementById('ao3-export-full').addEventListener('click', function () { exportFullReadingHistory(this); }); // 添加键盘快捷键 document.addEventListener('keydown', function(e) { if (e.ctrlKey && e.key === 'e') { e.preventDefault(); const fullBtn = document.getElementById('ao3-export-full'); if (fullBtn) fullBtn.click(); } }); console.log('AO3导出器初始化完成'); })();