Pixiv Novel Downloader

Download novels from Pixiv

// ==UserScript==
// @name         Pixiv Novel Downloader
// @name:zh-CN   Pixiv 小说下载器
// @namespace    http://tampermonkey.net/
// @version      0.8
// @description:vi  Tải Truyện Novel Cho Pixiv
// @description:zh-CN 从Pixiv下载小说
// @author       TieuThanhNhi
// @license      GPL-3.0
// @include      http*://www.pixiv.net*
// @match        https://www.pixiv.net/*
// @icon         http://www.pixiv.net/favicon.ico
// @require      https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/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
// @description Download novels from Pixiv
// ==/UserScript==

(function () {
    'use strict';

    // Internationalization
    const lang = (window.navigator.language || window.navigator.browserLanguage || "vi-vn").toLowerCase();

    const i18nMap = {
        "vi-vn": {
            "ui_title": "Trình Tải Tiểu Thuyết",
            "ui_dl_page": "Tải xuống Tác phẩm",
            "ui_dl_author": "Tải Xuống Toàn Bộ Tác Giả",
            "ui_dl_series": "Tải Xuống Toàn Bộ Loạt Truyện",
            "ui_dl_list": "Tải Xuống Toàn Bộ Danh Sách",
            "ui_dl_favlist": "Tải Xuống Danh Sách Yêu Thích",
            "ui_start": "BẮT ĐẦU",
            "ui_pause": "TẠM DỪNG",
            "ui_resume": "TIẾP TỤC",
            "ui_retry": "THỬ LẠI",
            "ui_cancel": "HỦY",
            "ui_dl": "Tải xuống",
            "ui_mode": "Chế độ",
            "ui_page": "Trang",
            "ui_all": "Tất Cả",
            "ui_current": "Hiện tại",
            "ui_inludelikes": "Tên Tập Tin Bao Gồm Likes",
            "error_default": "Đã xảy ra sự cố",
            "error_notpage": "Đây không phải trang tiểu thuyết.",
            "error_notauthor": "Đây không phải trang tác giả.",
            "error_notseries": "Đây không phải trang loạt truyện.",
            "error_notlist": "Đây không phải trang danh sách.",
            "error_notfavlist": "Đây không phải trang yêu thích",
            "txt_title": "Tiêu đề: ",
            "txt_novelid": "ID Tiểu Thuyết: ",
            "txt_author": "Tác giả: ",
            "txt_authorid": "ID Tác Giả: ",
            "txt_words": "Số Từ: ",
            "txt_likes": "Likes: ",
            "txt_createtime": "Thời Gian Tạo: ",
            "txt_updatetime": "Thời Gian Cập Nhật: ",
            "txt_tags": "Từ Khóa: ",
            "txt_desc": "Mô Tả: ",
            "txt_words2": "Từ",
            "txt_likes2": "Likes",
            "txt_pageno": "Trang {0}",
            "txt_fav": "Yêu Thích",
        },
        "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": "下载",
            "ui_mode": "模式",
            "ui_page": "页",
            "ui_all": "全部",
            "ui_current": "当前",
            "ui_inludelikes": "文件命名包含喜欢数",
            "error_default": "出错了",
            "error_notpage": "该页不是小说页。",
            "error_notauthor": "该页不是作者主页。",
            "error_notseries": "该页不是系列页。",
            "error_notlist": "该页不是列表页。",
            "error_notfavlist": "该页不是收藏列表。",
            "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": "收藏",
        },
    };
    const i18n = (key, ...args) => {
        let str = (i18nMap[lang] && i18nMap[lang][key]) || i18nMap["zh-cn"][key];
        args.forEach((value, index) => {
            str = str.replace(`{${index}}`, value);
        });
        return str;
    };

    // Constants
    const website = "pixiv";
    const fontFamily = "Arial, 'Microsoft Yahei', Helvetica, sans-serif";
    const noop = () => { };

    // jQuery UI Panel
    const $panel = jQuery(`
        <div class="pnd-panel">
            <h3>${i18n("ui_title")}</h3>
            <div class="pnd-group">
                <label>
                    ${i18n("ui_dl")}:
                    <input type="radio" name="dl_mode" value="all" checked>${i18n("ui_all")}
                    <input type="radio" name="dl_mode" value="page">${i18n("ui_current")}
                </label>
            </div>
            <div class="pnd-group">
                <label>
                    ${i18n("ui_inludelikes")}:
                    <input type="checkbox" name="dl_includelikes" value="1">
                </label>
            </div>
            <div class="pnd-tasks"></div>
        </div>
    `).css({
        position: "fixed",
        left: 0,
        bottom: 50,
        zIndex: 999999,
        background: "#fff",
        color: "#333",
        fontSize: 16,
        fontFamily: fontFamily,
        padding: 10,
        borderRadius: 6,
        boxShadow: "0 0 10px rgba(0,0,0,0.3)",
    })
        .appendTo(jQuery("body"));

    // Utility Functions
    function baseRequest(config) {
        return new Promise((resolve, reject) => {
            jQuery.ajax({
                timeout: 5000,
                ...config,
                success: (response) => {
                    resolve(response);
                },
                error: () => {
                    reject(new Error(i18n("error_default")));
                },
            });
        });
    }

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

            return body;
        });
    }

    // 过滤文件名非法字符
    function filterFilename(filename) {
        return filename.replace(/[\?*\:"<>\\\/\|]/g, "");
    }

    function wait(delay, ctrl = {}) {
        return new Promise((resolve, reject) => setTimeout(resolve, delay));
    }

    // Task Class
    class Task {
        title = "";
        $item = null;

        // unstarted=''; running; paused; error.
        status = "";

        // 文件命名包含喜欢数
        includeLikes = false;

        constructor(title) {
            this.title = title;
            this.start = this.start.bind(this);
            this.pause = this.pause.bind(this);
            this.resume = this.resume.bind(this);
            this.retry = this.retry.bind(this);
            this.cancel = this.cancel.bind(this);
            this.errorHandler = this.errorHandler.bind(this);
            this.init();
        }
        init() {
            const $item = jQuery(`
                <div class="pnd-task">
                    <span class="pnd-task-title">${i18n(this.title)}</span>
                    <button class="start pnd-btn pnd-btn-primary">${i18n("ui_start")}</button>
                    <button class="pause pnd-btn pnd-btn-warning">${i18n("ui_pause")}</button>
                    <button class="resume pnd-btn pnd-btn-success">${i18n("ui_resume")}</button>
                    <button class="retry pnd-btn pnd-btn-danger">${i18n("ui_retry")}</button>
                    <button class="cancel pnd-btn pnd-btn-secondary">${i18n("ui_cancel")}</button>
                    <span class="status"> - 
                        <span class="current">-</span> / 
                        <span class="total">-</span> 
                        (${i18n("ui_page")}: <span class="page">-</span>)
                    </span>
                </div>
            `).appendTo($panel.find('.pnd-tasks'));

            this.$item = $item;
            this.$start = $item.find(".start").on("click", this.start);
            this.$pause = $item.find(".pause").hide().on("click", this.pause);
            this.$resume = $item.find(".resume").hide().on("click", this.resume);
            this.$retry = $item.find(".retry").hide().on("click", this.retry);
            this.$cancel = $item.find(".cancel").hide().on("click", this.cancel);
            this.$status = $item.find(".status").hide();
            this.$currentStatus = $item.find(".status .current");
            this.$pageStatus = $item.find(".status .page");
            this.$totalStatus = $item.find(".status .total");
        }
        start() {
            this.status = "running";
            this.includeLikes = jQuery("input[name='dl_includelikes']:checked").prop('checked');
            this.$start.hide();
            this.$pause.show();
            this.$resume.hide();
            this.$retry.hide();
            this.$cancel.show();
            this.$status.hide();
        }
        pause() {
            this.status = "paused";
            this.$start.hide();
            this.$pause.hide();
            this.$resume.show();
            this.$retry.hide();
            this.$cancel.show();
            this.$status.show();
        }
        resume() {
            this.status = "running";
            this.$start.hide();
            this.$pause.show();
            this.$resume.hide();
            this.$retry.hide();
            this.$cancel.show();
            this.$status.show();
        }
        error() {
            this.status = "error";
            this.$start.hide();
            this.$pause.hide();
            this.$resume.hide();
            this.$retry.show();
            this.$cancel.show();
            this.$status.show();
        }
        retry() {
            this.status = "running";
            this.$start.hide();
            this.$pause.show();
            this.$resume.hide();
            this.$retry.hide();
            this.$cancel.show();
            this.$status.show();
        }
        cancel() {
            this.status = "";
            this.$start.show();
            this.$pause.hide();
            this.$resume.hide();
            this.$retry.hide();
            this.$cancel.hide();
            this.$status.hide();
        }
        isRunning() {
            return this.status === "running";
        }
        checkRunning() {
            if (!this.isRunning()) {
                throw new Error("CANCEL");
            }
        }
        errorHandler(e) {
            if (e.message === "CANCEL") {
                return;
            }
            this.error();
            console.trace(e);
            alert(e);
        }
        getWork(id) {
            return request({
                url: `/ajax/novel/${id}`,
                responseType: "json",
            }).then((body) => {
                let title = [];
                let output = [];

                title.push(`[${body.userName}]`);
                title.push(`[${website}]`);
                title.push(`[${body.id}]`);
                if (body.xRestrict === 1) {
                    title.push("[R18]");
                } else if (body.xRestrict === 2) {
                    title.push("[R18G]");
                }
                title.push(`[${body.title}]`);
                title.push(`[${body.content.length}${i18n("txt_words2")}]`);
                if (this.includeLikes) {
                    title.push(`[${body.likeCount}${i18n("txt_likes2")}]`);
                }

                output.push(i18n("txt_title") + body.title);
                output.push(i18n("txt_novelid") + body.id);
                output.push(i18n("txt_author") + body.userName);
                output.push(i18n("txt_authorid") + body.userId);
                output.push(i18n("txt_words") + body.content.length);
                output.push(i18n("txt_likes") + body.likeCount);
                output.push(i18n("txt_createtime") + body.createDate);
                output.push(i18n("txt_updatetime") + body.uploadDate);
                output.push(
                    i18n("txt_tags") +
                    body.tags.tags
                        .map(function (tag) {
                            if (tag.userId === body.userId) {
                                return "#" + tag.tag;
                            }
                            return "(#" + tag.tag + ")";
                        })
                        .join(" ")
                );
                output.push("");
                output.push("");
                output.push(i18n("txt_desc"));
                output.push(body.description.replace(/\r\n|\n|\r/g, "\n"));
                output.push("");
                output.push("");
                output.push("");
                output.push("");

                let pageCount = 1;
                output.push(
                    body.content
                        .replace(/\\n/g, "\n")
                        .replace(/\[jump:(\d+)\]/g, (_, $1) => {
                            return `[${i18n("txt_pageno", $1)}]`;
                        })
                        .replace(/\[newpage\]/g, () => {
                            return `\n\n[${i18n("txt_pageno", ++pageCount)}]\n\n`;
                        })
                );

                const filename = filterFilename(title.join("")) + ".txt";
                const content = output.join("\n");

                return {
                    filename,
                    content,
                };
            });
        }
    }

    // Multi-Page Task Class
    class TaskMultiPage extends Task {
        pageParam = "p";
        offsetParam = "offset";
        limitParam = "limit";
        defaultParams = {};

        // 当前页
        page = 1;
        // 当前页完成数量
        finished = 0;
        // 每页数量
        limit = 24;
        // 作品总数
        total = 0;
        // 总页数
        pages = 0;
        // 下载模式
        mode = "all";
        // 下载阶段 list=列表 works=作品
        step = "";

        url = null;
        params = null;
        promise = null;
        ids = null;
        entries = null;

        getUrl() {
            return "";
        }

        getSaveFilename() {
            return "";
        }

        check() { }

        start() {
            try {
                this.check();
            } catch (e) {
                alert(e);
                return;
            }
            super.start();

            this.mode = jQuery("input[name='dl_mode']:checked").val();

            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.getInitData().then(() => this.getNextList());
        }

        resume() {
            super.resume();
            this.resumeOrRetry();
        }

        retry() {
            super.retry();
            this.resumeOrRetry();
        }

        resumeOrRetry() {
            if (this.step === "works") {
                this.getWorks();
            } else {
                this.getNextList();
            }
        }

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

        getInitData() {
            return Promise.resolve();
        }

        getNextList() {
            if (!this.isRunning()) {
                return;
            }
            this.step = "list";
            this.setParams();

            this.promise = this.getList()
                .then(({
                    data = [], total
                }) => {
                    this.checkRunning();

                    this.total = total;
                    this.pages = Math.ceil(total / this.limit);
                    this.finished = 0;
                    this.entries = {};
                    this.updateStatus();

                    if (data.length <= 0) {
                        if (this.mode === 'all' && this.page < this.pages) {
                            this.page++;
                            this.getNextList();
                        } else {
                            this.cancel();
                        }
                        return;
                    }

                    const ids = (this.ids = new Set());
                    data.forEach((item) => ids.add(item.id));

                    this.getWorks();
                })
                .catch(this.errorHandler);
        }

        getList() {
            this.setParams();
            return request({
                url: this.url,
                data: this.params,
                method: "get",
                responseType: "json",
            }).then((body) => {
                this.checkRunning();
                return this.parseList(body);
            });
        }

        parseList(payload) {
            return payload;
        }

        getWorks() {
            if (!this.isRunning()) {
                return;
            }
            this.step = "works";

            const { ids } = this;

            this.promises = Array.from(ids).map((id) => {
                return this.getWork(id)
                    .then((work) => {
                        this.checkRunning();
                        this.finished++;
                        this.ids.delete(id);
                        this.entries[id] = work;
                        this.updateStatus();
                        return wait(100);
                    })
                    .catch(this.errorHandler);
            });

            Promise.all(this.promises)
                .then(() => {
                    if (!this.isRunning()) {
                        return;
                    }

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

                    if (hasFile) {
                        this.savedPage = this.page;

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

                    if (this.mode === "all" && this.page < this.pages) {
                        this.page++;
                        this.getNextList();
                    } else {
                        this.cancel();
                    }
                })
                .catch(this.errorHandler);
        }

        updateStatus() {
            this.$status.show();

            const { finished, limit, total, page, pages } = this;
            let curPageTotal = limit;
            if (page === pages) {
                curPageTotal = total - limit * (page - 1);
            }

            this.$currentStatus.html(`${finished}/${curPageTotal}`);
            this.$pageStatus.html(
                `${page}/${pages}`
            );
            this.$totalStatus.html(`${total}`);
        }
    }

    // Single Page Task Class
    class TaskPage extends Task {
        promise = null;

        init() {
            super.init();
            this.$pause.remove();
            this.$resume.remove();
            this.$retry.remove();
            this.$pageStatus.parent().remove();
        }

        start() {
            // https://www.pixiv.net/novel/show.php?id=作品id
            const exec = /\/novel\/show.php\?id=(.+)$/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 (!this.isRunning()) {
                        return;
                    }
                    this.cancel();
                    alert(e.message);
                });
        }
    }

    // Author Task Class
    class TaskAuthor extends TaskMultiPage {
        defaultParams = {
            limit: 24,
            last_order: 0,
            order_by: "asc",
            lang: "zh",
        };
        id = "";
        limit = 24;
        tag = "";
        userName = "";
        workIds = null;
        total = 0;

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

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

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

        getInitData() {
            // /ajax/user/44820588?full=1&lang=zh
            let infoPromise = request({
                url: `/ajax/user/${this.id}`,
                method: "get",
                data: {
                    full: 1,
                    lang: "zh",
                },
            }).then((payload) => {
                this.userName = payload.name;
            });

            let workPromise = request({
                url: `/ajax/user/${this.id}/profile/all`,
                method: "get",
                data: {
                    lang: "zh",
                },
            }).then((payload) => {
                const { novels } = payload;
                this.workIds = Object.keys(novels).sort((a, b) => b - a);
                this.total = this.workIds.length;
            });

            return Promise.all([infoPromise, workPromise]);
        }

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

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

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

            return {
                total: this.total,
                data: this.workIds.slice(0, this.limit).map((id) => {
                    return { id };
                }),
            };
        }

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

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

        getSaveFilename() {
            const date = new Date().toISOString().substring(0, 10);
            let arr = [];

            arr.push(this.userName);
            if (this.tag) {
                arr.push(this.tag);
            }
            arr.push("p" + this.savedPage);
            arr.push(date);

            return filterFilename(arr.join("_")) + ".zip";
        }
    }

    // Series Task Class
    class TaskSeries extends TaskMultiPage {
        defaultParams = {
            limit: 10,
            last_order: 0,
            order_by: "asc",
            lang: "zh",
        };
        id = "";
        limit = 10;
        title = "";
        userName = "";
        total = 0;

        check() {
            // https://www.pixiv.net/novel/series/系列id
            const exec = /^\/novel\/series\/(.+)$/i.exec(window.location.pathname);
            if (!exec) {
                throw new Error(i18n("error_notseries"));
            }

            this.id = exec[1];
        }

        getInitData() {
            return request({
                url: "/ajax/novel/series/" + this.id,
                method: "get",
                data: {
                    lang: "zh",
                },
            }).then((payload) => {
                const { title, userName, displaySeriesContentCount } = payload;
                this.title = title;
                this.userName = userName;
                this.total = 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() {
            const date = new Date().toISOString().substring(0, 10);
            return (
                filterFilename(
                    `${this.userName}_${this.id}_${this.title}_p${this.savedPage}_${date}`
                ) + ".zip"
            );
        }
    }

    // List Task Class
    class TaskList extends TaskMultiPage {
        defaultParams = {
            word: "",
            order: "date_d",
            mode: "all",
            p: 1,
            s_mode: "s_tag_full",
            gs: 0,
            lang: "zh",
        };
        tag = "";

        check() {
            // https://www.pixiv.net/tags/标签/
            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;
        }

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

        getUrl() {
            return "/ajax/search/novels/" + encodeURIComponent(this.tag);
        }

        getSaveFilename() {
            const date = new Date().toISOString().substring(0, 10);
            return filterFilename(`${this.tag}_p${this.savedPage}_${date}`) + ".zip";
        }
    }

    // Favorite List Task Class
    class TaskFavList extends TaskMultiPage {
        defaultParams = {
            tag: "",
            offset: 0,
            limit: 24,
            rest: "show",
            lang: "zh",
        };

        userId = "";

        check() {
            // https://www.pixiv.net/users/本人id/bookmarks/novels
            const exec = /^\/users\/(.+)\/bookmarks\/novels$/i.exec(
                window.location.pathname
            );
            if (!exec) {
                throw new Error(i18n("error_notfavlist"));
            }
            this.userId = exec[1];
        }

        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() {
            const date = new Date().toISOString().substring(0, 10);
            return (
                filterFilename(`${i18n("txt_fav")}_p${this.savedPage}_${date}`) + ".zip"
            );
        }
    }

    // Initialize Tasks
    jQuery(function ($) {
        new TaskPage("ui_dl_page");
        new TaskAuthor("ui_dl_author");
        new TaskSeries("ui_dl_series");
        new TaskList("ui_dl_list");
        new TaskFavList("ui_dl_favlist");
    });
})();