Greasy Fork is available in English.
批量下载 AO3 作品为 EPUB,支持 tag 页、作者页、详情页,可自动翻页,可自定义文件名格式
当前为
// ==UserScript==
// @name AO3 Helper
// @description 批量下载 AO3 作品为 EPUB,支持 tag 页、作者页、详情页,可自动翻页,可自定义文件名格式
// @namespace http://tampermonkey.net/
// @version 1.4.0
// @author Lumiarna
// @match http*://archiveofourown.org/*
// @grant GM_xmlhttpRequest
// @connect archiveofourown.org
// @connect download.archiveofourown.org
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const DOWNLOAD_BASE = 'https://download.archiveofourown.org';
const KEYS = {
maxWorks: 'ao3_helper_maxWorks',
processed: 'ao3_helper_worksProcessed',
stopFlag: 'ao3_helper_stopFlag',
originUrl: 'ao3_helper_downloadOriginUrl',
filenameFormat: 'ao3_helper_filenameFormat',
};
let maxWorks = Number(localStorage.getItem(KEYS.maxWorks)) || 1000;
let worksProcessed = Number(localStorage.getItem(KEYS.processed)) || 0;
const delay = 4000;
let isDownloading = false;
let downloadInterrupted = false;
// ────── UI ──────
const host = document.createElement('div');
host.id = 'ao3-helper-host';
document.body.append(host);
const shadow = host.attachShadow({ mode: 'closed' });
shadow.innerHTML = `<style>
.ao3-helper-btn {
position: fixed;
right: 10px;
top: 90px;
z-index: 999999;
padding: 8px 14px;
border: none;
border-radius: 6px;
background: #1e90ff;
color: #fff;
font-size: 13px;
font-weight: 500;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,.15);
transition: opacity .2s, transform .15s;
white-space: nowrap;
}
.ao3-helper-btn:hover { opacity: .9; transform: translateY(-1px); }
.ao3-helper-btn:active { transform: translateY(0); }
.ao3-helper-btn:disabled { opacity: .6; cursor: not-allowed; }
.ao3-gear-btn {
position: fixed;
right: 10px;
top: 130px;
z-index: 999999;
padding: 6px 10px;
border: none;
border-radius: 6px;
background: #555;
color: #fff;
font-size: 16px;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,.15);
transition: opacity .2s, transform .3s;
line-height: 1;
}
.ao3-gear-btn:hover { opacity: .85; transform: rotate(45deg); }
.ao3-settings-modal {
display: none;
position: fixed;
right: 60px;
top: 125px;
z-index: 9999999;
background: #fff;
border: 1px solid #ddd;
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0,0,0,.2);
padding: 16px;
min-width: 240px;
font-size: 13px;
color: #333;
}
.ao3-settings-modal.open { display: block; }
.ao3-settings-modal h3 {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
border-bottom: 1px solid #eee;
padding-bottom: 8px;
}
.ao3-settings-modal label {
display: block;
margin-bottom: 4px;
font-weight: 500;
color: #555;
}
.ao3-settings-modal input {
width: 100%;
box-sizing: border-box;
padding: 5px 8px;
border: 1px solid #ccc;
border-radius: 5px;
font-size: 13px;
margin-bottom: 10px;
}
.ao3-settings-modal .ao3-settings-note {
font-size: 11px;
color: #999;
margin-top: -8px;
margin-bottom: 10px;
}
.ao3-settings-save {
width: 100%;
padding: 6px;
background: #1e90ff;
color: #fff;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
}
.ao3-settings-save:hover { opacity: .9; }
</style>`;
const button = document.createElement('button');
button.className = 'ao3-helper-btn';
button.innerText = '开始下载';
shadow.append(button);
const gearBtn = document.createElement('button');
gearBtn.className = 'ao3-gear-btn';
gearBtn.title = '下载设置';
gearBtn.textContent = '⚙';
shadow.append(gearBtn);
const modal = document.createElement('div');
modal.className = 'ao3-settings-modal';
modal.innerHTML = `
<h3>下载设置</h3>
<label>最大下载数量</label>
<input id="ao3-max-input" type="number" min="1" max="99999" />
<label>文件名格式</label>
<input id="ao3-format-input" type="text" placeholder="{title}_{author}" />
<div class="ao3-settings-note">可用:{title} {author} {workId}</div>
<button class="ao3-settings-save" id="ao3-settings-save">保存</button>
`;
shadow.append(modal);
modal.querySelector('#ao3-max-input').value = maxWorks;
modal.querySelector('#ao3-format-input').value = localStorage.getItem(KEYS.filenameFormat) || '';
gearBtn.addEventListener('click', () => {
modal.classList.toggle('open');
});
modal.querySelector('#ao3-settings-save').addEventListener('click', () => {
const max = parseInt(modal.querySelector('#ao3-max-input').value, 10);
if (max > 0) {
maxWorks = max;
localStorage.setItem(KEYS.maxWorks, max);
}
const fmt = modal.querySelector('#ao3-format-input').value.trim();
if (fmt) localStorage.setItem(KEYS.filenameFormat, fmt);
else localStorage.removeItem(KEYS.filenameFormat);
modal.classList.remove('open');
if (isDownloading) updateButtonProgress();
});
document.addEventListener('click', (e) => {
if (!modal.classList.contains('open')) return;
if (!e.composedPath().includes(host)) {
modal.classList.remove('open');
}
});
// ────── 公共工具函数 ──────
const sanitize = s => s.replace(/[\/:*?"<>|]/g, '');
function extractWorkInfo(doc) {
const title = doc.querySelector('h2.title')?.textContent.trim() || '无标题';
const author = doc.querySelector('a[rel="author"]')?.textContent.trim() || 'Anonymous';
const epubHref = doc.querySelector('li.download ul a[href*=".epub"]')?.getAttribute('href');
if (!epubHref) return null;
const workId = (epubHref.match(/\/downloads\/(\d+)/) || [])[1] || '';
const fmt = localStorage.getItem(KEYS.filenameFormat) || '{title}_{author}';
const filename = sanitize(fmt.replace('{title}', title).replace('{author}', author).replace('{workId}', workId)) + '.epub';
return { filename, epubUrl: `${DOWNLOAD_BASE}${epubHref}` };
}
function gmFetch(opts, label) {
return new Promise((resolve, reject) => {
let attempt = 0;
(function tryOnce() {
if (downloadInterrupted) return reject(new Error('interrupted'));
GM_xmlhttpRequest({
...opts,
onload: resolve,
onerror: () => {
attempt++;
const wait = Math.min(attempt * 5, 60);
button.innerText = `${label} 重试 #${attempt}(${wait}s 后)`;
setTimeout(tryOnce, wait * 1000);
},
});
})();
});
}
function downloadEpub(url, filename) {
return gmFetch({ method: 'GET', url, responseType: 'blob' }, `下载 ${filename}`)
.then(res => saveBlob(res.response, filename));
}
function saveBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
function updateButtonProgress() {
button.innerText = `下载中 - 进度:${worksProcessed}/${maxWorks}`;
}
// ────── 单篇下载 ──────
function downloadCurrentWork() {
const info = extractWorkInfo(document);
if (!info) {
alert('未找到epub下载链接');
return;
}
button.innerText = '下载中...';
button.disabled = true;
downloadEpub(info.epubUrl, info.filename).then(() => {
button.innerText = '下载完成';
button.disabled = false;
});
}
// ────── 批量下载 ──────
function startDownload() {
worksProcessed = 0;
localStorage.removeItem(KEYS.processed);
console.log(`开始下载最多 ${maxWorks} 篇作品...`);
isDownloading = true;
updateButtonProgress();
processDoc(document);
}
function processDoc(doc) {
const workLinks = Array.from(doc.querySelectorAll('li.work.blurb h4.heading a'))
.filter(a => /\/works\/\d+$/.test(a.pathname))
.map(a => `${a.href}?view_adult=true`);
processWorksWithDelay(workLinks, 0, doc);
}
async function processWorksWithDelay(workLinks, index = 0, pageDoc) {
for (let i = index; i < workLinks.length && !downloadInterrupted && worksProcessed < maxWorks; i++) {
const response = await gmFetch({ method: 'GET', url: workLinks[i] }, `加载作品 ${i + 1}/${workLinks.length}`);
if (downloadInterrupted) return;
const doc = new DOMParser().parseFromString(response.responseText, 'text/html');
const info = extractWorkInfo(doc);
if (info) {
await downloadEpub(info.epubUrl, info.filename);
worksProcessed++;
localStorage.setItem(KEYS.processed, worksProcessed);
}
updateButtonProgress();
await new Promise(r => setTimeout(r, delay));
}
checkForNextPage(pageDoc);
}
function checkForNextPage(doc) {
if (worksProcessed >= maxWorks || downloadInterrupted) {
completeAndReset();
return;
}
const nextLink = doc.querySelector('li.next a');
if (nextLink) {
const nextPageUrl = new URL(nextLink.href, window.location.origin).toString();
console.log('跳转下一页:', nextPageUrl);
localStorage.setItem(KEYS.originUrl, nextPageUrl);
window.location.href = nextPageUrl;
} else {
completeAndReset();
}
}
function completeAndReset() {
console.log('下载完成,清空记录。');
localStorage.removeItem(KEYS.processed);
localStorage.removeItem(KEYS.stopFlag);
localStorage.removeItem(KEYS.originUrl);
worksProcessed = 0;
isDownloading = false;
location.reload();
}
// ────── 入口 ──────
const isSingleWork = /\/works\/\d+/.test(window.location.pathname);
button.addEventListener('click', () => {
if (isSingleWork) {
downloadCurrentWork();
return;
}
if (isDownloading) {
downloadInterrupted = true;
button.innerText = '开始下载';
localStorage.setItem(KEYS.stopFlag, 'true');
localStorage.removeItem(KEYS.processed);
worksProcessed = 0;
isDownloading = false;
location.reload();
} else {
localStorage.removeItem(KEYS.stopFlag);
downloadInterrupted = false;
startDownload();
}
});
const savedUrl = localStorage.getItem(KEYS.originUrl);
if (savedUrl && savedUrl === window.location.href && localStorage.getItem(KEYS.processed) && localStorage.getItem(KEYS.stopFlag) !== 'true') {
isDownloading = true;
updateButtonProgress();
processDoc(document);
}
})();