哔哩哔哩漫画批量购买脚本使用教程

在哔哩哔哩漫画中连续购买大量章节时,如果逐话点击购买,操作会比较繁琐。

本文介绍一个基于 Tampermonkey 的用户脚本。它可以从当前漫画章节开始,按照指定数量自动调用购买接口,并支持以下功能:

  • 支持通用券和漫读券
  • 手动粘贴漫画章节列表 JSON
  • 自动定位当前章节
  • 每批并发购买 3 话
  • 每批请求之间等待 500ms
  • 购买前显示章节范围并要求二次确认
  • 遇到接口错误时自动停止后续购买

需要特别说明的是,该脚本执行的是真实购买操作,会消耗账号中的通用券或漫读券。首次使用时,建议先购买 1~3 话进行测试。


源码

// ==UserScript==
// @name         哔哩哔哩漫画批量购买(手动导入列表)
// @namespace    无笙
// @version      1.3
// @description  手动粘贴 ComicDetail 接口 JSON,每批并发购买 3 话,批次间隔 500ms
// @author       无笙
// @include      http*://manga.bilibili.com/*
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @license      MIT
// ==/UserScript==

(function () {
    "use strict";

    /**
     * 每批并发购买数量。
     */
    const BATCH_SIZE = 3;

    /**
     * 每批购买完成后的延迟时间,单位毫秒。
     */
    const BATCH_DELAY = 500;

    GM_registerMenuCommand("批量购买漫画", function () {
        const regex = /https:\/\/manga\.bilibili\.com\/.*?\/(\d+).*/;
        const url = window.location.href;
        const match = url.match(regex);

        if (!match) {
            alert("请在漫画购买页面使用此脚本");
            return;
        }

        const epId = Number.parseInt(match[1], 10);

        if (!Number.isInteger(epId)) {
            alert("无法从当前页面地址获取 ep_id");
            return;
        }

        console.log("当前 ep_id:", epId);

        const count = Number.parseInt(
            window.prompt("请输入需要购买的话数"),
            10
        );

        if (!Number.isInteger(count) || count <= 0) {
            return;
        }

        const buyMethod = Number.parseInt(
            window.prompt("请输入购买方式:1漫读券"),
            10
        );

        if (![1].includes(buyMethod)) {
            alert("购买方式只能填写1");
            return;
        }

        getEpisodeBuyInfo(epId, count, buyMethod);
    });

    /**
     * 获取当前漫画的购买信息。
     */
    function getEpisodeBuyInfo(epId, count, buyMethod) {
        GM_xmlhttpRequest({
            url: "https://manga.bilibili.com/twirp/comic.v1.Comic/GetEpisodeBuyInfo?device=pc&platform=web",
            method: "POST",

            data: JSON.stringify({
                ep_id: epId
            }),

            headers: {
                "Content-Type": "application/json"
            },

            onerror: function (xhr) {
                console.error("GetEpisodeBuyInfo 请求错误:", xhr);
                alert("获取当前漫画信息失败");
            },

            onload: async function (response) {
                let result;

                try {
                    result = JSON.parse(response.responseText);
                } catch (error) {
                    console.error(
                        "GetEpisodeBuyInfo 返回内容解析失败:",
                        error,
                        response.responseText
                    );

                    alert("获取当前漫画信息失败:返回内容不是合法 JSON");
                    return;
                }

                if (result.code !== 0) {
                    alert(
                        "获取当前漫画信息失败:" +
                        (result.msg || `错误码 ${result.code}`)
                    );
                    return;
                }

                const couponId = result.data?.recommend_coupon_id;
                const comicId = result.data?.comic_id;

                console.log("coupon_id:", couponId);
                console.log("comic_id:", comicId);

                try {
                    const jsonText = await showJsonInputDialog();
                    const list = extractEpisodeList(jsonText);

                    handleEpisodeList(
                        couponId,
                        list,
                        epId,
                        count,
                        buyMethod
                    );
                } catch (error) {
                    if (error.message === "USER_CANCELLED") {
                        return;
                    }

                    console.error("解析漫画列表失败:", error);
                    alert("解析漫画列表失败:" + error.message);
                }
            }
        });
    }

    /**
     * 处理手动输入的话数列表。
     */
    function handleEpisodeList(
        couponId,
        list,
        epId,
        count,
        buyMethod
    ) {
        const index = list.findIndex(function (episode) {
            return String(episode.id) === String(epId);
        });

        if (index === -1) {
            alert(
                `在粘贴的 JSON 中没有找到当前话 ep_id:${epId}`
            );
            return;
        }

        console.log(
            `找到当前话,列表下标为 ${index},准备从当前话开始购买`
        );

        /*
         * 漫画列表是倒序的。
         * 从当前 index 向 index - 1 的方向购买。
         */
        const availableCount = index + 1;
        const actualCount = Math.min(count, availableCount);

        const startEpisode = getEpisodeName(list[index]);
        const endEpisode = getEpisodeName(
            list[index - actualCount + 1]
        );

        const confirmed = window.confirm(
            [
                "请确认批量购买信息:",
                "",
                `购买方式:${buyMethod === 5 ? "通用券" : "漫读券"}`,
                `输入数量:${count} 话`,
                `实际最多购买:${actualCount} 话`,
                `开始话数:${startEpisode}`,
                `结束话数:${endEpisode}`,
                `请求方式:每批并发 ${BATCH_SIZE} 个`,
                `批次间隔:${BATCH_DELAY}ms`,
                "",
                "该操作会实际消耗账户中的券,是否继续?"
            ].join("\n")
        );

        if (!confirmed) {
            return;
        }

        buy(
            couponId,
            list,
            index,
            actualCount,
            buyMethod
        );
    }

    /**
     * 从粘贴的 JSON 中提取 ep_list。
     *
     * 支持以下格式:
     *
     * 1. 完整接口响应:
     *    {
     *        "code": 0,
     *        "data": {
     *            "ep_list": []
     *        }
     *    }
     *
     * 2. data 对象:
     *    {
     *        "ep_list": []
     *    }
     *
     * 3. ep_list 数组:
     *    []
     */
    function extractEpisodeList(jsonText) {
        let text = String(jsonText || "").trim();

        /*
         * 允许粘贴 Markdown JSON 代码块。
         */
        text = text
            .replace(/^```(?:json)?\s*/i, "")
            .replace(/\s*```$/, "")
            .trim();

        if (!text) {
            throw new Error("没有输入 JSON");
        }

        let parsed;

        try {
            parsed = JSON.parse(text);
        } catch (error) {
            throw new Error(
                `JSON 格式不正确:${error.message}`
            );
        }

        /*
         * 兼容被转义成字符串的 JSON。
         */
        if (typeof parsed === "string") {
            try {
                parsed = JSON.parse(parsed);
            } catch (error) {
                throw new Error(
                    `JSON 字符串二次解析失败:${error.message}`
                );
            }
        }

        let list;

        if (Array.isArray(parsed)) {
            list = parsed;
        } else if (Array.isArray(parsed?.ep_list)) {
            list = parsed.ep_list;
        } else if (Array.isArray(parsed?.data?.ep_list)) {
            list = parsed.data.ep_list;
        } else {
            throw new Error(
                "没有找到 ep_list,请粘贴 ComicDetail 接口的完整响应"
            );
        }

        if (list.length === 0) {
            throw new Error("ep_list 是空数组");
        }

        const invalidEpisode = list.find(function (episode) {
            return (
                !episode ||
                episode.id === undefined ||
                episode.id === null
            );
        });

        if (invalidEpisode) {
            throw new Error(
                "ep_list 中存在缺少 id 的话数数据"
            );
        }

        console.log(`成功读取 ${list.length} 条话数数据`);

        return list;
    }

    /**
     * 显示用于粘贴 JSON 的输入窗口。
     */
    function showJsonInputDialog() {
        return new Promise(function (resolve, reject) {
            const overlay = document.createElement("div");

            Object.assign(overlay.style, {
                position: "fixed",
                left: "0",
                top: "0",
                right: "0",
                bottom: "0",
                zIndex: "2147483647",
                background: "rgba(0, 0, 0, 0.65)",
                display: "flex",
                alignItems: "center",
                justifyContent: "center",
                padding: "20px",
                boxSizing: "border-box"
            });

            const dialog = document.createElement("div");

            Object.assign(dialog.style, {
                width: "900px",
                maxWidth: "100%",
                maxHeight: "90vh",
                background: "#ffffff",
                borderRadius: "10px",
                padding: "20px",
                boxSizing: "border-box",
                boxShadow: "0 10px 40px rgba(0, 0, 0, 0.35)",
                color: "#222222"
            });

            const title = document.createElement("div");
            title.textContent = "粘贴漫画列表 JSON";

            Object.assign(title.style, {
                fontSize: "20px",
                fontWeight: "bold",
                marginBottom: "10px"
            });

            const description = document.createElement("div");
            description.textContent =
                "请粘贴 ComicDetail 接口返回的完整 JSON,也可以只粘贴 data 对象或 ep_list 数组。";

            Object.assign(description.style, {
                fontSize: "14px",
                lineHeight: "1.6",
                marginBottom: "12px",
                color: "#666666"
            });

            const textarea = document.createElement("textarea");

            textarea.placeholder =
                '{"code":0,"data":{"ep_list":[...]}}';

            Object.assign(textarea.style, {
                width: "100%",
                height: "55vh",
                minHeight: "300px",
                resize: "vertical",
                boxSizing: "border-box",
                border: "1px solid #cccccc",
                borderRadius: "6px",
                padding: "12px",
                fontFamily: "Consolas, Monaco, monospace",
                fontSize: "13px",
                lineHeight: "1.5",
                outline: "none"
            });

            const buttonArea = document.createElement("div");

            Object.assign(buttonArea.style, {
                display: "flex",
                justifyContent: "flex-end",
                gap: "10px",
                marginTop: "15px"
            });

            const cancelButton = createButton(
                "取消",
                "#eeeeee",
                "#333333"
            );

            const submitButton = createButton(
                "解析并开始购买",
                "#fb7299",
                "#ffffff"
            );

            function closeDialog() {
                document.removeEventListener(
                    "keydown",
                    handleKeydown
                );

                overlay.remove();
            }

            function cancel() {
                closeDialog();
                reject(new Error("USER_CANCELLED"));
            }

            function submit() {
                const value = textarea.value.trim();

                if (!value) {
                    alert("请先粘贴 JSON");
                    textarea.focus();
                    return;
                }

                closeDialog();
                resolve(value);
            }

            function handleKeydown(event) {
                if (event.key === "Escape") {
                    cancel();
                    return;
                }

                if (
                    event.key === "Enter" &&
                    (event.ctrlKey || event.metaKey)
                ) {
                    submit();
                }
            }

            cancelButton.addEventListener("click", cancel);
            submitButton.addEventListener("click", submit);

            overlay.addEventListener("click", function (event) {
                if (event.target === overlay) {
                    cancel();
                }
            });

            document.addEventListener(
                "keydown",
                handleKeydown
            );

            buttonArea.append(
                cancelButton,
                submitButton
            );

            dialog.append(
                title,
                description,
                textarea,
                buttonArea
            );

            overlay.appendChild(dialog);
            document.body.appendChild(overlay);

            textarea.focus();
        });
    }

    /**
     * 创建按钮。
     */
    function createButton(text, background, color) {
        const button = document.createElement("button");

        button.textContent = text;

        Object.assign(button.style, {
            border: "none",
            borderRadius: "6px",
            padding: "9px 18px",
            background: background,
            color: color,
            cursor: "pointer",
            fontSize: "14px"
        });

        return button;
    }

    /**
     * 获取话数名称。
     */
    function getEpisodeName(episode) {
        if (!episode) {
            return "未知";
        }

        return (
            episode.short_title ||
            episode.title ||
            episode.ord ||
            `ep_id=${episode.id}`
        );
    }

    /**
     * 批量购买漫画。
     *
     * 每批最多同时发送 3 个购买请求。
     * 当前批次全部完成后,等待 500ms,再执行下一批。
     */
    async function buy(
        couponId,
        list,
        index,
        count,
        buyMethod
    ) {
        let remainingCount = count;
        let currentIndex = index;
        let successCount = 0;

        console.log("开始批量购买:", {
            couponId,
            index,
            count,
            buyMethod,
            batchSize: BATCH_SIZE,
            batchDelay: BATCH_DELAY
        });

        while (
            remainingCount > 0 &&
            currentIndex >= 0
        ) {
            /*
             * 当前批次实际请求数量。
             */
            const currentBatchSize = Math.min(
                BATCH_SIZE,
                remainingCount,
                currentIndex + 1
            );

            const batchEpisodes = [];

            for (let i = 0; i < currentBatchSize; i++) {
                const episodeIndex = currentIndex - i;
                const episode = list[episodeIndex];

                batchEpisodes.push({
                    episode,
                    index: episodeIndex
                });
            }

            console.log(
                `开始执行当前批次,共 ${batchEpisodes.length} 话:`,
                batchEpisodes.map(function (item) {
                    return {
                        index: item.index,
                        ep_id: item.episode?.id,
                        title: getEpisodeName(item.episode)
                    };
                })
            );

            /*
             * 同一批次的请求并发执行。
             */
            const results = await Promise.all(
                batchEpisodes.map(function (item) {
                    return buyOneEpisode(
                        item.episode,
                        buyMethod
                    );
                })
            );

            const successfulResults = results.filter(function (result) {
                return result.success;
            });

            const failedResults = results.filter(function (result) {
                return !result.success;
            });

            const currentSuccessCount = successfulResults.length;

            successCount += currentSuccessCount;

            /*
             * 本批次中的请求均已执行,因此移动到下一批。
             */
            currentIndex -= batchEpisodes.length;

            /*
             * 成功的话数从剩余数量中扣除。
             */
            remainingCount -= currentSuccessCount;

            results.forEach(function (result) {
                if (result.success) {
                    console.log(
                        `购买成功:${getEpisodeName(result.episode)},` +
                        `ep_id=${result.episode.id}`
                    );
                } else {
                    console.error(
                        `购买失败:${getEpisodeName(result.episode)},` +
                        `ep_id=${result.episode?.id ?? "未知"},` +
                        `原因=${result.message}`
                    );
                }
            });

            /*
             * 当前批次存在失败时,停止后续购买。
             */
            if (failedResults.length > 0) {
                const failedMessage = failedResults
                    .map(function (result) {
                        return (
                            `${getEpisodeName(result.episode)}:` +
                            result.message
                        );
                    })
                    .join("\n");

                alert(
                    [
                        "当前批次存在购买失败,已停止后续购买。",
                        "",
                        failedMessage,
                        "",
                        `本次成功购买:${successCount} 话`,
                        `尚未成功购买:${remainingCount} 话`
                    ].join("\n")
                );

                return;
            }

            console.log(
                `当前批次购买完成,累计成功 ${successCount} 话,` +
                `还剩 ${remainingCount} 话`
            );

            /*
             * 还有下一批时,等待 500ms。
             */
            if (
                remainingCount > 0 &&
                currentIndex >= 0
            ) {
                console.log(
                    `等待 ${BATCH_DELAY}ms 后执行下一批`
                );

                await sleep(BATCH_DELAY);
            }
        }

        if (remainingCount > 0) {
            alert(
                [
                    "已经到达漫画列表开头。",
                    `成功购买:${successCount} 话`,
                    `剩余未购买:${remainingCount} 话`
                ].join("\n")
            );

            return;
        }

        alert(
            `购买完成,共成功购买 ${successCount} 话`
        );
    }

    /**
     * 购买单独一话。
     */
    function buyOneEpisode(episode, buyMethod) {
        return new Promise(function (resolve) {
            if (
                !episode ||
                episode.id === undefined ||
                episode.id === null
            ) {
                resolve({
                    success: false,
                    episode: episode || {},
                    message: "话数数据无效或缺少 ep_id"
                });

                return;
            }

            console.log(
                `发送购买请求:${getEpisodeName(episode)},` +
                `ep_id=${episode.id}`
            );

            GM_xmlhttpRequest({
                url: "https://manga.bilibili.com/twirp/comic.v1.Comic/BuyEpisode?device=pc&platform=web",
                method: "POST",

                data: JSON.stringify({
                    buy_method: buyMethod,
                    ep_id: episode.id
                }),

                headers: {
                    "Content-Type": "application/json"
                },

                onerror: function (xhr) {
                    console.error(
                        "BuyEpisode 请求错误:",
                        xhr
                    );

                    resolve({
                        success: false,
                        episode,
                        message: "网络请求失败"
                    });
                },

                onload: function (response) {
                    let result;

                    try {
                        result = JSON.parse(
                            response.responseText
                        );
                    } catch (error) {
                        console.error(
                            "购买接口返回内容解析失败:",
                            error,
                            response.responseText
                        );

                        resolve({
                            success: false,
                            episode,
                            message: "接口返回内容不是合法 JSON"
                        });

                        return;
                    }

                    if (result.code !== 0) {
                        resolve({
                            success: false,
                            episode,
                            message:
                                result.msg ||
                                `接口错误码 ${result.code}`
                        });

                        return;
                    }

                    resolve({
                        success: true,
                        episode,
                        message: ""
                    });
                }
            });
        });
    }

    /**
     * 延迟指定毫秒数。
     */
    function sleep(milliseconds) {
        return new Promise(function (resolve) {
            setTimeout(resolve, milliseconds);
        });
    }
})();

