AO3下载文章

AO3下载tag中的文章并打包成压缩包

// ==UserScript==
// @name         AO3下载文章
// @namespace    https://greasyfork.org/users/1384897
// @version      0.2
// @description  AO3下载tag中的文章并打包成压缩包
// @author       ✌
// @match        https://archiveofourown.org/tags/*/works*
// @match        https://archiveofourown.org/works?*
// @grant        GM_xmlhttpRequest
// @grant        GM_download
// @connect      archiveofourown.org
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    const maxWorks = 1000; // 设置最大下载篇数
    const delay = 4000; // 设置页面跳转的延迟,单位:毫秒
    let worksProcessed = Number(localStorage.getItem('worksProcessed')) || 0;
    let zip = new JSZip();
    let isDownloading = false; // 标志变量,是否正在下载
    let downloadInterrupted = false; // 标志变量,用于控制是否中断下载

    // 恢复未完成的 ZIP 进程
    if (localStorage.getItem('ao3ZipData')) {
        const zipData = JSON.parse(localStorage.getItem('ao3ZipData'));
        Object.keys(zipData).forEach(filename => zip.file(filename, zipData[filename]));
    }

    // 创建下载按钮
    const button = document.createElement('button');
    button.innerText = `开始下载`;
    button.style.margin = "10px auto";
    button.style.display = "block";
    button.style.padding = "10px 20px";
    button.style.backgroundColor = "#3498db";
    button.style.color = "#000";
    button.style.border = "none";
    button.style.borderRadius = "5px";
    button.style.cursor = "pointer";
    button.style.fontSize = "16px";
    button.style.textAlign = "center";
    button.style.boxShadow = "0 2px 4px rgba(0, 0, 0, 0.2)";

    // 将按钮插入到 header 中
    const header = document.querySelector('header#header');
    if (header) {
        header.insertAdjacentElement('afterend', button);
    }

    button.addEventListener('click', () => {
        if (isDownloading) {
            // 如果正在下载,则停止下载
            finalizeDownloadPartial(true);
            downloadInterrupted = true;
            console.log('下载已暂停');
            button.innerText = '开始下载';

            localStorage.clear();
            worksProcessed = 0;
            isDownloading = false;
            location.reload();
        } else {
            // 如果没有在下载,则开始下载
            downloadInterrupted = false;
            startDownload();
        }
    });

    // 自动启动下载(用于翻页后的页面)
    if (localStorage.getItem('worksProcessed')) {
        startDownload();
    }

    function startDownload() {
        console.log(`开始下载最多 ${maxWorks} 篇作品...`);
        isDownloading = true;
        button.innerText = `下载中 - 进度:${worksProcessed}/${maxWorks}`;
        updateButtonProgress();
        processPage(window.location.href);
    }

    function processWorksWithDelay(links, index = 0) {
        if (downloadInterrupted) {
            isDownloading = false;
            console.log('下载已中断');
            return;
        }

        if (index >= links.length || worksProcessed >= maxWorks) {
            checkForNextPage(document);
            return;
        }

        const link = links[index];
        GM_xmlhttpRequest({
            method: 'GET',
            url: link,
            onload: response => {
                if (downloadInterrupted) {
                    isDownloading = false;
                    console.log('下载已中断');
                    return;
                }

                const parser = new DOMParser();
                const doc = parser.parseFromString(response.responseText, "text/html");

                const title = doc.querySelector('h2.title').innerText.trim();
                let authorElement = doc.querySelector('a[rel="author"]');
                const author = authorElement ? authorElement.innerText.trim() : "匿名";
                const contentElement = doc.querySelector('#workskin');
                const content = contentElement ? contentElement.innerHTML : "<p>内容不可用</p>";

                const htmlContent = `
                    <!DOCTYPE html>
                    <html lang="en">
                    <head>
                        <meta charset="UTF-8">
                        <title>${title} by ${author}</title>
                    </head>
                    <body>
                        <h1>${title}</h1>
                        <h2>by ${author}</h2>
                        ${content}
                    </body>
                    </html>
                `;

                const filename = `${title} - ${author}.html`.replace(/[\/:*?"<>|]/g, '');
                zip.file(filename, htmlContent);

                try {
                    const zipData = JSON.parse(localStorage.getItem('ao3ZipData')) || {};
                    zipData[filename] = htmlContent;
                    localStorage.setItem('ao3ZipData', JSON.stringify(zipData));
                } catch (e) {
                    if (e.name === 'QuotaExceededError') {
                        console.warn('存储空间已满,立即导出并清空。');
                        finalizeDownloadPartial(true); // 强制导出当前部分
                    } else {
                        console.error('存储时出错:', e);
                    }
                }

                worksProcessed++;
                localStorage.setItem('worksProcessed', worksProcessed);
                console.log(`已处理 ${worksProcessed}/${maxWorks}: ${title} by ${author}`);
                updateButtonProgress();

                // 每100篇下载一个ZIP包
                if (worksProcessed % 100 === 0) {
                    finalizeDownloadPartial();
                }

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

    function processPage(url) {
        GM_xmlhttpRequest({
            method: 'GET',
            url: url,
            onload: response => {
                const parser = new DOMParser();
                const doc = parser.parseFromString(response.responseText, "text/html");
                const links = Array.from(doc.querySelectorAll('h4.heading a'))
                                   .filter(link => link.getAttribute("href").includes("/works/"))
                                   .map(link => `${new URL(link.getAttribute('href'), window.location.origin)}?view_adult=true&view_full_work=true`);

                console.log(`正在处理页面,共有 ${links.length} 篇作品...`);
                processWorksWithDelay(links);
            },
            onerror: () => {
                console.error(`加载页面失败: ${url}`);
            }
        });
    }

    function checkForNextPage(doc) {
        if (worksProcessed >= maxWorks || downloadInterrupted) {
            finalizeDownload();
            return;
        }

        const nextLink = document.querySelector('a[rel="next"]');

        if (nextLink) {
            const nextPageUrl = new URL(nextLink.getAttribute('href'), window.location.origin).toString();
            console.log("找到下一页链接:", nextPageUrl);
            window.location.href = nextPageUrl;
        } else {
            console.log("未找到下一页链接,结束下载");
            finalizeDownload();
        }
    }

    function finalizeDownloadPartial(forceDownload = false) {
        console.log(`生成部分 ZIP 文件,包含 ${forceDownload ? worksProcessed % 100 : 100} 篇作品...`);
        zip.generateAsync({ type: "blob" }).then(blob => {
            const partNumber = Math.ceil(worksProcessed / 100);
            GM_download({
                url: URL.createObjectURL(blob),
                name: `AO3_Works_Part_${partNumber}.zip`,
                saveAs: true
            });

            zip = new JSZip();
            localStorage.removeItem('ao3ZipData');
        }).catch(err => console.error("生成部分 ZIP 时出错:", err));
    }

    function finalizeDownload() {
        if (worksProcessed % 100 !== 0) {
            finalizeDownloadPartial(true);
        }
        console.log("所有作品已处理,下载完成。");

        localStorage.clear();
        worksProcessed = 0;
        isDownloading = false;
        location.reload();
    }

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