[E/Ex-Hentai] Downloader

在 E 和 Ex 的漫畫頁面, 創建下載按鈕, 可使用[壓縮下載/單圖下載], 自動獲取圖片下載

Ekde 2023/08/27. Vidu La ĝisdata versio.

// ==UserScript==
// @name         [E/Ex-Hentai] Downloader
// @name:zh-TW   [E/Ex-Hentai] 下載器
// @name:zh-CN   [E/Ex-Hentai] 下载器
// @name:ja      [E/Ex-Hentai] ダウンローダー
// @name:ko      [E/Ex-Hentai] 다운로더
// @name:en      [E/Ex-Hentai] Downloader
// @version      0.0.9
// @author       HentiSaru
// @description         在 E 和 Ex 的漫畫頁面, 創建下載按鈕, 可使用[壓縮下載/單圖下載], 自動獲取圖片下載
// @description:zh-TW   在 E 和 Ex 的漫畫頁面, 創建下載按鈕, 可使用[壓縮下載/單圖下載], 自動獲取圖片下載
// @description:zh-CN   在 E 和 Ex 的漫画页面, 创建下载按钮, 可使用[压缩下载/单图下载], 自动获取图片下载
// @description:ja      EとExの漫画ページで、ダウンロードボタンを作成し、[圧縮ダウンロード/単一画像ダウンロード]を使用して、自動的に画像をダウンロードします。
// @description:ko      E 및 Ex의 만화 페이지에서 다운로드 버튼을 만들고, [압축 다운로드/단일 이미지 다운로드]를 사용하여 이미지를 자동으로 다운로드합니다.
// @description:en      On the comic pages of E and Ex, create a download button that can use [compressed download/single image download] to automatically download images.

// @match        https://e-hentai.org/*
// @match        https://exhentai.org/*
// @icon         https://e-hentai.org/favicon.ico

// @license      MIT
// @namespace    https://greasyfork.org/users/989635

// @run-at       document-end
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_download
// @grant        GM_addElement
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand

// @require      https://greasyfork.org/scripts/473358-jszip/code/JSZip.js?version=1237031
// @require      https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// ==/UserScript==

