// ==UserScript== // @name AO3 Helper // @name:zh-CN AO3 助手 // @description Batch download AO3 works as EPUB, supports tag/author/work pages with auto-pagination // @description:zh-CN 批量下载 AO3 作品为 EPUB,支持 tag 页、作者页、详情页,自动翻页 // @namespace http://tampermonkey.net/ // @version 1.2 // @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', }; 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 style = document.createElement('style'); style.textContent = ` .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 { 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 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; } `; document.head.appendChild(style); const button = document.createElement('button'); button.className = 'ao3-helper-btn'; button.innerText = '开始下载'; document.body.appendChild(button); const gearBtn = document.createElement('button'); gearBtn.className = 'ao3-gear-btn'; gearBtn.title = '下载设置'; gearBtn.textContent = '⚙'; document.body.appendChild(gearBtn); const modal = document.createElement('div'); modal.className = 'ao3-settings-modal'; modal.style.display = 'none'; modal.innerHTML = `

下载设置

`; document.body.appendChild(modal); modal.querySelector('#ao3-max-input').value = maxWorks; gearBtn.addEventListener('click', (e) => { e.stopPropagation(); modal.style.display = modal.style.display === 'none' ? 'block' : 'none'; }); 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); } modal.style.display = 'none'; if (isDownloading) updateButtonProgress(); }); document.addEventListener('click', (e) => { if (modal.style.display === 'none') return; if (!modal.contains(e.target) && e.target !== gearBtn) { modal.style.display = 'none'; } }); // ────── 公共工具函数 ────── 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; return { filename: `${sanitize(title)}_${sanitize(author)}.epub`, epubUrl: `${DOWNLOAD_BASE}${epubHref}`, }; } function gmFetch(opts, label) { return new Promise((resolve) => { let attempt = 0; (function tryOnce() { if (downloadInterrupted) return; 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) { 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); } })();