一、为什么要手动导入漫画列表

原脚本通过下面的接口获取漫画详情和章节列表:

https://manga.bilibili.com/twirp/comic.v2.Comic/ComicDetail

但接口调整后,直接请求可能需要额外的签名参数。普通用户脚本无法简单构造完整签名,因此新版脚本不再主动请求 ComicDetail 接口,而是让用户手动粘贴接口返回的 JSON。

脚本仍然会自动完成以下操作:

  1. 从当前页面地址中解析 ep_id
  2. 调用 GetEpisodeBuyInfo 获取漫画基础信息
  3. 从用户粘贴的 JSON 中提取 ep_list
  4. 在章节列表中定位当前章节
  5. 按照指定数量批量调用 BuyEpisode
  6. 每批同时购买 3 话
  7. 当前批次完成后等待 500ms,再执行下一批

这种方式绕开了脚本直接请求已增加签名校验的漫画详情接口。


二、准备工作

使用脚本前,需要准备以下环境:

1. 安装浏览器扩展

推荐使用以下任意一种用户脚本管理器:

  • Tampermonkey
  • Violentmonkey

以 Chrome 为例,可以在 Chrome 扩展商店中安装 Tampermonkey。

安装完成后,浏览器右上角会出现 Tampermonkey 图标。

2. 登录哔哩哔哩漫画

