AO3 Helper

批量下载 AO3 作品为 EPUB,支持 tag 页、作者页、详情页,自动翻页

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         AO3 Helper
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  批量下载 AO3 作品为 EPUB,支持 tag 页、作者页、详情页,自动翻页
// @author       Lumiarna
// @match        https://archiveofourown.org/tags/*/works*
// @match        https://archiveofourown.org/works?*
// @match        https://archiveofourown.org/*
// @grant        GM_xmlhttpRequest
// @connect      archiveofourown.org
// @connect      download.archiveofourown.org
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    let maxWorks = Number(localStorage.getItem('ao3_helper_maxWorks')) || 1000;
    let worksProcessed = Number(localStorage.getItem('ao3_helper_worksProcessed')) || 0;
    const delay = 4000;
    let isDownloading = false;
    let downloadInterrupted = false;

    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 = `
      <h3>下载设置</h3>
      <label>最大下载数量</label>
      <input id="ao3-max-input" type="number" min="1" max="99999" />
      <button class="ao3-settings-save" id="ao3-settings-save">保存</button>
    `;
    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('ao3_helper_maxWorks', max);
        }
        modal.style.display = 'none';
        if (isDownloading) updateButtonProgress();
    });

    document.addEventListener('click', (e) => {
        if (!modal.contains(e.target) && e.target !== gearBtn) {
            modal.style.display = 'none';
        }
    });

    const isSingleWork = /\/works\/\d+/.test(window.location.pathname);

    button.addEventListener('click', () => {
        if (isSingleWork) {
            downloadCurrentWork();
            return;
        }
        if (isDownloading) {
            downloadInterrupted = true;
            button.innerText = '开始下载';
            localStorage.setItem('ao3_helper_stopFlag', 'true');
            localStorage.removeItem('ao3_helper_worksProcessed');
            worksProcessed = 0;
            isDownloading = false;
            location.reload();
        } else {
            localStorage.removeItem('ao3_helper_stopFlag');
            downloadInterrupted = false;
            startDownload();
        }
    });

    const savedUrl = localStorage.getItem('ao3_helper_downloadOriginUrl');
    if (savedUrl && savedUrl === window.location.href && localStorage.getItem('ao3_helper_worksProcessed') && localStorage.getItem('ao3_helper_stopFlag') !== 'true') {
        isDownloading = true;
        updateButtonProgress();
        processDoc(document);
    }

    function downloadCurrentWork() {
        const title = document.querySelector('h2.title')?.textContent.trim() || '无标题';
        const author = document.querySelector('a[rel="author"]')?.textContent.trim() || 'Anonymous';
        const epubHref = document.querySelector('li.download ul a[href*=".epub"]')?.getAttribute('href');
        if (!epubHref) {
            alert('未找到epub下载链接');
            return;
        }
        const safeTitle = title.replace(/[\/:*?"<>|]/g, '');
        const safeAuthor = author.replace(/[\/:*?"<>|]/g, '');
        const filename = `${safeTitle}_${safeAuthor}.epub`;
        const epubUrl = `https://download.archiveofourown.org${epubHref}`;
        button.innerText = '下载中...';
        button.disabled = true;
        GM_xmlhttpRequest({
            method: 'GET',
            url: epubUrl,
            responseType: 'blob',
            onload: res => {
                saveBlob(res.response, filename).then(() => {
                    button.innerText = '下载完成';
                    button.disabled = false;
                });
            },
            onerror: () => {
                button.innerText = '下载失败';
                button.disabled = false;
            }
        });
    }

    function startDownload() {
        worksProcessed = 0;
        localStorage.removeItem('ao3_helper_worksProcessed');
        console.log(`开始下载最多 ${maxWorks} 篇作品...`);
        isDownloading = true;
        updateButtonProgress();
        processDoc(document);
    }

    function processWorksWithDelay(workLinks, index = 0, pageDoc) {
        if (downloadInterrupted || index >= workLinks.length || worksProcessed >= maxWorks) {
            checkForNextPage(pageDoc);
            return;
        }

        const { workUrl } = workLinks[index];
        GM_xmlhttpRequest({
            method: 'GET',
            url: workUrl,
            onload: response => {
                if (downloadInterrupted) return;

                const parser = new DOMParser();
                const doc = parser.parseFromString(response.responseText, "text/html");
                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) {
                    setTimeout(() => processWorksWithDelay(workLinks, index + 1, pageDoc), delay);
                    return;
                }

                const safeTitle = title.replace(/[\/:*?"<>|]/g, '');
                const safeAuthor = author.replace(/[\/:*?"<>|]/g, '');
                const filename = `${safeTitle}_${safeAuthor}.epub`;
                const epubUrl = `https://download.archiveofourown.org${epubHref}`;

                GM_xmlhttpRequest({
                    method: 'GET',
                    url: epubUrl,
                    responseType: 'blob',
                    onload: res => { saveBlob(res.response, filename); },
                    onerror: e => console.error(`[AO3] 下载失败: ${filename}`, e)
                });

                worksProcessed++;
                localStorage.setItem('ao3_helper_worksProcessed', worksProcessed);
                updateButtonProgress();

                setTimeout(() => processWorksWithDelay(workLinks, index + 1, pageDoc), delay);
            },
            onerror: () => {
                console.error(`加载内容失败: ${workUrl}`);
                setTimeout(() => processWorksWithDelay(workLinks, index + 1, pageDoc), delay);
            }
        });
    }

    function processDoc(doc) {
        const workLinks = Array.from(doc.querySelectorAll('h4.heading a'))
            .filter(a => /\/works\/\d+$/.test(a.pathname))
            .map(a => ({ workUrl: `${a.href}?view_adult=true` }));
        processWorksWithDelay(workLinks, 0, doc);
    }

    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('ao3_helper_downloadOriginUrl', nextPageUrl);
            window.location.href = nextPageUrl;
        } else {
            completeAndReset();
        }
    }

    function completeAndReset() {
        console.log('下载完成,清空记录。');
        localStorage.removeItem('ao3_helper_worksProcessed');
        localStorage.removeItem('ao3_helper_stopFlag');
        localStorage.removeItem('ao3_helper_downloadOriginUrl');
        worksProcessed = 0;
        isDownloading = false;
        location.reload();
    }

    function updateButtonProgress() {
        button.innerText = `下载中 - 进度:${worksProcessed}/${maxWorks}`;
    }

    function saveBlob(blob, filename) {
        const a = document.createElement('a');
        a.href = URL.createObjectURL(blob);
        a.download = filename;
        a.click();
        URL.revokeObjectURL(a.href);
    }
})();