Greasy Fork is available in English.

Pixiv Novel Download King

Your pixiv novel download friend

// ==UserScript==
// @name         Pixiv Novel Download King
// @name:zh-CN   Pixiv小说下载王
// @name:zh-TW   Pixiv小說下載王
// @name:ja      Pixiv小説ダウンロード王
// @name:ko      Pixiv소설 다운로드 킹
// @namespace    calary.tampermonkey
// @version      0.1
// @description  Your pixiv novel download friend
// @description:zh-CN 您最好的pxiv小说下载朋友
// @description:zh-TW 您最好的pxiv小說下載朋友
// @description:ja      Pixivから小説をダウンロードします
// @description:ko      Pixiv에서 소설을 다운로드합니다
// @author       eyeyani
// @license      GPL-3.0
// @match        https://*.pixiv.net/*
// @icon         https://www.pixiv.net/favicon.ico
// @require      https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// @grant        none
// @run-at       document-end
// ==/UserScript==

(function() {
    'use strict';

    const lang = (
        window.navigator.language ||
        window.navigator.browserLanguage ||
        "en-us"
    ).toLowerCase();

    const i18nMap = {
        "en-us": {
            ui_title: "Novel Download King",
            ui_dl_page: "Download This Page",
            ui_dl_author: "Batch Download This Author",
            ui_dl_series: "Batch Download This Series",
            ui_dl_list: "Batch Download This List",
            ui_dl_favlist: "Batch Download Bookmark List",
            ui_start: "START",
            ui_pause: "PAUSE",
            ui_resume: "RESUME",
            ui_retry: "RETRY",
            ui_cancel: "CANCEL",
            ui_dl_current_page: "Current Page",
            ui_all: "ZIP All",
            ui_specific: "Specific Chapters",
            ui_merge: "Merge into Single TXT",
            ui_chapter: "Chapter(s)",
            error_default: "Something went wrong",
            error_notpage: "This is not a novel page.",
            error_notauthor: "This is not an author page.",
            error_notseries: "This is not a series page.",
            error_notlist: "This is not a list page.",
            error_notfavlist: "This is not a bookmark page",
            error_invalid_chapter_input: "Invalid chapter input.",
            error_no_chapters_found: "No chapters found matching your input.",
            ui_page: "Page",
            ui_batch_download: "Batch Download",
            ui_batch_download_options: "Batch Download Options",
            ui_single_download: "Single Download",
            ui_start_download: "Start Download",
            ui_download_scope: "Download Scope:",
            ui_auto_detect: "Auto Detect",
            ui_scope_author: "Author",
            ui_scope_series: "Series",
            ui_scope_list: "List",
            ui_scope_favlist: "Favorites",
            ui_chapter_selection: "Chapter Selection:",
            ui_all_chapters: "All",
            ui_specific_chapters: "Specific",
            ui_output_format: "Output Format:",
            ui_format_zip: "ZIP",
            ui_format_txt: "TXT",
            ui_start_batch_download: "Start Batch Download"
        },
        "zh-cn": {
            ui_title: "小说下载王",
            ui_dl_page: "下载此页面",
            ui_dl_author: "批量下载此作者",
            ui_dl_series: "批量下载此系列",
            ui_dl_list: "批量下载此列表页",
            ui_dl_favlist: "批量下载收藏列表",
            ui_start: "开始",
            ui_pause: "暂停",
            ui_resume: "继续",
            ui_retry: "重试",
            ui_cancel: "取消",
            ui_dl_current_page: "当前页",
            ui_all: "打包为ZIP",
            ui_specific: "指定章节",
            ui_merge: "合并TXT",
            ui_chapter: "章节",
            error_default: "出错了",
            error_notpage: "该页不是小说页。",
            error_notauthor: "该页不是作者主页。",
            error_notseries: "该页不是系列页。",
            error_notlist: "该页不是列表页。",
            error_notfavlist: "该页不是收藏列表。",
            error_invalid_chapter_input: "无效的章节输入。",
            error_no_chapters_found: "没有找到符合您输入的章节。",
            txt_title: "标题:",
            txt_novelid: "作品id:",
            txt_author: "作者:",
            txt_authorid: "Pixiv ID:",
            txt_words: "字数:",
            txt_likes: "喜欢:",
            txt_createtime: "创建时间:",
            txt_updatetime: "更新时间:",
            txt_tags: "标签:",
            txt_desc: "描述:",
            txt_words2: "字",
            txt_likes2: "喜欢",
            txt_pageno: "第{0}页",
            txt_fav: "收藏",
            ui_page: "页",
            ui_batch_download: "批量下载",
            ui_batch_download_options: "批量下载选项",
            ui_single_download: "单页下载",
            ui_start_download: "开始下载此章",
            ui_download_scope: "下载范围:",
            ui_auto_detect: "自动检测",
            ui_scope_author: "作者",
            ui_scope_series: "系列",
            ui_scope_list: "列表",
            ui_scope_favlist: "收藏",
            ui_chapter_selection: "章节选择:",
            ui_all_chapters: "全部",
            ui_specific_chapters: "指定",
            ui_output_format: "输出格式:",
            ui_format_zip: "ZIP",
            ui_format_txt: "TXT",
            ui_start_batch_download: "开始批量下载"
        },
        "zh-tw": {
            ui_title: "小說下載王",
            ui_dl_page: "下載此頁面",
            ui_dl_author: "批量下載此作者",
            ui_dl_series: "批量下載此系列",
            ui_dl_list: "批量下載此列表頁",
            ui_dl_favlist: "批量下載收藏列表",
            ui_start: "開始",
            ui_pause: "暫停",
            ui_resume: "繼續",
            ui_retry: "重試",
            ui_cancel: "取消",
            ui_dl_current_page: "當前頁",
            ui_all: "打包爲ZIP",
            ui_specific: "指定章節",
            ui_merge: "合併TXT",
            ui_chapter: "章節",
            error_default: "出錯了",
            error_notpage: "該頁不是小說頁。",
            error_notauthor: "該頁不是作者主頁。",
            error_notseries: "該頁不是系列頁。",
            error_notlist: "該頁不是列表頁。",
            error_notfavlist: "該頁不是收藏列表。",
            error_invalid_chapter_input: "無效的章節輸入。",
            error_no_chapters_found: "沒有找到符合您輸入的章節。",
            txt_title: "標題:",
            txt_novelid: "作品id:",
            txt_author: "作者:",
            txt_authorid: "Pixiv ID:",
            txt_words: "字數:",
            txt_likes: "喜歡:",
            txt_createtime: "創建時間:",
            txt_updatetime: "更新時間:",
            txt_tags: "標籤:",
            txt_desc: "描述:",
            txt_words2: "字",
            txt_likes2: "喜歡",
            txt_pageno: "第{0}頁",
            txt_fav: "收藏",
            ui_page: "頁",
            ui_batch_download: "批量下載",
            ui_batch_download_options: "批量下載選項",
            ui_single_download: "單頁下載",
            ui_start_download: "開始下載此章",
            ui_download_scope: "下載範圍:",
            ui_auto_detect: "自動檢測",
            ui_scope_author: "作者",
            ui_scope_series: "系列",
            ui_scope_list: "列表",
            ui_scope_favlist: "收藏",
            ui_chapter_selection: "章節選擇:",
            ui_all_chapters: "全部",
            ui_specific_chapters: "指定",
            ui_output_format: "輸出格式:",
            ui_format_zip: "ZIP",
            ui_format_txt: "TXT",
            ui_start_batch_download: "開始批量下載"
        },
        "ja": {
            ui_title: "小説ダウンロード王",
            ui_dl_page: "このページをダウンロード",
            ui_dl_author: "この作者をまとめてダウンロード",
            ui_dl_series: "このシリーズをまとめてダウンロード",
            ui_dl_list: "このリストページをまとめてダウンロード",
            ui_dl_favlist: "ブックマークリストをまとめてダウンロード",
            ui_start: "開始",
            ui_pause: "一時停止",
            ui_resume: "再開",
            ui_retry: "再試行",
            ui_cancel: "キャンセル",
            ui_dl_current_page: "現在のページ",
            ui_all: "全部ZIPにする",
            ui_specific: "指定章",
            ui_merge: "TXTを結合",
            ui_chapter: "チャプター",
            error_default: "問題が発生しました",
            error_notpage: "これは小説ページではありません。",
            error_notauthor: "これは作者ページではありません。",
            error_notseries: "これはシリーズページではありません。",
            error_notlist: "これはリストページではありません。",
            error_notfavlist: "これはブックマークページではありません",
            error_invalid_chapter_input: "無効なチャプター入力です。",
            error_no_chapters_found: "入力に一致するチャプターが見つかりませんでした。",
            txt_title: "タイトル:",
            txt_novelid: "作品ID:",
            txt_author: "作者:",
            txt_authorid: "Pixiv ID:",
            txt_words: "文字数:",
            txt_likes: "いいね:",
            txt_createtime: "作成時間:",
            txt_updatetime: "更新時間:",
            txt_tags: "タグ:",
            txt_desc: "説明:",
            txt_words2: "文字",
            txt_likes2: "いいね",
            txt_pageno: "{0}ページ",
            txt_fav: "お気に入り",
            ui_page: "ページ",
            ui_batch_download: "バッチダウンロード",
            ui_batch_download_options: "バッチダウンロードオプション",
            ui_single_download: "シングルダウンロード",
            ui_start_download: "ダウンロードを開始",
            ui_download_scope: "ダウンロード範囲:",
            ui_auto_detect: "自動検出",
            ui_scope_author: "作者",
            ui_scope_series: "シリーズ",
            ui_scope_list: "リスト",
            ui_scope_favlist: "お気に入り",
            ui_chapter_selection: "章の選択:",
            ui_all_chapters: "すべて",
            ui_specific_chapters: "特定",
            ui_output_format: "出力フォーマット:",
            ui_format_zip: "ZIP",
            ui_format_txt: "TXT",
            ui_start_batch_download: "バッチダウンロードを開始"
        },
        "ko": {
            ui_title: "소설 다운로드 왕",
            ui_dl_page: "이 페이지를 다운로드",
            ui_dl_author: "이 작가를 묶어서 다운로드",
            ui_dl_series: "이 시리즈를 묶어서 다운로드",
            ui_dl_list: "이 목록 페이지를 묶어서 다운로드",
            ui_dl_favlist: "북마크 목록을 묶어서 다운로드",
            ui_start: "시작",
            ui_pause: "일시 정지",
            ui_resume: "재개",
            ui_retry: "재시도",
            ui_cancel: "취소",
            ui_dl_current_page: "현재 페이지",
            ui_all: "전부 ZIP으로 묶기",
            ui_specific: "지정 장",
            ui_merge: "TXT를 병합",
            ui_chapter: "챕터",
            error_default: "문제가 발생했습니다",
            error_notpage: "이것은 소설 페이지가 아닙니다.",
            error_notauthor: "이것은 작가 페이지가 아닙니다.",
            error_notseries: "이것은 시리즈 페이지가 아닙니다.",
            error_notlist: "이것은 목록 페이지가 아닙니다.",
            error_notfavlist: "이것은 북마크 페이지가 아닙니다",
            error_invalid_chapter_input: "유효하지 않은 챕터 입력입니다.",
            error_no_chapters_found: "입력과 일치하는 챕터를 찾을 수 없습니다。",
            txt_title: "제목:",
            txt_novelid: "작품 ID:",
            txt_author: "작가:",
            txt_authorid: "Pixiv ID:",
            txt_words: "글자 수:",
            txt_likes: "좋아요:",
            txt_createtime: "작성 시간:",
            txt_updatetime: "업데이트 시간:",
            txt_tags: "태그:",
            txt_desc: "설명:",
            txt_words2: "글자",
            txt_likes2: "좋아요",
            txt_pageno: "{0}페이지",
            txt_fav: "즐겨찾기",
            ui_page: "페이지",
            ui_batch_download: "일괄 다운로드",
            ui_batch_download_options: "일괄 다운로드 옵션",
            ui_single_download: "단일 다운로드",
            ui_start_download: "다운로드 시작",
            ui_download_scope: "다운로드 범위 :",
            ui_auto_detect: "자동 감지",
            ui_scope_author: "작가",
            ui_scope_series: "시리즈",
            ui_scope_list: "목록",
            ui_scope_favlist: "즐겨 찾기",
            ui_chapter_selection: "장 선택 :",
            ui_all_chapters: "모두",
            ui_specific_chapters: "특정",
            ui_output_format: "출력 형식 :",
            ui_format_zip: "ZIP",
            ui_format_txt: "TXT",
            ui_start_batch_download: "일괄 다운로드 시작"
        },
    };
    const i18n = (key, ...args) => {
        let str = (i18nMap[lang] && i18nMap[lang][key]) || i18nMap["en-us"][key];
        args.forEach((value, index) => {
            str = str.replace(`{${index}}`, value);
        });
        return str;
    };

    const fontFamily = 'sans-serif';

    function filterFilename(filename) {
        return filename.replace(/\?|[*:"<>\\/|]/g, "");
    }

    function baseRequest(config) {
        return new Promise((resolve, reject) => {
            $.ajax({
                timeout: 50000,
                ...config,
                success: resolve,
                error: (xhr, status, error) => {
                    if (config.signal && config.signal.aborted) {
                        reject(new Error("Request aborted"));
                    } else {
                        reject(new Error(i18n("error_default")));
                    }
                },
            });
        });
    }

    function request(config) {
        return baseRequest(config).then(({ error, message, body }) => {
            if (error) {
                throw new Error(message);
            }
            return body;
        });
    }

    class Task {
        constructor(title) {
            this.title = title;
            this.status = '';
            this.$item = $(`<div class="task-item">
                <span class="task-title">${i18n(title)}</span>
                <span class="task-status">
                    <span class="current"></span> -
                    <span class="page"></span>
                </span>
            </div>`);
            this.$status = this.$item.find(".task-status").hide();
            this.$currentStatus = this.$item.find(".task-status .current");
            this.$pageStatus = this.$item.find(".task-status .page");
        }

        start() {
            this.status = 'running';
        }

        cancel() {
            this.status = '';
        }

        isRunning() {
            return this.status === 'running';
        }

        isCancelled() {
            return this.status === 'cancelled';
        }

        isPaused() {
            return this.status === 'paused';
        }

        checkRunning() {
            if (!this.isRunning()) {
                throw new Error("CANCELLED");
            }
        }

        errorHandler(e) {
            if (e.message === "CANCELLED") return;
            this.error();
            console.error(e);
            alert(e);
        }

       async getWork(id, isSingle = false, signal) {
            try {
                const body = await request({
                    url: `/ajax/novel/${id}`,
                    responseType: "json",
                    signal: signal,
                });

                 let content = body.content
                    .replace(/\[uploadedimage:\d+\]/g, '')
                    .replace(/\[PARAGRAPH\]/g, "\n")
                    .replace(/\[\[rb:(.+?) > (.+?)\]\]/g, '$1')
                    .replace(/\[newpage\]/g, '')
                    .replace(/^\s*\[chapter:(.*?)\]\s*/gim, '$1\n')
                    .replace(/^[ \t ]+/gm, '')
                     .replace(/(\r?\n|\r|\u2028|\u2029)(第[零一二三四五六七八九十]{1,3}章)/g, "$1\n$2")
                    .replace(/\\n/g, "\n")
                    .replace(/\r\n/g, '\n')
                    .replace(/\r/g, '\n')
                    .replace(/\n{2,}/g, '\n')
                    .replace(/(.+?)(?<!\n)$/gm, '$1\n')
                    .replace(/^\n+|\n+$/g, '');


                let filename = '';
                let chapterTitleForFilename = filterFilename(body.title);


               if (isSingle) {
                    filename = `1_${chapterTitleForFilename}.txt`;
                } else {
                    this.chapterCounter++;
                    const chapterNumber = String(this.chapterCounter);
                    filename = `${chapterNumber}_${chapterTitleForFilename}.txt`
                }

                 // Add indentation to the beginning of the content
                content = `  ${content}`;

                return { id, filename, content: content.trim() };
            } catch (error) {
                if (error.name === 'AbortError') {
                    console.log(`Fetch aborted for novel ${id}`);
                    throw new Error("CANCELLED");
                } else {
                    console.error(`Error fetching novel ${id}:`, error);
                    throw error;
                }
            }
        }
    }

    class TaskMultiPage extends Task {
        constructor(title) {
            super(title);
            this.bookTitle = "";
            this.pageParam = "p";
            this.offsetParam = "offset";
            this.limitParam = "limit";
            this.defaultParams = {};
            this.page = 1;
            this.finished = 0;
            this.limit = 24;
            this.total = 0;
            this.pages = 0;
            this.chapterCounter = 0;
            this.mode = "all";
            this.step = "";
            this.url = null;
            this.params = null;
            this.promise = null;
            this.entries = {};
            this.allNovelIds = new Set();
            this.specificChapters = null;
            this.batchScope = "auto";
            this.batchChapters = "all";
            this.batchFormat = "zip";
            this.paused = false;
            this.cancelled = false;
            this.currentWork = null;
            this.activeControllers = {};
        }

        getUrl() { return ""; }
        getSaveFilename() { return filterFilename(this.bookTitle) + ".zip"; }
        check() {}
        getInitData() { return Promise.resolve(); }
        parseList(payload) { return payload; }

        start() {
            try {
                this.check();
                this.chapterCounter = 0;
            } catch (e) {
                alert(e);
                return;
            }
            this.status = 'running';
            $panel.find('.batch-download-btn').hide();
            $panel.find('.pause-btn').show();
            $panel.find('.cancel-btn').show();
            $panel.find('.resume-btn').hide();
            $batchProgress.hide();

            this.batchScope = $panel.find('select[name="batch_scope"]').val();
            this.batchChapters = $panel.find('input[name="batch_chapters"]:checked').val();
            this.batchFormat = $panel.find('input[name="batch_format"]:checked').val();

            if (this.batchChapters === 'specific') {
                const chaptersInput = $panel.find('input[name="specific_chapters_input"]').val();
                this.specificChapters = this.parseChapterInput(chaptersInput);
                if (!this.specificChapters) {
                    alert(i18n('error_invalid_chapter_input'));
                    this.cancel();
                    return;
                }
            } else {
                this.specificChapters = null;
            }

            const curPageUrl = new URL(window.location.href);
            this.url = this.getUrl();
            this.params = Object.assign({}, this.defaultParams, Object.fromEntries(curPageUrl.searchParams));
            this.page = parseInt(this.params[this.pageParam]) || 1;
            this.allNovelIds.clear();
            this.entries = {};
            this.paused = false;
            this.cancelled = false;
            this.currentWork = null;
            this.activeControllers = {};

            this.getInitData().then(() => this.getNextList()).catch(this.errorHandler.bind(this));
        }

        parseChapterInput(input) {
            if (!input) return null;
            const ranges = input.split(',');
            const chapters = new Set();
            for (const range of ranges) {
                const match = range.match(/(\d+)(?:-(\d+))?/);
                if (!match) return null;
                const start = parseInt(match[1], 10);
                const end = match[2] ? parseInt(match[2], 10) : start;
                if (isNaN(start) || isNaN(end) || start < 1 || end < start) return null;
                for (let i = start; i <= end; i++) {
                    chapters.add(i);
                }
            }
            return Array.from(chapters).sort((a, b) => a - b);
        }

        pause() {
            this.paused = true;
            $panel.find('.pause-btn').hide();
            $panel.find('.resume-btn').show();
            $batchProgress.show();
        }

        resume() {
            this.paused = false;
            $panel.find('.resume-btn').hide();
            $panel.find('.pause-btn').show();
            $batchProgress.show();
            if (this.step === "list") {
                this.getNextList();
            } else if (this.step === "works") {
                this.getWorks();
            }
        }

        retry() {
            this.page = 1;
            this.allNovelIds.clear();
            this.entries = {};
            this.paused = false;
            this.cancelled = false;
            $panel.find('.resume-btn').hide();
            $panel.find('.cancel-btn').hide();
            $panel.find('.pause-btn').show();
            $batchProgress.show();
            this.getInitData().then(() => this.getNextList()).catch(this.errorHandler.bind(this));
        }

        isPaused() {
            return this.paused;
        }

        isCancelled() {
            return this.cancelled;
        }

        cancel() {
            this.cancelled = true;

            Object.values(this.activeControllers).forEach(controller => controller.abort());
            this.activeControllers = {};

            this.currentWork = null;

            $panel.find('.batch-download-btn').show();
            $panel.find('.pause-btn').hide();
            $panel.find('.cancel-btn').hide();
            $panel.find('.resume-btn').hide();

            this.clearProgress();
            this.finish();
        }

        finish() {
            this.step = "idle";
            this.clearProgress();
            $panel.find('.batch-download-btn').show();
            $panel.find('.pause-btn').hide();
            $panel.find('.cancel-btn').hide();
            $panel.find('.resume-btn').hide();
        }

        setParams() {
            this.params[this.pageParam] = this.page;
            this.params[this.limitParam] = this.limit;
            this.params[this.offsetParam] = (this.page - 1) * this.limit;
        }

        getNextList() {
            if (this.isPaused() || this.isCancelled()) return;
            this.step = "list";
            this.setParams();

            this.promise = this.getList()
                .then(({ data = [], total }) => {
                    if (this.isPaused() || this.isCancelled()) return;
                    this.total = total;
                    this.pages = Math.ceil(total / this.limit);
                    this.finished = 0;
                    this.updateStatus();

                    if (data.length === 0) return;

                    data.forEach(item => this.allNovelIds.add(item.id));

                    if (this.batchChapters === "all" && this.allNovelIds.size < this.total) {
                        this.page++;
                        this.getNextList();
                    } else {
                        this.getWorks();
                    }
                })
                .catch(this.errorHandler.bind(this));
        }

        async getList() {
            if (this.isPaused() || this.isCancelled()) return { data: [], total: 0 };
            this.setParams();
            try {
                const body = await request({
                    url: this.url,
                    data: this.params,
                    method: "get",
                    responseType: "json",
                });
                if (this.isPaused() || this.isCancelled()) return { data: [], total: 0 };
                return this.parseList(body.page);
            } catch (error) {
                this.errorHandler(error);
                return { data: [], total: 0 };
            }
        }

        async getWorks() {
            if (this.isPaused() || this.isCancelled()) return;
            this.step = "works";

            const novelIdsToDownload = this.batchChapters === 'specific'
                ? Array.from(this.allNovelIds).filter(id => this.specificChapters.includes(parseInt(id, 10)))
                : Array.from(this.allNovelIds);

            if (this.batchChapters === 'specific' && novelIdsToDownload.length === 0) {
                alert(i18n('error_no_chapters_found'));
                this.cancel();
                return;
            }

            const getWorkSequentially = async () => {
                for (const id of novelIdsToDownload) {
                    if (this.isPaused()) {
                        return;
                    }

                    if (this.isCancelled()) {
                        this.finish();
                        return;
                    }

                    try {
                        const currentRequestController = new AbortController();
                        this.activeControllers[id] = currentRequestController;
                        const signal = currentRequestController.signal;

                        const work = await this.getWork(id, false, signal);
                        delete this.activeControllers[id];

                        if (this.isCancelled()) {
                            this.finish();
                            return;
                        }

                        this.entries[id] = work;
                        this.updateStatus();
                    } catch (error) {
                        if (error.message !== "CANCELLED") {
                            this.errorHandler(error);
                        } else {
                              this.finish();
                              return;
                        }
                        if (this.isCancelled()) {
                              this.finish();
                              return;
                        }
                    }
                }

                if (!this.isPaused() && !this.isCancelled()) {
                    if (this.batchFormat === 'txt') {
                        this.downloadMergedText();
                    } else {
                        this.downloadZipped();
                    }
                }
            };

            await getWorkSequentially();
        }

        downloadZipped() {
            const zip = new JSZip();
            let hasFile = false;
            Object.values(this.entries).forEach(({ filename, content }) => {
                hasFile = true;
                zip.file(filename, content);
            });

            if (hasFile) {
                zip.generateAsync({ type: "blob" })
                   .then(content => saveAs(content, this.getSaveFilename()));
            }
            this.finish();
        }

         downloadMergedText() {
            let mergedContent = '';
            const sortedEntries = Object.values(this.entries).sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
            const bookTitleForMerge = filterFilename(this.bookTitle);
            let chapterNumber = 1;
            let isFirstChapter = true;

            sortedEntries.forEach(entry => {
                const filenameParts = entry.filename.split('_');
                 let chapterTitle = '';
                if (filenameParts.length > 1) {
                    chapterTitle = filenameParts.slice(1).join('_').replace('.txt', '');
                } else {
                     chapterTitle = entry.filename.replace('.txt', '');
                 }


                 const chapterNumberRegex = /(?:(?:第(?=[零一二三四五六七八九十〇\d壹貳參肆伍陸柒捌玖拾佰仟])(?:[零一二三四五六七八九十〇\d壹貳參肆伍陸柒捌玖拾佰仟]+)[章話节節])(?:之[\d]+)?|第[\d]+[章話节節])|(?:卷|冊|辑|輯)[\s]?[\d]+|(?:Chapter|Chap|Part|Section|Segment|Book)[\.\s]?[\w\d]+|(?:Ch|Bk)[\.\s]?[\d]+|[\u4E00-\u9FFF]+(?:[\s]?[\d]+)?|(?:[第]?[\u4E00\u4E8C\u4E09\u56DB\u4E94\u516D\u4E03\u516B\u4E5D\u5341百千]+[章節話編巻書])(?:[\s・\-])?(?:[\u4E00-\u9FFF\u3040-\u309F\u30A0-\u30FF\d]+)?|(?:\d+[章節話編巻書])(?:[\s・\-])?(?:[\u4E00-\u9FFF\u3040-\u309F\u30A0-\u30FF\d]+)?|\b节\b/gui;


                 if (!chapterNumberRegex.test(chapterTitle) && !/^(第[\d一二三四五六七八九十]+章)/.test(chapterTitle)) {
                    chapterTitle = `第${chapterNumber}章 ${chapterTitle}`;
                }
                if (!isFirstChapter) {
                    mergedContent += `\n\n${chapterTitle}\n\n`;
                } else {
                    mergedContent += `${chapterTitle}\n\n`;
                    isFirstChapter = false;
                }


                mergedContent += entry.content;


                chapterNumber++;
            });

            mergedContent = mergedContent.replace(/\[newpage\]/g, "");
            mergedContent = mergedContent.replace(/\\n/g, "\n");

            const filename = filterFilename(`${this.userName}_${bookTitleForMerge}`) + ".txt";
            saveAs(new Blob([mergedContent], { type: "text/plain;charset=UTF-8" }), filename);
            this.finish();
        }

        clearProgress() {
            $batchProgress.empty().hide();
        }

        updateStatus() {
            $batchProgress.show();
            const { finished, total, pages } = this;
            $batchProgress.html(`${Object.keys(this.entries).length}/${total}`);
        }
    }

    class TaskPage extends Task {
        constructor(title) {
            super(title);
            this.promise = null;
        }

        async getWork(id, isSingle = true, signal) {
            try {
                const body = await request({
                    url: `/ajax/novel/${id}`,
                    responseType: "json",
                    signal: signal,
                });

                const chapterNumber = String(1);
                const chapterTitle = filterFilename(body.title);
                const filename = `${chapterNumber}_${chapterTitle}.txt`;

                let content = body.content
                    .replace(/\[uploadedimage:\d+\]/g, '')
                    .replace(/\[PARAGRAPH\]/g, "\n")
                    .replace(/\[\[rb:(.+?) > (.+?)\]\]/g, '$1')
                    .replace(/\[newpage\]/g, '')
                    .replace(/^\s*\[chapter:(.*?)\]\s*/gim, '$1\n')
                    .replace(/^[ \t ]+/gm, '')
                     .replace(/(\r?\n|\r|\u2028|\u2029)(第[零一二三四五六七八九十]{1,3}章)/g, "$1\n$2")
                    .replace(/\\n/g, "\n")
                    .replace(/\r\n/g, '\n')
                    .replace(/\r/g, '\n')
                    .replace(/\n{2,}/g, '\n')
                    .replace(/(.+?)(?<!\n)$/gm, '$1\n')
                    .replace(/^\n+|\n+$/g, '');

                 content = `  ${content}`;


                return { id, filename, content: content.trim() };
            } catch (error) {
                if (error.name === 'AbortError') {
                    console.log(`Fetch aborted for novel ${id}`);
                    throw new Error("CANCELLED");
                } else {
                    console.error(`Error fetching novel ${id}:`, error);
                    throw error;
                }
            }
        }

        start() {
            const exec = /\/novel\/show.php\?id=(\d+)/i.exec(window.location.href);
            if (!exec) {
                alert(i18n("error_notpage"));
                return;
            }
            const id = exec[1];
            super.start();

            this.promise = this.getWork(id)
                .then(({ filename, content }) => {
                    if (!this.isRunning()) return;
                    this.cancel();
                    saveAs(new Blob([content], { type: "text/plain;charset=UTF-8" }), filename);
                })
                .catch(e => {
                    if (e.message !== "CANCELLED") {
                        this.errorHandler(e);
                    }
                });
        }
    }

    class TaskAuthor extends TaskMultiPage {
        constructor(title) {
            super(title);
            this.defaultParams = {
                limit: 10,
                last_order: 0,
                order_by: "asc",
                lang: "zh",
            };
            this.id = "";
            this.limit = 24;
            this.tag = "";
            this.userName = "";
            this.workIds = null;
            this.total = 0;
        }

        check() {
            const pathname = window.location.pathname;
            const exec2 = /^\/users\/(\d+)\/novels\/(.+)$/.exec(pathname);
            const exec1 = /^\/users\/(\d+)(\/novels)*$/.exec(pathname);

            this.id = "";
            this.tag = "";

            if (this.batchScope === "auto") {
                if (exec2) {
                    this.id = exec2[1];
                    this.tag = decodeURIComponent(exec2[2]);
                    this.batchScope = "author";
                } else if (exec1) {
                    this.id = exec1[1];
                    this.batchScope = "author";
                } else {
                    throw new Error(i18n("error_notauthor"));
                }
            } else if (this.batchScope === "author"){
                if (exec2) {
                    this.id = exec2[1];
                    this.tag = decodeURIComponent(exec2[2]);
                } else if (exec1) {
                    this.id = exec1[1];
                } else {
                    throw new Error(i18n("error_notauthor"));
                }
            } else {
                throw new Error(i18n("error_notauthor"));
            }
        }

        async getInitData() {
            const [infoPayload, workPayload] = await Promise.all([
                request({
                    url: `/ajax/user/${this.id}`,
                    method: "get",
                    data: { full: 1, lang: "zh" },
                }),
                request({
                    url: `/ajax/user/${this.id}/profile/all`,
                    method: "get",
                    data: { lang: "zh" },
                })
            ]);
            this.userName = infoPayload.name;
            this.workIds = Object.keys(workPayload.novels).sort((a, b) => b - a);
            this.total = this.workIds.length;
        }

        async getList() {
            if (this.tag) {
                return super.getList();
            }

            const { limit, page, workIds } = this;
            const offset = limit * (page - 1);
            return Promise.resolve({
                total: workIds.length,
                data: workIds.slice(offset, offset + limit).map(id => ({ id })),
            });
        }

        parseList(payload) {
            if (this.tag) {
                return { data: payload.works, total: payload.total };
            }
            return { total: this.total, works: Object.values(payload.works) };
        }

        getUrl() {
            return `/ajax/user/${this.id}/novels/tag`;
        }

        setParams() {
            const { tag, limit, page } = this;
            const offset = limit * (page - 1);
            this.params = { tag, limit, offset, lang: "zh" };
        }

        getSaveFilename() {
            return filterFilename(`${this.userName}_${this.batchFormat === 'txt' ? '合集' : '作品集'}`) + (this.batchFormat === 'txt' ? '.txt' : '.zip');
        }
    }

    class TaskSeries extends TaskMultiPage {
        constructor(title) {
            super(title);
            this.defaultParams = {
                last_order: 0,
                order_by: "asc",
                lang: "zh",
            };
            this.id = "";
            this.limit = 10;
            this.title = "";
            this.userName = "";
            this.total = 0;
        }

        check() {
            if (this.batchScope === "auto" || this.batchScope === "series"){
                const exec = /^\/novel\/series\/(\d+)/i.exec(window.location.pathname);
                if (!exec) {
                    throw new Error(i18n("error_notseries"));
                }
                this.id = exec[1];
            } else {
                throw new Error(i18n("error_notseries"));
            }
        }

        async getInitData() {
            const payload = await request({
                url: `/ajax/novel/series/${this.id}`,
                method: "get",
                data: { lang: "zh" },
            });
            this.bookTitle = filterFilename(payload.title);
            this.title = payload.title;
            this.userName = payload.userName;
            this.total = payload.displaySeriesContentCount;
        }

        parseList(payload) {
            return { data: payload.seriesContents, total: this.total };
        }

        getUrl() {
            return `/ajax/novel/series_content/${this.id}`;
        }

        setParams() {
            this.params.last_order = this.limit * (this.page - 1);
        }

        getSaveFilename() {
            return filterFilename(`${this.userName}_${this.bookTitle}`) + (this.batchFormat === 'txt' ? '.txt' : '.zip');
        }
    }

    class TaskList extends TaskMultiPage {
        constructor(title) {
            super(title);
            this.defaultParams = {
                word: "",
                order: "date_d",
                mode: "all",
                p: 1,
                s_mode: "s_tag_full",
                gs: 0,
                lang: "zh",
            };
            this.tag = "";
        }

        check() {
            if (this.batchScope === "auto" || this.batchScope === "list"){
                const exec = /^\/tags\/(.+)\/novels$/i.exec(window.location.pathname);
                if (!exec) {
                    throw new Error(i18n("error_notlist"));
                }
                this.tag = decodeURIComponent(exec[1]);
                this.defaultParams.word = this.tag;
                this.bookTitle = filterFilename(this.tag);
            } else {
                throw new Error(i18n("error_notlist"));
            }
        }

        async getList() {
            try {
                const payload = await request({
                    url: `/ajax/search/novels/${encodeURIComponent(this.tag)}`,
                    responseType: "json",
                });
                this.checkRunning();
                return this.parseList(payload.novel);
            } catch (error) {
                this.errorHandler(error);
                return { data: [], total: 0 };
            }
        }

        parseList(payload) {
            const { data, total } = payload;
            return { data, total };
        }

        getUrl() {
            return `/ajax/search/novels/${encodeURIComponent(this.tag)}`;
        }

        getSaveFilename() {
            return filterFilename(this.bookTitle) + (this.batchFormat === 'txt' ? '.txt' : '.zip');
        }
    }

    class TaskFavList extends TaskMultiPage {
        constructor(title) {
            super(title);
            this.defaultParams = {
                tag: "",
                offset: 0,
                limit: 24,
                rest: "show",
                lang: "zh",
            };
            this.userId = "";
        }

        check() {
            if (this.batchScope === "auto" || this.batchScope === "favlist"){
                const exec = /^\/users\/(\d+)\/bookmarks\/novels$/i.exec(window.location.pathname);
                if (!exec) {
                    throw new Error(i18n("error_notfavlist"));
                }
                this.userId = exec[1];
                this.bookTitle = i18n("txt_fav");
            } else {
                throw new Error(i18n("error_notfavlist"));
            }
        }

        async getList() {
            try {
                const payload = await request({
                    url: `/ajax/user/${this.userId}/novels/bookmarks`,
                    responseType: "json",
                });
                this.checkRunning();
                return this.parseList(payload);
            } catch (error) {
                this.errorHandler(error);
                return { data: [], total: 0 };
            }
        }

        parseList(payload) {
            const { works, total } = payload;
            const data = works.filter(item => !!item.xRestrict);
            return { data, total };
        }

        getUrl() {
            return `/ajax/user/${this.userId}/novels/bookmarks`;
        }

        getSaveFilename() {
            return filterFilename(this.bookTitle) + (this.batchFormat === 'txt' ? '.txt' : '.zip');
        }
    }

    const taskPage = new TaskPage("ui_dl_page");
    const taskAuthor = new TaskAuthor("ui_dl_author");
    const taskSeries = new TaskSeries("ui_dl_series");
    const taskList = new TaskList("ui_dl_list");
    const taskFavList = new TaskFavList("ui_dl_favlist");

    const $panel = $(`
<div class="pixiv-downloader-panel collapsed">
    <span class="download-icon">⬇</span>
    <h4 class="downloader-title">${i18n("ui_title")}</h4>

    <div class="downloader-section single-download-section">
        <h5 class="section-title">${i18n("ui_single_download")}</h5>
        <button class="downloader-btn single-download-btn">${i18n("ui_start_download")}</button>
        </div>

    <div class="downloader-section batch-download-section">
        <h5 class="section-title">${i18n("ui_batch_download")}</h5>
        <div class="downloader-option">
            <span>${i18n("ui_download_scope")}</span>
            <select name="batch_scope">
                <option value="auto" selected>${i18n("ui_auto_detect")}</option>
                <option value="author">${i18n("ui_scope_author")}</option>
                <option value="series">${i18n("ui_scope_series")}</option>
                <option value="list">${i18n("ui_scope_list")}</option>
                <option value="favlist">${i18n("ui_scope_favlist")}</option>
            </select>
        </div>
        <div class="downloader-option">
            <span>${i18n("ui_chapter_selection")}</span>
            <label><input type="radio" name="batch_chapters" value="all" checked> ${i18n("ui_all_chapters")}</label>
            <label><input type="radio" name="batch_chapters" value="specific"> ${i18n("ui_specific_chapters")}</label>
        </div>
        <div class="downloader-option specific-chapters" style="display: none;">
            <span>${i18n("ui_chapter")}:</span>
            <input type="text" name="specific_chapters_input" placeholder="id,id-id,id" />
        </div>
        <div class="downloader-option">
            <span>${i18n("ui_output_format")}</span>
            <label><input type="radio" name="batch_format" value="zip" checked> ${i18n("ui_format_zip")}</label>
            <label><input type="radio" name="batch_format" value="txt"> ${i18n("ui_format_txt")}</label>
        </div>
        <button class="downloader-btn batch-download-btn">${i18n("ui_start_batch_download")}</button>
        <button class="downloader-btn pause-btn" style="display: none;">${i18n("ui_pause")}</button>
        <button class="downloader-btn resume-btn" style="display: none;">${i18n("ui_resume")}</button>
        <button class="downloader-btn cancel-btn" style="display: none;">${i18n("ui_cancel")}</button>
        <span class="batch-progress" style="margin-left: 10px;"></span>
    </div>

    <div class="collapse-btn"></div>
</div>
`);

    $('body').append($panel);

    const $collapseBtn = $panel.find('.collapse-btn').html('');
    const $downloadIcon = $panel.find('.download-icon');
    const $specificChapters = $panel.find('.specific-chapters');
    const $batchProgress = $panel.find('.batch-progress');

    $panel.on('change', 'input[name="batch_chapters"]', function() {
        $specificChapters.toggle($(this).val() === 'specific');
    });

    $downloadIcon.on('click', function() {
        if ($panel.hasClass('collapsed')) {
            $collapseBtn.click();
        }
    });

    $collapseBtn.on('click', function() {
        const fullWidth = $panel.data('fullWidth') || 300;
        const collapsedWidth = 30;
        const isCollapsed = $panel.hasClass('collapsed');

        if (isCollapsed) {
            $panel.removeClass('collapsed').animate({
                width: fullWidth,
                paddingLeft: '10px'
            }, 300, function() {
                $panel.find('> *:not(.collapse-btn, .download-icon)').fadeIn(100);
                $downloadIcon.hide();
                $collapseBtn.html('◀');
            });
        } else {
            $panel.addClass('collapsed').animate({
                width: collapsedWidth,
                paddingLeft: '5px'
            }, 300, function() {
                $panel.find('> *:not(.collapse-btn, .download-icon)').fadeOut(100);
                $downloadIcon.show();
                $collapseBtn.html('');
            });
        }
    });

    $panel.data('fullWidth', $panel.width());
    $panel.find('> *:not(.download-icon, .collapse-btn)').hide();

$('head').append(`
<style>
    .pixiv-downloader-panel {
        position: fixed;
        left: 10px;
        top: 10px;
        z-index: 999999;
        background: #f8f8f8;
        color: #333;
        font-size: 14px;
        font-family: ${fontFamily};
        padding: 10px;
        border-radius: 8px;
        border: 1px solid #eee;
        display: flex;
        flex-direction: column;
        align-items: center;
        transition: width 0.3s ease-in-out, padding-left 0.3s ease-in-out, opacity 0.3s ease-in-out;
        width: auto;
        max-width: 400px;
        overflow: hidden;
        opacity: 0.95;
    }

    .pixiv-downloader-panel.collapsed {
        width: 30px;
        padding: 5px;
        display: flex;
        justify-content: center;
        align-items: center;
    }

    .pixiv-downloader-panel.collapsed > .download-icon {
        display: block;
    }

    .pixiv-downloader-panel.collapsed > *:not(.download-icon, .collapse-btn) {
        display: none !important;
    }

    .pixiv-downloader-panel:not(.collapsed) {
        padding-left: 10px;
    }

    .pixiv-downloader-panel:not(.collapsed) > .download-icon {
        display: none;
    }

    .download-icon {
        font-size: 1.5em;
        color: black;
        cursor: pointer;
        display: block;
    }

    .downloader-title {
        margin: 0 0 10px 0;
        padding: 0;
        font-size: 1.4em;
        font-weight: bold;
        display: block;
        text-align: center;
        width: 100%;
        margin-bottom: 10px;
    }

    .downloader-section {
        margin-bottom: 1px;
        padding-bottom: 1px;
        border-bottom: 1px solid #eee;
        width: 100%;
    }

    .downloader-section:last-child {
        border-bottom: none;
    }

    .downloader-section.single-download-section {
        margin-bottom: 10px;
        padding-bottom: 1px;
        border-bottom: 1px solid #eee;
        width: 100%;
    }

    .section-title {
        margin-top: 0;
        margin-bottom: 5px;
        font-size: 1.1em;
        font-weight: bold;
    }

    .downloader-option {
        margin-bottom: 2px;
        display: flex;
        align-items: center;
        font-size: 0.95em;
        flex-wrap: wrap;
    }

    .downloader-option > span {
        margin-right: 8px;
        flex-shrink: 0;
        display: flex;
        align-items: center;
    }

    .downloader-option label {
        display: flex;
        align-items: center;
        margin-right: 10px;
}

    .downloader-option input[type="radio"],
    .downloader-option input[type="checkbox"] {
        margin: 0 5px 0 0;
        flex-shrink: 0;
    }

    .downloader-option input[type="text"],
    .downloader-option select {
        margin-left: 5px;
        padding: 6px;
        border-radius: 4px;
        border: 1px solid #ccc;
        flex-grow: 1;
        min-width: 0;
    }

    .downloader-btn {
        background-color: #e0e0e0;
        border: 1px solid #ccc;
        border-radius: 4px;
        padding: 8px 12px;
        cursor: pointer;
        font-size: 1.em;
        margin-top: 5px;
    }

    .downloader-btn:hover {
        background-color: #d0d0d0;
    }

    .collapse-btn {
        position: absolute;
        top: 5px;
        right: 5px;
        background: none;
        border: none;
        cursor: pointer;
        font-size: 1em;
        line-height: 1;
        padding: 0;
        color: #666;
        transition: right 0.3s ease-in-out;
    }

    .pixiv-downloader-panel.collapsed .collapse-btn {
        right: -5px;
    }

    .task-item {
        flex-basis: 100%;
        margin-bottom: 2px;
        padding-bottom: 5px;
    }

    .task-item:last-child {
        border-bottom: none;
    }

    .task-title {
        flex-basis: 100%;
        margin-bottom: 2px;
    }

    .task-status {
        font-size: 0.9em;
        color: #777;
        margin-left: auto;
    }
</style>
`);

    $panel.find('.single-download-btn').off('click').on('click', () => taskPage.start());
    $panel.find('.batch-download-btn').off('click').on('click', () => {
        const selectedScope = $panel.find('select[name="batch_scope"]').val();
        switch (selectedScope) {
            case 'author':
                taskAuthor.start();
                break;
            case 'series':
                taskSeries.start();
                break;
            case 'list':
                taskList.start();
                break;
            case 'favlist':
                taskFavList.start();
                break;
            default:
                try {
                    taskAuthor.check();
                    taskAuthor.start();
                } catch (e) {
                    try {
                        taskSeries.check();
                        taskSeries.start();
                    } catch (e) {
                        try {
                            taskList.check();
                            taskList.start();
                        } catch (e) {
                            try {
                                taskFavList.check();
                                taskFavList.start();
                            } catch (e) {
                                alert(i18n("error_default"));
                            }
                        }
                    }
                }
        }
    });

    $panel.find('.pause-btn').on('click', () => {
        if (taskAuthor.isRunning()) {
            taskAuthor.pause();
        } else if (taskSeries.isRunning()) {
            taskSeries.pause();
        } else if (taskList.isRunning()) {
            taskList.pause();
        } else if (taskFavList.isRunning()) {
            taskFavList.pause();
        }
    });
    $panel.find('.resume-btn').on('click', () => {
        if (taskAuthor.isPaused()) {
            taskAuthor.resume();
        } else if (taskSeries.isPaused()) {
            taskSeries.resume();
        } else if (taskList.isPaused()) {
            taskList.resume();
        } else if (taskFavList.isPaused()) {
            taskFavList.resume();
        }
    });
    $panel.find('.cancel-btn').on('click', () => {
        if (taskAuthor.isRunning() || taskAuthor.isPaused()) {
            taskAuthor.cancel();
        } else if (taskSeries.isRunning() || taskSeries.isPaused()) {
            taskSeries.cancel();
        } else if (taskList.isRunning() || taskList.isPaused()) {
            taskList.cancel();
        } else if (taskFavList.isRunning() || taskFavList.isPaused()) {
            taskFavList.cancel();
        }
    });
})();