打开哔哩哔哩漫画网页并登录自己的账号:

https://manga.bilibili.com/

确认账号中有足够的通用券或漫读券。


三、安装脚本

点击 Tampermonkey 图标,选择:

添加新脚本

删除编辑器中的默认内容,然后粘贴完整脚本。

脚本头部应类似:

// ==UserScript==
// @name         哔哩哔哩漫画批量购买(手动导入列表)
// @namespace    dzj0821
// @version      1.2
// @description  手动粘贴 ComicDetail 接口 JSON,每批并发购买 3 话,批次间隔 500ms
// @include      http*://manga.bilibili.com/*
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// ==/UserScript==

粘贴完成后,按下:

Ctrl + S

保存脚本。

确认脚本已经处于启用状态。


四、准备漫画章节 JSON

新版脚本不会主动调用 ComicDetail 接口,因此使用前需要准备包含 ep_list 的 JSON。

脚本支持三种 JSON 格式。

格式一:完整接口响应

{
  "code": 0,
  "msg": "",
  "data": {
    "ep_list": [
      {
        "id": 123456,
        "title": "第1话",
        "short_title": "1"
      },
      {
        "id": 123457,
        "title": "第2话",
        "short_title": "2"
      }
    ]
  }
}

格式二:只粘贴 data 对象