(function() {
    const Ex_HManga = /https:\/\/exhentai\.org\/g\/\d+\/[a-zA-Z0-9]+/;
    const E_HManga = /https:\/\/e-hentai\.org\/g\/\d+\/[a-zA-Z0-9]+/;
    var count = 0, ModeDisplay,
    parser = new DOMParser(),
    OriginalTitle = document.title,
    url = window.location.href.split("?p=")[0],
    CompressMode = GM_getValue("CompressedMode", []),
    language = display_language(navigator.language);

    /* @===== 可調設置 =====@ */

    let DeBug = false, Experimental = true;
    const Delay = {
        Home: 100, // 主頁數據獲取延遲
        Image: 30, // 圖片連結獲取延遲
        Download: 300, // 下載速度延遲
    }

    /* @===== 運行入口 =====@ */

    /* 判斷創建的網址格式 */
    if (Ex_HManga.test(url) || E_HManga.test(url)) {
        ButtonCreation();
    }
    /* 創建菜單 */
    GM_registerMenuCommand(language.MN_01, function() {DownloadModeSwitch()}, "C");

    /* @===== 按鈕創建 =====@ */

    async function ButtonCreation() {
        let download_button;
        GM_addStyle(`
            .Download_Button {
                float: right;
                width: 9rem;
                cursor: pointer;
                font-weight: bold;
                line-height: 20px;
                border-radius: 5px;
                position: relative;
                padding: 1px 5px 2px;
                font-family: arial,helvetica,sans-serif;
            }
        `);
        // 自適應樣式
        AdaptiveCSS(`
            .Download_Button {
                color: #5C0D12;
                border: 2px solid #9a7c7e;
                background-color: #EDEADA;
            }
            .Download_Button:hover {
                color: #8f4701;
                border: 2px dashed #B5A4A4;
            }
            .Download_Button:disabled {
                color: #B5A4A4;
                border: 2px dashed #B5A4A4;
                cursor: default;
            }
            `,`
            .Download_Button {
                color: #b3b3b3;
                border: 2px solid #34353b;
                background-color: #2c2b2b;
            }
            .Download_Button:hover {
                color: #f1f1f1;
                border: 2px dashed #4f535b;
            }
            .Download_Button:disabled {
                color: #4f535b;
                border: 2px dashed #4f535b;
                cursor: default;
            }
        `);
        try {
            download_button = GM_addElement(document.querySelector("div#gd2"), "button", {class: "Download_Button"});
            if (CompressMode) {
                ModeDisplay = language.DM_01;
            } else {
                ModeDisplay = language.DM_02;
            }
            download_button.textContent = ModeDisplay;
            download_button.addEventListener("click", function() {
                download_button.textContent = language.DS_01;
                download_button.disabled = true;
                HomeDataProcessing(download_button);
            });
        } catch {}
    }

    /* @===== 數據處理 =====@ */

    /* 非法字元排除 */
    function IllegalFilter(Name) {
        return Name.replace(/[\/\?<>\\:\*\|":]/g, '');
    }

    /* 取得總頁數 */
    function GetTotal(page) {
        return parseInt(page[page.length - 2].textContent.replace(/\D/g, ''));
    }

    /* 圖片擴展名 */
    function GetExtension(link) {
        try {
            const match = link.match(/\.([^.]+)$/);
            return match[1].toLowerCase() || "png";
        } catch {return "png"}
    }

    /* 主頁數據處理 */
    async function HomeDataProcessing(button) {
        let title, homepage = new Map(), task = 0, DC = 1,
        pages = GetTotal(document.querySelector("div#gdd").querySelectorAll("td.gdt2"));
        title = document.getElementById("gj").textContent.trim() || document.getElementById("gn").textContent.trim();
        title = IllegalFilter(title);
        pages = Math.ceil(pages / 20);

        if (Experimental) {
            const worker = BackWorkerCreation(`
                let queue = [], processing = false;
                onmessage = function(e) {
                    const {index, url} = e.data;
                    queue.push({index, url});

                    if (!processing) {
                        processQueue();
                        processing = true;
                    }
                }

                async function processQueue() {
                    if (queue.length > 0) {
                        const {index, url} = queue.shift();
                        FetchRequest(index, url);
                        setTimeout(processQueue, ${Delay.Home});
                    }
                }

                async function FetchRequest(index, url) {
                    try {
                        const response = await fetch(url);
                        const html = await response.text();
                        postMessage({index, html, error: false});
                    } catch {
                        postMessage({index, url, error: true});
                    }
                }
            `)

            // 傳遞訊息
            worker.postMessage({index: 0, url: url});
            for (let index = 1; index < pages; index++) {
                worker.postMessage({index, url: `${url}?p=${index}`});
            }

            // 接受訊息
            worker.onmessage = function(e) {
                const {index, html, error} = e.data;
                if (!error) {GetLink(index, parser.parseFromString(html, "text/html"))}
                else {FetchRequest(index, html, 10)}
            }

            // 獲取連結
            async function GetLink(index, data) {
                const homebox = [];
                data.querySelector("#gdt").querySelectorAll("a").forEach(link => {
                    homebox.push(link.href)
                });
                homepage.set(index, homebox);
                button.textContent = `${language.DS_02}: [${DC}/${pages}]`;

                DC++; // 顯示效正
                task++; // 任務進度
            }

            // 數據試錯請求
            async function FetchRequest(index, url, retry) {
                try {
                    const response = await fetch(url);
                    const html = await response.text();
                    await GetLink(index, parser.parseFromString(html, "text/html"));
                } catch {
                    if (retry > 0) {
                        await FetchRequest(index, url, retry-1);
                    } else {
                        task++;
                    }
                }
            }

            // 等待全部處理完成 (雖然會吃資源, 但是比較能避免例外)
            let interval = setInterval(() => {
                if (task === pages) {
                    clearInterval(interval);
                    worker.terminate();
                    const homebox = [];
                    for (let i = 0; i < homepage.size; i++) {
                        homebox.push(...homepage.get(i));
                    }

                    if (DeBug) {
                        console.groupCollapsed("Home Page Data");
                        console.log(`[Title] : ${title}`);
                        console.log(homebox);
                        console.groupEnd();
                    }
                    ImageLinkProcessing(button, title, homebox);
                }
            }, 300);

        } else { // 舊處理
            async function GetLink(index, data) { // 獲取頁面所有連結
                const homebox = [];
                data.querySelector("#gdt").querySelectorAll("a").forEach(link => {
                    homebox.push(link.href)
                });
                homepage.set(index, homebox); // 確保索引順序
            }

            async function FetchRequest(index, url) { // 數據請求
                const response = await fetch(url);
                const html = await response.text();
                await GetLink(index, parser.parseFromString(html, "text/html"));
            }

            const promises = [FetchRequest(0, url)];
            for (let index = 1; index < pages; index++) {
                promises.push(FetchRequest(index, `${url}?p=${index}`));
                button.textContent = `${language.DS_02}: [${index+1}/${pages}]`;
                await new Promise(resolve => setTimeout(resolve, Delay.Home));
            }
            await Promise.allSettled(promises);

            const homebox = [];
            for (let i = 0; i < homepage.size; i++) {
                homebox.push(...homepage.get(i));
            }

            if (DeBug) {
                console.groupCollapsed("Home Page Data");
                console.log(`[Title] : ${title}`);
                console.log(homebox);
                console.groupEnd();
            }
            ImageLinkProcessing(button, title, homebox);
        }
    }

    /* 漫畫連結處理 */
    async function ImageLinkProcessing(button, title, link) {
        let imgbox = new Map(), pages = link.length, DC = 1, task = 0;

        if (Experimental) {
            const worker = BackWorkerCreation(`
                let queue = [], processing = false;
                onmessage = function(e) {
                    const {index, url} = e.data;
                    queue.push({index, url});

                    if (!processing) {
                        processQueue();
                        processing = true;
                    }
                }

                async function processQueue() {
                    if (queue.length > 0) {
                        const {index, url} = queue.shift();
                        FetchRequest(index, url);
                        setTimeout(processQueue, ${Delay.Image});
                    }
                }

                async function FetchRequest(index, url) {
                    try {
                        const response = await fetch(url);
                        const html = await response.text();
                        postMessage({index, html, error: false});
                    } catch {
                        postMessage({index, url, error: true});
                    }
                }
            `)

            // 傳遞訊息
            for (let index = 0; index < pages; index++) {
                worker.postMessage({index, url: link[index]});
            }

            // 接收回傳
            worker.onmessage = function(e) {
                const {index, html, error} = e.data;
                if (!error) {GetLink(index, parser.parseFromString(html, "text/html").querySelector("img#img"))}
                else {FetchRequest(index, html, 10)}
            }

            // 獲取連結
            async function GetLink(index, data) {
                try {
                    imgbox.set(index, data.src);
                    button.textContent = `${language.DS_03}: [${DC}/${pages}]`;
                } catch {
                    try {
                        imgbox.set(index, data.href);
                        button.textContent = `${language.DS_03}: [${DC}/${pages}]`;
                    } catch {}
                }

                DC++; // 顯示效正
                task++; // 任務進度
            }

            // 數據試錯請求
            async function FetchRequest(index, url, retry) {
                try {
                    const response = await fetch(url);
                    const html = await response.text();
                    await GetLink(index, parser.parseFromString(html, "text/html").querySelector("img#img"));
                } catch {
                    if (retry > 0) {
                        await FetchRequest(index, url, retry-1);
                    } else {
                        task++;
                    }
                }
            }

            // 等待完成
            let interval = setInterval(() => {
                if (task === pages) {
                    clearInterval(interval);
                    worker.terminate();
                    if (DeBug) {
                        console.groupCollapsed("Img Link Data");
                        console.log(imgbox);
                        console.groupEnd();
                    }
                    DownloadTrigger(button, title, imgbox);
                }
            }, 300);

        } else { // 舊處理
            async function GetLink(index, data) {
                try {
                    imgbox.set(index, data.src);
                    button.textContent = `${language.DS_03}: [${index + 1}/${pages}]`;
                } catch {
                    try {
                        imgbox.set(index, data.href);
                        button.textContent = `${language.DS_03}: [${index + 1}/${pages}]`;
                    } catch {}
                }
            }

            async function FetchRequest(index, url) {
                try {
                    const response = await fetch(url);
                    const html = await response.text();
                    await GetLink(index, parser.parseFromString(html, "text/html").querySelector("img#img"));
                } catch (error) {
                    await FetchRequest(index, url);
                }
            }

            const promises = [];
            for (let index = 0; index < pages; index++) {
                promises.push(FetchRequest(index, link[index]));
                await new Promise(resolve => setTimeout(resolve, Delay.Image));
            }

            await Promise.allSettled(promises);
            if (DeBug) {
                console.groupCollapsed("Img Link Data");
                console.log(imgbox);
                console.groupEnd();
            }
            DownloadTrigger(button, title, imgbox);
        }
    }

    /* @===== 下載處理 =====@ */

    /* 下載觸發器 */
    async function DownloadTrigger(button, title, link) {
        if (CompressMode) {ZipDownload(button, title, link)}
        else {ImageDownload(button, title, link)}
    }

    /* 壓縮下載 */
    async function ZipDownload(Button, Folder, ImgData) {
        const zip = new JSZip(), Total = ImgData.size, promises = [];
        let progress = 1, link, mantissa, extension;
        async function Request(index, retry) {
            link = ImgData.get(index);
            extension = GetExtension(link);
            return new Promise((resolve, reject) => {
                if (typeof link !== "undefined") {
                    GM_xmlhttpRequest({
                        method: "GET",
                        url: link,
                        responseType: "blob",
                        headers : {"user-agent": navigator.userAgent},
                        onload: response => {
                            if (response.status === 200 && response.response instanceof Blob && response.response.size > 0) {
                                mantissa = (index + 1).toString().padStart(4, '0');
                                zip.file(`${Folder}/${mantissa}.${extension}`, response.response);
                                document.title = `[${progress}/${Total}]`;
                                Button.textContent = `${language.DS_04}: [${progress}/${Total}]`;
                                progress++;
                                resolve();
                            } else {
                                if (retry > 0) {
                                    if (DeBug) {console.log(`Request Retry : [${retry}]`)}
                                    Request(index, retry-1);
                                    resolve();
                                } else {
                                    reject(new Error("Request error"));
                                }
                            }
                        },
                        onerror: error => {
                            if (retry > 0) {
                                if (DeBug) {console.log(`Request Retry : [${retry}]`)}
                                Request(index, retry-1);
                                resolve();
                            } else {
                                console.groupCollapsed("Request Error");
                                console.log(`[Request Error] : ${link}`);
                                console.groupEnd();
                                reject(error);
                            }
                        }
                    })
                } else {reject(new Error("undefined url"))}
            });
        }
        for (let i = 0; i < Total; i++) {
            promises.push(Request(i, 10));
            count++;
            if (count === 5) {
                count = 0;
                await new Promise(resolve => setTimeout(resolve, Delay.Download));
            }
        }
        await Promise.allSettled(promises);
        Compression();

        async function Compression() {
            zip.generateAsync({
                type: "blob",
                compression: "DEFLATE",
                compressionOptions: {
                    level: 5
                }
            }, (progress) => {
                document.title = `${progress.percent.toFixed(1)} %`;
                Button.textContent = `${language.DS_05}: ${progress.percent.toFixed(1)} %`;
            }).then(async zip => {
                await saveAs(zip, `${Folder}.zip`);
                Button.textContent = language.DS_06;
                document.title = OriginalTitle;
                setTimeout(() => {
                    Button.textContent = ModeDisplay;
                    Button.disabled = false;
                }, 3000);
            }).catch(result => {
                Button.textContent = language.DS_07;
                document.title = OriginalTitle;
                setTimeout(() => {
                    Button.textContent = ModeDisplay;
                    Button.disabled = false;
                }, 6000);
            })
        }
    }

    /* 單圖下載 */
    async function ImageDownload(Button, Folder, ImgData) {
        const Total = ImgData.size, promises = [];
        let progress = 1, link, extension;
        async function Request(index, retry) {
            link = ImgData.get(index);
            extension = GetExtension(link);
            return new Promise((resolve, reject) => {
                if (typeof link !== "undefined") {
                    GM_download({
                        url: link,
                        name: `${Folder}_${(index + 1).toString().padStart(4, '0')}.${extension}`,
                        headers : {"user-agent": navigator.userAgent},
                        onload: () => {
                            document.title = `[${progress}/${Total}]`;
                            Button.textContent = `${language.DS_04}: [${progress}/${Total}]`;
                            progress++;
                            resolve();
                        },
                        onerror: () => {
                            if (retry > 0) {
                                if (DeBug) {console.log(`Request Retry : [${retry}]`)}
                                Request(index, retry-1);
                                resolve();
                            } else {
                                reject(new Error("Request error"));
                            }
                        }
                    })
                } else {reject(new Error("undefined url"))}
            });
        }
        for (let i = 0; i < Total; i++) {
            promises.push(Request(i));
            await new Promise(resolve => setTimeout(resolve, Delay.Download));
        }
        await Promise.allSettled(promises);
        Button.textContent = language.DS_08;
        setTimeout(() => {
            Button.textContent = ModeDisplay;
            Button.disabled = false;
        }, 3000);
    }

    /* @===== 附加功能函數 =====@ */

    /* 自適應css */
    function AdaptiveCSS(e, ex) {
        const Domain = window.location.hostname;
        if (Domain === "e-hentai.org") {
            GM_addStyle(`${e}`);
        } else if (Domain === "exhentai.org") {
            GM_addStyle(`${ex}`);
        }
    }

    /* work創建 */
    function BackWorkerCreation(code) {
        let blob = new Blob([code], {type: "application/javascript"});
        return new Worker(URL.createObjectURL(blob));
    }

    /* 下載模式切換 */
    async function DownloadModeSwitch() {
        if (CompressMode){
            GM_setValue("CompressedMode", false);
        } else {
            GM_setValue("CompressedMode", true);
        }
        location.reload();
    }

    /* 顯示語言 */
    function display_language(language) {
        let display = {
            "zh-TW": [{
                "MN_01" : "🔁 切換下載模式",
                "DM_01" : "壓縮下載",
                "DM_02" : "單圖下載",
                "DS_01" : "開始下載",
                "DS_02" : "獲取頁面",
                "DS_03" : "獲取連結",
                "DS_04" : "下載進度",
                "DS_05" : "壓縮封裝",
                "DS_06" : "壓縮完成",
                "DS_07" : "壓縮失敗",
                "DS_08" : "下載完成"
            }],
            "zh-CN": [{
                "MN_01" : "🔁 切换下载模式",
                "DM_01" : "压缩下载",
                "DM_02" : "单图下载",
                "DS_01" : "开始下载",
                "DS_02" : "获取页面",
                "DS_03" : "获取链接",
                "DS_04" : "下载进度",
                "DS_05" : "压缩封装",
                "DS_06" : "压缩完成",
                "DS_07" : "压缩失败",
                "DS_08" : "下载完成"
            }],
            "ja": [{
                "MN_01" : "🔁 ダウンロードモードの切り替え",
                "DM_01" : "圧縮ダウンロード",
                "DM_02" : "単一画像ダウンロード",
                "DS_01" : "ダウンロード開始",
                "DS_02" : "ページを取得する",
                "DS_03" : "リンクを取得する",
                "DS_04" : "ダウンロードの進捗状況",
                "DS_05" : "圧縮パッケージング",
                "DS_06" : "圧縮完了",
                "DS_07" : "圧縮に失敗しました",
                "DS_08" : "ダウンロードが完了しました"
            }],
            "en-US": [{
                "MN_01" : "🔁 Switch download mode",
                "DM_01" : "Compressed download",
                "DM_02" : "Single image download",
                "DS_01" : "Start download",
                "DS_02" : "Get page",
                "DS_03" : "Get link",
                "DS_04" : "Download progress",
                "DS_05" : "Compressed packaging",
                "DS_06" : "Compression complete",
                "DS_07" : "Compression failed",
                "DS_08" : "Download complete"
            }],
            "ko": [{
                "MN_01" : "🔁 다운로드 모드 전환",
                "DM_01" : "압축 다운로드",
                "DM_02" : "단일 이미지 다운로드",
                "DS_01" : "다운로드 시작",
                "DS_02" : "페이지 가져오기",
                "DS_03" : "링크 가져오기",
                "DS_04" : "다운로드 진행 상황",
                "DS_05" : "압축 포장",
                "DS_06" : "압축 완료",
                "DS_07" : "압축 실패",
                "DS_08" : "다운로드 완료"
            }]
        };

        if (display.hasOwnProperty(language)) {
            return display[language][0];
        } else {
            return display["en-US"][0];
        }
    }
})();