// ==UserScript==
// @name AO3 阅读历史增强导出器(设备一致版)
// @namespace https://github.com/KWzhabing/ao3-reading-exporter
// @version 2.4
// @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';
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 API 检查是否点过赞 ==========
async function checkKudosGiven(workId, username) {
if (!workId) return false;
try {
// AO3 的 Kudos 列表分页接口(每页最多 20 条)
let page = 1;
while (true) {
const kudosUrl = `https://archiveofourown.org/works/${workId}/kudos?page=${page}`;
const res = await fetch(kudosUrl, { credentials: 'include' });
if (!res.ok) {
// 404 表示无 Kudos 或私有,直接返回 false
if (res.status === 404) return false;
throw new Error(`Kudos 请求失败: ${res.status}`);
}
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
// 检查当前页是否有当前用户
const userLinks = doc.querySelectorAll('ol.kudos li a');
for (const link of userLinks) {
if (link.textContent.trim() === username) {
return true;
}
}
// 如果当前页不足 20 条,说明是最后一页
if (userLinks.length < 20) break;
page++;
}
return false;
} catch (err) {
console.warn(`检查 Kudos 失败 (work_id=${workId}):`, err.message);
return false; // 安静降级
}
}
// ========== 获取评论状态(保持原逻辑,但优化健壮性)==========
async function fetchAllCommentsHtml(workId, username) {
if (!workId) return false;
try {
const commentUrl = `https://archiveofourown.org/comments/show_comments?work_id=${workId}`;
const res = await fetch(commentUrl, { credentials: 'include' });
if (!res.ok) return false;
const html = await res.text();
const lowerHtml = html.toLowerCase();
const lowerUser = username.toLowerCase();
return (
lowerHtml.includes(`>${lowerUser}<`) ||
lowerHtml.includes(`by ${lowerUser}`) ||
lowerHtml.includes(`comment by ${lowerUser}`) ||
lowerHtml.includes(`href="/users/${username}"`)
);
} catch (err) {
console.warn('获取评论失败 (work_id=' + workId + '):', err.message);
return false;
}
}
// ========== 快速导出(不变)==========
async function exportBasicReadingHistory(btnEl) {
const originalText = btnEl.textContent;
btnEl.disabled = true;
btnEl.textContent = '导出中...';
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}`;
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 authorLinks = Array.from(item.querySelectorAll('h4.heading a[rel="author"]'));
const authors = authorLinks.map(a => a.textContent.trim());
return {
title: titleEl.textContent.trim(),
url: 'https://archiveofourown.org' + titleEl.getAttribute('href'),
authors: authors.length > 0 ? authors : ['匿名'],
date: dateEl.title || dateEl.textContent.trim()
};
}).filter(Boolean);
if (readings.length === 0) break;
allReadings.push(...readings);
page++;
}
downloadJson(allReadings, 'ao3_reading_history_basic');
} catch (err) {
console.error(err);
alert('快速导出失败:' + err.message);
} finally {
btnEl.disabled = false;
btnEl.textContent = originalText;
}
}
// ========== 完整导出(关键修改:使用新 Kudos 检查)==========
async function exportFullReadingHistory(btnEl) {
const originalText = btnEl.textContent;
btnEl.disabled = true;
btnEl.textContent = '准备中...';
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}`;
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 authorLinks = Array.from(item.querySelectorAll('h4.heading a[rel="author"]'));
const authors = authorLinks.map(a => a.textContent.trim());
return {
title: titleEl.textContent.trim(),
url: 'https://archiveofourown.org' + titleEl.getAttribute('href'),
authors: authors.length > 0 ? authors : ['匿名'],
date: dateEl.title || dateEl.textContent.trim()
};
}).filter(Boolean);
if (readings.length === 0) break;
allReadings.push(...readings);
page++;
}
if (allReadings.length === 0) {
alert('未找到任何阅读记录');
return;
}
const MAX_CONCURRENT = 4; // 降低并发,避免 429
const DELAY_PER_BATCH = 1000; // 增加延迟,更友好
const total = allReadings.length;
let completed = 0;
const updateProgress = () => {
const percent = Math.min(100, Math.round((completed / total) * 100));
btnEl.textContent = `导出中... ${percent}% (${completed}/${total})`;
};
async function fetchWorkDetails(reading) {
try {
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');
let words = null;
const wordMeta = workDoc.querySelector('dd.words') || [...workDoc.querySelectorAll('p.meta')].find(p => p.textContent.includes('Words:'));
if (wordMeta) {
const match = wordMeta.textContent.match(/[\d,]+/);
if (match) words = parseInt(match[0].replace(/,/g, ''), 10);
}
const tags = Array.from(workDoc.querySelectorAll('.tag'))
.map(el => el.textContent.trim())
.filter(t => t && t !== 'No tags');
// ✅ 使用新方法检查 Kudos
const workId = extractWorkIdFromUrl(reading.url);
const kudos_given = workId ? await checkKudosGiven(workId, username) : false;
const commented = workId ? await fetchAllCommentsHtml(workId, username) : false;
return { ...reading, words, tags, kudos_given, commented };
} catch (err) {
console.warn(`跳过作品 ${reading.url}:`, err.message);
return { ...reading, words: null, tags: [], kudos_given: false, commented: false };
} finally {
completed++;
updateProgress();
}
}
const queue = [...allReadings];
const results = [];
while (queue.length > 0) {
const batch = queue.splice(0, MAX_CONCURRENT);
const promises = batch.map(fetchWorkDetails);
const batchResults = await Promise.all(promises);
results.push(...batchResults);
if (queue.length > 0) {
await new Promise(r => setTimeout(r, DELAY_PER_BATCH));
}
}
downloadJson(results, 'ao3_reading_history_enhanced');
} catch (err) {
console.error(err);
alert('完整导出失败:' + err.message);
} finally {
btnEl.disabled = false;
btnEl.textContent = originalText;
}
}
// ========== 事件绑定 ==========
document.getElementById('ao3-export-basic').addEventListener('click', function () {
exportBasicReadingHistory(this);
});
document.getElementById('ao3-export-full').addEventListener('click', function () {
exportFullReadingHistory(this);
});
// ========== 安全下载函数(不变)==========
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 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);
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);
}
})();