{
  "ep_list": [
    {
      "id": 123456,
      "title": "第1话"
    },
    {
      "id": 123457,
      "title": "第2话"
    }
  ]
}

格式三:只粘贴 ep_list 数组

[
  {
    "id": 123456,
    "title": "第1话"
  },
  {
    "id": 123457,
    "title": "第2话"
  }
]

其中最重要的字段是:

{
  "id": 123456
}

每个章节对象都必须包含 id,因为脚本最终会把这个值作为 ep_id 发送给购买接口。

其他字段,例如:

title
short_title
ord

主要用于在控制台和确认窗口中显示章节名称,不是购买操作的必要字段。


五、如何使用脚本

第一步:打开漫画章节页面

进入准备购买的漫画,并打开希望作为起点的章节页面。

例如,你准备从第 50 话开始向前购买 20 话,就需要先打开第 50 话对应的页面。

脚本会从当前页面地址中提取章节 ID。

页面地址通常包含一个数字,例如:

https://manga.bilibili.com/mc12345/678901

脚本会将最后的数字识别为当前章节的 ep_id


第二步:打开脚本菜单

点击浏览器右上角的 Tampermonkey 图标。

在脚本菜单中选择:

批量购买漫画

第三步:输入购买数量

脚本会弹出输入框:

请输入需要购买的话数

