// ==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();
})();