// ==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 // @downloadURL none // ==/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 = ``; 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 = `

下载设置

可用:{title} {author} {workId}
`; 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); } })();