例如需要购买 20 话,就输入:

20

输入内容必须是大于 0 的整数。


第四步:选择购买方式

接下来脚本会提示:

请输入购买方式:5 通用券,2 漫读券

可填写:

5

表示使用通用券。

填写:

2

表示使用漫读券。

其他数字会被脚本判定为无效购买方式,并停止执行。


第五步:粘贴章节列表 JSON

随后页面会出现一个较大的 JSON 输入窗口。

将准备好的完整接口响应、data 对象或 ep_list 数组粘贴进去。

然后点击:

解析并开始购买

也可以使用快捷键:

Ctrl + Enter

提交 JSON。

按下:

Esc

可以关闭输入窗口并取消操作。


第六步:检查购买确认信息

脚本解析成功后,会显示类似下面的确认内容:

请确认批量购买信息:

购买方式:漫读券
输入数量:20 话
实际最多购买:20 话
开始话数:第50话
结束话数:第31话
请求方式:每批并发3个
批次间隔:500ms

该操作会实际消耗账户中的券,是否继续?

请重点确认:

  • 购买方式是否正确
  • 购买数量是否正确
  • 开始章节是否正确
  • 结束章节是否正确

确认无误后点击“确定”。

点击“取消”则不会发送购买请求。


六、每批 3 个、延迟 500ms 是什么意思

