哔哩哔哩漫画批量购买脚本使用教程
在哔哩哔哩漫画中连续购买大量章节时,如果逐话点击购买,操作会比较繁琐。
本文介绍一个基于 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。
脚本仍然会自动完成以下操作:
- 从当前页面地址中解析
ep_id - 调用
GetEpisodeBuyInfo获取漫画基础信息 - 从用户粘贴的 JSON 中提取
ep_list - 在章节列表中定位当前章节
- 按照指定数量批量调用
BuyEpisode - 每批同时购买 3 话
- 当前批次完成后等待 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 个请求。如果本批次中有任意请求失败,脚本会:
- 等待本批次的所有请求结束
- 统计成功和失败数量
- 显示错误原因
- 停止后续批次
这样可以避免在账号状态、券余额或接口状态异常时继续发送大量购买请求。
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
→ 检查章节范围
→ 确认购买
由于脚本会真实消耗账号中的券,使用前务必仔细检查购买数量、购买方式、起始章节和结束章节。
同时,网页接口和参数可能随平台更新发生变化。如果后续出现接口错误、返回结构改变或购买方式失效,需要根据浏览器控制台中的错误信息重新调整脚本。
本文仅用于用户脚本技术研究与个人操作效率提升。使用时应遵守平台服务协议,并自行承担因接口变化、账号状态、券余额和脚本操作产生的风险。
.jpg)