脚本中的两个核心配置为:

const BATCH_SIZE = 3;
const BATCH_DELAY = 500;

其中:

BATCH_SIZE = 3

表示每个批次最多同时发送 3 个购买请求。

BATCH_DELAY = 500

表示当前批次全部完成后,等待 500ms,再执行下一批。

假设需要购买 10 话,脚本的执行过程大致如下:

第1批:同时购买3话
等待当前3个请求全部完成
等待500ms

第2批:同时购买3话
等待当前3个请求全部完成
等待500ms

第3批:同时购买3话
等待当前3个请求全部完成
等待500ms

第4批:购买剩余1话

这里并不是每个请求之间等待 500ms,而是每组请求之间等待 500ms。


七、脚本如何确定购买顺序

哔哩哔哩漫画返回的 ep_list 通常为倒序列表。

脚本会先找到当前页面对应的章节下标:

const index = list.findIndex(function (episode) {
    return String(episode.id) === String(epId);
});

找到当前章节后,再按照:

index - 1

的方向继续购买。

因此,当前脚本依赖输入 JSON 中章节的原始顺序。

不要随意对 ep_list 进行排序,否则可能导致购买方向与预期不一致。

在点击最终确认前,一定要检查开始话数和结束话数是否正确。


八、如何查看脚本运行状态

脚本会将详细执行信息输出到浏览器开发者工具。

可以按下:

F12

然后切换到:

Console

也就是“控制台”标签页。

正常执行时,可以看到类似日志:

当前 ep_id:123456
成功读取 200 条话数数据
找到当前话,列表下标为 50
开始执行当前批次,共 3 话
发送购买请求:第50话
发送购买请求:第49话
发送购买请求:第48话
购买成功:第50话
购买成功:第49话
购买成功:第48话
等待 500ms 后执行下一批

如果某一话购买失败,也会在控制台显示对应的章节 ID和错误信息。


九、常见问题

1. 提示“请在漫画购买页面使用此脚本”

原因通常是当前页面地址中没有识别到章节 ID。

请确认:

  • 当前页面属于 manga.bilibili.com
  • 当前打开的是具体漫画或章节页面
  • 页面地址中包含章节数字 ID

刷新页面后再尝试执行脚本。


2. 提示“没有找到 ep_list”

说明粘贴的 JSON 中没有符合要求的章节数组。

脚本只能识别以下路径:

parsed

本身是数组,或者:

parsed.ep_list

或者:

parsed.data.ep_list

建议优先粘贴完整的 ComicDetail 接口响应。


3. 提示“JSON 格式不正确”

常见原因包括:

  • 少了逗号
  • 多了尾部逗号
  • 使用了中文引号
  • 只复制了部分 JSON
  • JSON 前后夹杂了其他文本

可以先将内容放到 JSON 格式化工具中检查。

标准 JSON 的键名和字符串必须使用英文双引号:

{
  "id": 123456
}

下面这种写法不是合法 JSON:

{
  id: 123456
}

4. 提示“在粘贴的 JSON 中没有找到当前话 ep_id”

说明当前页面中的 ep_id 不在粘贴的章节列表中。

可能原因包括:

  • 粘贴了另一部漫画的 JSON
  • JSON 数据不完整
  • 当前打开的是特殊章节
  • 页面地址解析到的 ID 不是章节 ID
  • 接口结果不是当前漫画的最新结果

请重新确认当前漫画和 JSON 是否对应。


5. 输入购买 20 话,实际最多购买数量少于 20

脚本只会购买当前章节之前仍存在于列表中的章节。

例如当前章节前面只剩 8 话,即使输入 20,确认窗口中也会显示:

实际最多购买:8话

这是为了避免下标越界。


6. 当前批次有一话失败后,为什么脚本停止了

脚本采用了较保守的处理逻辑。

每批最多发出 3 个请求。如果本批次中有任意请求失败,脚本会:

  1. 等待本批次的所有请求结束
  2. 统计成功和失败数量
  3. 显示错误原因
  4. 停止后续批次

这样可以避免在账号状态、券余额或接口状态异常时继续发送大量购买请求。


7. 显示购买失败,但章节可能已经解锁

网络异常或接口响应异常时,存在请求已经成功但脚本没有正确收到结果的可能。

遇到这种情况时,不要立即重复大量购买。

建议先刷新漫画页面,检查章节是否已经解锁,再决定是否继续运行脚本。


8. 可以把每批数量改成其他值吗

可以修改:

const BATCH_SIZE = 3;

例如改成每批 1 个:

const BATCH_SIZE = 1;

改成每批 5 个:

const BATCH_SIZE = 5;

不过不建议设置过大。

并发请求过多可能导致:

  • 请求被限流
  • 接口返回异常
  • 部分购买成功、部分失败
  • 账号触发风控
  • 难以确认实际购买结果

推荐保持每批 3 个。


9. 可以修改 500ms 延迟吗

可以修改:

const BATCH_DELAY = 500;

例如改成 1 秒:

const BATCH_DELAY = 1000;

单位为毫秒:

500ms = 0.5秒
1000ms = 1秒
2000ms = 2秒

如果频繁出现限流或网络错误,可以适当增加延迟,例如设置为:

const BATCH_DELAY = 1000;

十、使用注意事项

1. 先进行小数量测试

首次使用时,建议购买:

1~3话

确认以下内容正常后,再增加购买数量:

  • 章节方向正确
  • 购买方式正确
  • JSON 对应当前漫画
  • 账号券余额正常
  • 购买接口没有异常

2. 不要重复运行

脚本运行期间,不要再次点击“批量购买漫画”。

否则可能同时运行多个购买任务,造成重复请求或不可预测的结果。

3. 不要切换漫画页面

脚本开始执行后,尽量保持当前页面打开。

虽然 GM_xmlhttpRequest 通常可以继续执行,但关闭页面、刷新页面或跳转可能会中断脚本。

4. 确认券的使用范围

部分章节可能不支持某种券,或者存在限免、已购买、锁定等特殊状态。

如果某一话不支持当前购买方式,脚本会在该批次结束后停止。

5. 仅在自己的账号上使用

脚本会使用当前浏览器中的登录状态执行购买。

请勿在公共电脑、他人设备或不可信环境中使用,也不要向他人提供账号 Cookie、登录凭证或完整网络请求头。


十一、技术实现说明

脚本的核心流程可以概括为:

读取当前页面 ep_id
        ↓
获取当前章节购买信息
        ↓
用户手动粘贴 ep_list JSON
        ↓
解析并验证 JSON
        ↓
查找当前章节下标
        ↓
确认购买范围
        ↓
每批并发购买 3 话
        ↓
等待 500ms
        ↓
继续下一批

单话购买请求使用的接口为:

https://manga.bilibili.com/twirp/comic.v1.Comic/BuyEpisode?device=pc&platform=web

请求体结构为:

{
  "buy_method": 2,
  "ep_id": 123456
}

其中:

buy_method = 2

表示漫读券。

buy_method = 5

表示通用券。

脚本使用 Promise.all 并发执行每一批请求:

const results = await Promise.all(
    batchEpisodes.map(function (item) {
        return buyOneEpisode(
            item.episode,
            buyMethod
        );
    })
);

当前批次完成后,通过下面的函数延迟:

function sleep(milliseconds) {
    return new Promise(function (resolve) {
        setTimeout(resolve, milliseconds);
    });
}

调用方式:

await sleep(500);

十二、总结

这个脚本主要解决了两个问题:

第一,漫画详情接口增加签名后,用户脚本无法直接获取完整章节列表,因此改为手动导入 JSON。

第二,大量逐话点击购买效率较低,因此脚本使用“每批 3 话、批次间隔 500ms”的方式自动执行购买。

完整使用步骤可以简化为:

安装 Tampermonkey
→ 安装脚本
→ 打开目标章节
→ 点击批量购买漫画
→ 输入购买数量
→ 输入购买方式
→ 粘贴 ep_list JSON
→ 检查章节范围
→ 确认购买

由于脚本会真实消耗账号中的券,使用前务必仔细检查购买数量、购买方式、起始章节和结束章节。

同时,网页接口和参数可能随平台更新发生变化。如果后续出现接口错误、返回结构改变或购买方式失效,需要根据浏览器控制台中的错误信息重新调整脚本。

本文仅用于用户脚本技术研究与个人操作效率提升。使用时应遵守平台服务协议,并自行承担因接口变化、账号状态、券余额和脚本操作产生的风险。

消息盒子

# 暂无消息 #

只显示最新10条未读和已读信息