Merge pull request #19 from huangxd-/dev

添加环境变量BILIBILI_COOKIE,用于获取完整b站弹幕
This commit is contained in:
huangxd- authored and GitHub committed 2025-09-20 08:58:08 +08:00
commit 3133bdebcb
4 files changed
+251 -59

No files matched your search

+18 -9
View File
@@ -1,7 +1,7 @@
# LogVar 弹幕 API 服务器
[![GitHub](https://img.shields.io/badge/-GitHub-181717?logo=github)](https://github.com/huangxd-/damnu_api)
![GitHub License](https://img.shields.io/github/license/huangxd-/damnu_api)
![GitHub License](https://img.shields.io/github/license/huangxd-/danmu_api)
![Docker Pulls](https://img.shields.io/docker/pulls/logvar/danmu-api)
[![telegram](https://img.shields.io/static/v1?label=telegram&message=telegram_channel&color=blue)](https://t.me/logvar_danmu_channel)
[![telegram](https://img.shields.io/static/v1?label=telegram&message=telegram_group&color=blue)](https://t.me/logvar_danmu_group)
@@ -10,9 +10,9 @@
本项目仅为个人爱好开发,代码开源。如有任何侵权行为,请联系本人删除。
有问题提issue或私信机器人都ok。https://t.me/ddjdd_bot
有问题提issue或 [私信机器人](https://t.me/ddjdd_bot) 都ok。
新加了tg频道 (https://t.me/logvar_danmu_channel) ,方便发送更新通知,以及群组,太多人私信咨询了,索性增加一个互助群 (https://t.me/logvar_danmu_group) ,大家有问题可以在群里求助。请点击上面蓝色的按钮进行添加关注。
新加了 [tg频道](https://t.me/logvar_danmu_channel) ,方便发送更新通知,以及群组,太多人私信咨询了,索性增加一个 [互助群](https://t.me/logvar_danmu_group) ,大家有问题可以在群里求助。
## 功能
- **API 接口**:
@@ -124,6 +124,7 @@
### 优化点
Settings > Functions > Advanced Setting > Function Region 切换为 Hong Kong,能提高访问速度,体验更优
> hk有可能访问不了360,也可以尝试切其他region,如新加坡等
## 部署到 腾讯云 edgeone pages 【推荐】
@@ -134,7 +135,9 @@ Settings > Functions > Advanced Setting > Function Region 切换为 Hong Kong,
>
> 示例请求:`https://{your_domian}/{TOKEN}/api/v2/search/anime?keyword=子夜归`确认是否部署成功
>
> 部署的时候项目加速区域最好设置为"全球可用区(不含中国大陆)",不然不绑定自定义域名貌似只能生成3小时的预览链接?https://edgeone.cloud.tencent.com/pages/document/175191784523485184
> 部署的时候项目加速区域最好设置为"全球可用区(不含中国大陆)",不然不绑定自定义域名貌似只能生成3小时的预览链接?[相关文档](https://edgeone.cloud.tencent.com/pages/document/175191784523485184)
>
> 也可直接用国际站的部署按钮一键部署,默认选择"全球可用区(不含中国大陆)" [![使用 EdgeOne Pages 部署](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://edgeone.ai/pages/new?template=https://github.com/huangxd-/danmu_api&project-name=danmu-api&root-directory=.%2F&env=TOKEN)
>
<img src="https://i.mji.rip/2025/09/17/3a675876dabb92e4ce45c10d543ce66b.png" style="width:400px" />
@@ -157,7 +160,8 @@ Settings > Functions > Advanced Setting > Function Region 切换为 Hong Kong,
创建一个worker,将`danmu_api/worker.js`里的代码直接拷贝到你创建的`worker.js`里,然后点击部署。
## API食用指南
支持 forward/senplayer 等支持弹幕API的播放器。
支持 forward/senplayer/hills/小幻/yamby/eplayerx/afusekt 等支持弹幕API的播放器。
以`senplayer`为例:
1. 获取到部署之后的API地址,如`http://192.168.1.7:9321/87654321`,其中`87654321`是默认token,前提是没有传TOKEN环境变量
2. 将API地址填入自定义弹幕API,在`设置 - 弹幕设置 - 自定义弹幕API`
@@ -169,12 +173,15 @@ Settings > Functions > Advanced Setting > Function Region 切换为 Hong Kong,
<img src="https://i.mji.rip/2025/09/14/9fdf945fb247994518042691f60d7849.jpeg" style="width:400px" />
<img src="https://i.mji.rip/2025/09/14/dbacc0cf9c8a839f16b8960de1f38f11.jpeg" style="width:400px" />
> 注意:小幻在填写API的时候需要在API后面加上`/api/v2`,如`http://192.168.1.7:9321/87654321/api/v2`
## 环境变量列表
| 变量名称 | 描述 |
| ----------- | ----------- |
| TOKEN | 用户token |
| OTHER_SERVER | 兜底第三方弹幕服务器,如 https://api.danmu.icu |
| VOD_SERVER | vod查询站点,如 https://www.caiji.cyou |
| TOKEN | 【可选】自定义用户token,不填默认为`87654321` |
| OTHER_SERVER | 【可选】兜底第三方弹幕服务器,如 https://api.danmu.icu |
| VOD_SERVER | 【可选】vod查询站点,如 https://www.caiji.cyou |
| BILIBILI_COOKIE | 【可选】b站cookie(填入后能抓取完整弹幕),如 `buvid3=E2BCA ... eao6; theme-avatar-tip-show=SHOWED`,请自行通过浏览器或抓包工具抓取 |
## 项目结构
```
@@ -186,6 +193,9 @@ danmu_api/
│ ├── server.js # 本地node启动脚本
│ ├── worker.js # 主 API 服务器代码
│ ├── worker.test.js # 测试文件
├── node-functions/
│ ├── [[...path]]..js # edgeone pages 所有路由跳转指向index
│ ├── index.js # edgeone pages 中间处理逻辑
├── .gitignore
├── Dockerfile
├── package.json
@@ -202,7 +212,6 @@ danmu_api/
- cloudflare貌似被哔风控了。
- 如果想更换兜底第三方弹幕服务器,请添加环境变量`OTHER_SERVER`,示例`https://api.danmu.icu`。
- 如果想更换vod站点,请添加环境变量`VOD_SERVER`,示例`https://www.caiji.cyou`。
- 会返回所有跟title匹配的影片,所以主要使用场景是手动弹幕搜索场景。
- 推荐vercel和claw部署,cloudflare好像不稳定,当然最稳定还是自己本地docker部署最佳。
### 📈项目 Star 数增长趋势
+169 -18
View File
@@ -1,6 +1,6 @@
// 全局状态(Cloudflare 和 Vercel 都可能重用实例)
// ⚠️ 不是持久化存储,每次冷启动会丢失
const VERSION = "1.0.3";
const VERSION = "1.0.4";
let animes = [];
let episodeIds = [];
let episodeNum = 10001; // 全局变量,用于自增 ID
@@ -39,6 +39,16 @@ function resolveVodServer(env) {
return DEFAULT_VOD_SERVER;
}
const DEFAULT_BILIBILI_COOKIE = ""; // 默认 bilibili cookie
let bilibliCookie = DEFAULT_BILIBILI_COOKIE;
// 这里既支持 Cloudflare env,也支持 Node process.env
function resolveBilibiliCookie(env) {
if (env && env.BILIBILI_COOKIE) return env.BILIBILI_COOKIE; // Cloudflare Workers
if (typeof process !== "undefined" && process.env?.BILIBILI_COOKIE) return process.env.BILIBILI_COOKIE; // Vercel / Node
return DEFAULT_BILIBILI_COOKIE;
}
// 添加元素到 episodeIds:检查 url 是否存在,若不存在则以自增 id 添加
function addEpisode(url, title) {
// 检查是否已存在相同的 url
@@ -320,7 +330,7 @@ async function get360Animes(title) {
return animes;
} catch (error) {
log("error", `get360Animes error: ${error.message}`);
throw error;
return [];
}
}
@@ -355,7 +365,7 @@ async function get360Zongyi(entId, site, year) {
return links;
} catch (error) {
log("error", `get360Animes error: ${error.message}`);
throw error;
return [];
}
}
@@ -443,7 +453,17 @@ function convertToDanmakuJson(contents, platform) {
for (const item of items) {
let attributes, m;
if ("timepoint" in item) {
// 新增:处理新格式的弹幕数据
if ("progress" in item && "mode" in item && "content" in item) {
// 处理新格式的弹幕对象
attributes = [
(item.progress / 1000).toFixed(2), // progress 转换为秒
item.mode || 1,
item.color || 16777215,
`[${platform}]`
].join(",");
m = item.content;
} else if ("timepoint" in item) {
// 处理对象数组输入
attributes = [
parseFloat(item.timepoint).toFixed(2),
@@ -453,6 +473,9 @@ function convertToDanmakuJson(contents, platform) {
].join(",");
m = item.content;
} else {
if (!("p" in item)) {
continue;
}
// 处理 XML 解析后的格式
const pValues = item.p.split(",");
attributes = [
@@ -682,6 +705,89 @@ function md5(message) {
return (wordToHex(a) + wordToHex(b) + wordToHex(c) + wordToHex(d)).toLowerCase();
}
function parseDanmakuBase64(base64) {
const bytes = base64ToBytes(base64);
const elems = [];
let offset = 0;
while (offset < bytes.length) {
// 每个 DanmakuElem 在 elems 列表里是 length-delimited
const key = bytes[offset++];
if (key !== 0x0a) break; // field=1 (elems), wire=2
const [msgBytes, nextOffset] = readLengthDelimited(bytes, offset);
offset = nextOffset;
let innerOffset = 0;
const elem = {};
while (innerOffset < msgBytes.length) {
const tag = msgBytes[innerOffset++];
const fieldNumber = tag >> 3;
const wireType = tag & 0x07;
if (wireType === 0) {
// varint
const [val, innerNext] = readVarint(msgBytes, innerOffset);
innerOffset = innerNext;
switch (fieldNumber) {
case 1: elem.id = val; break;
case 2: elem.progress = val; break;
case 3: elem.mode = val; break;
case 4: elem.fontsize = val; break;
case 5: elem.color = val; break;
case 8: elem.ctime = val; break;
case 9: elem.weight = val; break;
case 11: elem.pool = val; break;
case 13: elem.attr = val; break;
case 15: elem.like_num = val; break;
case 17: elem.dm_type_v2 = val; break;
}
} else if (wireType === 2) {
// length-delimited
const [valBytes, innerNext] = readLengthDelimited(msgBytes, innerOffset);
innerOffset = innerNext;
switch (fieldNumber) {
case 6: elem.midHash = utf8BytesToString(valBytes); break;
case 7: elem.content = utf8BytesToString(valBytes); break;
case 10: elem.action = utf8BytesToString(valBytes); break;
case 12: elem.idStr = utf8BytesToString(valBytes); break;
case 14: elem.animation = utf8BytesToString(valBytes); break;
case 16: elem.color_v2 = utf8BytesToString(valBytes); break;
}
} else {
// 其他类型不常用,忽略
const [_, innerNext] = readVarint(msgBytes, innerOffset);
innerOffset = innerNext;
}
}
elems.push(elem);
}
return elems;
}
function readVarint(bytes, offset) {
let result = 0n;
let shift = 0n;
let pos = offset;
while (true) {
const b = bytes[pos++];
result |= BigInt(b & 0x7f) << shift;
if ((b & 0x80) === 0) break;
shift += 7n;
}
return [Number(result), pos];
}
function readLengthDelimited(bytes, offset) {
const [length, newOffset] = readVarint(bytes, offset);
const start = newOffset;
const end = start + length;
const slice = bytes.slice(start, end);
return [slice, end];
}
// =====================
// 获取腾讯弹幕
// =====================
@@ -1086,7 +1192,7 @@ async function fetchBilibili(inputUrl) {
return [];
}
let title, danmakuUrl;
let title, danmakuUrl, cid, aid, duration;
// 普通投稿视频
if (inputUrl.includes("video/")) {
@@ -1111,6 +1217,7 @@ async function fetchBilibili(inputUrl) {
if (inputUrl.includes("BV")) {
videoInfoUrl = `${api_video_info}?bvid=${path[2]}`;
} else {
aid = path[2].substring(2)
videoInfoUrl = `${api_video_info}?aid=${path[2].substring(2)}`;
}
@@ -1127,7 +1234,8 @@ async function fetchBilibili(inputUrl) {
return [];
}
const cid = data.data.pages[p - 1].cid;
duration = data.data.duration;
cid = data.data.pages[p - 1].cid;
danmakuUrl = `https://comment.bilibili.com/${cid}.xml`;
} catch (error) {
log("error", "请求普通投稿视频信息失败:", error);
@@ -1156,7 +1264,8 @@ async function fetchBilibili(inputUrl) {
for (const episode of data.result.episodes) {
if (episode.id == epid) {
title = episode.share_copy;
const cid = episode.cid;
cid = episode.cid;
duration = episode.duration / 1000;
danmakuUrl = `https://comment.bilibili.com/${cid}.xml`;
break;
}
@@ -1176,18 +1285,59 @@ async function fetchBilibili(inputUrl) {
log("error", "不支持的B站视频网址,仅支持普通视频(av,bv)、剧集视频(ep)");
return [];
}
log("log", danmakuUrl, cid, aid, duration);
const response = await httpGet(danmakuUrl, {
headers: {
"Content-Type": "application/xml",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
},
});
// 计算视频的分片数量
const maxLen = Math.floor(duration / 360) + 1;
log("log", "maxLen: ", maxLen);
const contents = response.data;
printFirst200Chars(contents);
const segmentList = [];
for (let i = 0; i < maxLen; i += 1) {
let danmakuUrl;
if (aid) {
danmakuUrl = `https://api.bilibili.com/x/v2/dm/web/seg.so?type=1&oid=${cid}&pid=${aid}&segment_index=${i + 1}`;
} else {
danmakuUrl = `https://api.bilibili.com/x/v2/dm/web/seg.so?type=1&oid=${cid}&segment_index=${i + 1}`;
}
return convertToDanmakuJson(contents, "bilibili");
segmentList.push({
segment_start: i * 360 * 1000,
segment_end: (i + 1) * 360 * 1000,
url: danmakuUrl,
});
}
// 使用 Promise.all 并行请求所有分片
try {
const allComments = await Promise.all(
segmentList.map(async (segment) => {
log("log", "正在请求弹幕数据...", segment.url);
try {
// 请求单个分片的弹幕数据
let res = await httpGet(segment.url, {
headers: {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
"Cookie": bilibliCookie
},
base64Data: true,
});
return parseDanmakuBase64(res.data);
} catch (error) {
log("error", "请求弹幕数据失败: ", error);
return [];
}
})
);
// 合并所有分片的弹幕数据
const mergedComments = allComments.flat();
return convertToDanmakuJson(mergedComments, "bilibili");
} catch (error) {
log("error", "获取所有弹幕数据时出错: ", error);
return [];
}
}
// =====================
@@ -2836,6 +2986,7 @@ async function handleRequest(req, env) {
token = resolveToken(env); // 每次请求动态获取,确保热更新环境变量后也能生效
otherServer = resolveOtherServer(env);
vodServer = resolveVodServer(env);
bilibliCookie = resolveBilibiliCookie(env);
const url = new URL(req.url);
let path = url.pathname;
@@ -2948,5 +3099,5 @@ export async function vercelHandler(req, res) {
}
// 为了测试导出 handleRequest
export { handleRequest, searchAnime, searchEpisodes, matchAnime, getBangumi, getComment, fetchTencentVideo, fetchIqiyi, fetchMangoTV,
fetchBilibili, fetchYouku, fetchOtherServer, httpGet, httpPost };
export { handleRequest, searchAnime, searchEpisodes, matchAnime, getBangumi, getComment, fetchTencentVideo, fetchIqiyi,
fetchMangoTV, fetchBilibili, fetchYouku, fetchOtherServer, httpGet, httpPost };
+56 -31
View File
@@ -1,7 +1,7 @@
const test = require('node:test');
const assert = require('node:assert').strict;
const { handleRequest, searchAnime, matchAnime, getBangumi, getComment, fetchTencentVideo, fetchIqiyi, fetchMangoTV,
fetchBilibili, fetchYouku, fetchOtherServer, httpGet, httpPost } = require('./worker');
const { handleRequest, searchAnime, matchAnime, searchEpisode, getBangumi, getComment, fetchTencentVideo, fetchIqiyi,
fetchMangoTV, fetchBilibili, fetchYouku, fetchOtherServer, httpGet, httpPost } = require('./worker');
// Mock Request class for testing
class MockRequest {
@@ -52,10 +52,10 @@ test('worker.js API endpoints', async (t) => {
// assert(res.length > 2, `Expected res.length > 2, but got ${res.length}`);
// });
// await t.test('GET bilibili danmu', async () => {
// const res = await fetchBilibili("https://www.bilibili.com/bangumi/play/ep1231564");
// assert(res.length > 2, `Expected res.length > 2, but got ${res.length}`);
// });
await t.test('GET bilibili danmu', async () => {
const res = await fetchBilibili("https://www.bilibili.com/bangumi/play/ep1231564");
assert(res.length > 2, `Expected res.length > 2, but got ${res.length}`);
});
// await t.test('GET youku danmu', async () => {
// const res = await fetchYouku("https://v.youku.com/v_show/id_XNjQ3ODMyNjU3Mg==.html");
@@ -97,30 +97,55 @@ test('worker.js API endpoints', async (t) => {
// assert(commentData.count > 0, `Expected commentData.count > 0, but got ${commentData.count}`);
// });
// 测试 POST /api/v2/match 接口
await t.test('POST /api/v2/match for matching anime', async () => {
// 构造请求体
const requestBody = {
"fileName": "生万物 S01E28",
"fileHash": "1234567890",
"fileSize": 0,
"videoDuration": 0,
"matchMode": "fileNameOnly"
};
// // 测试 POST /api/v2/match 接口
// await t.test('POST /api/v2/match for matching anime', async () => {
// // 构造请求体
// const requestBody = {
// "fileName": "生万物 S01E28",
// "fileHash": "1234567890",
// "fileSize": 0,
// "videoDuration": 0,
// "matchMode": "fileNameOnly"
// };
//
// // 模拟 POST 请求
// const matchUrl = `${urlPrefix}/${token}/api/v2/match`; // 注意路径与 handleRequest 中匹配
// const req = new MockRequest(matchUrl, { method: 'POST', body: requestBody });
//
// // 调用 handleRequest 来处理 POST 请求
// const res = await handleRequest(req);
//
// // 解析响应
// const responseBody = await parseResponse(res);
// console.log(responseBody);
//
// // 验证响应状态
// assert.equal(res.status, 200);
// assert.deepEqual(responseBody.success, true);
// });
// 模拟 POST 请求
const matchUrl = `${urlPrefix}/${token}/api/v2/match`; // 注意路径与 handleRequest 中匹配
const req = new MockRequest(matchUrl, { method: 'POST', body: requestBody });
// 调用 handleRequest 来处理 POST 请求
const res = await handleRequest(req);
// 解析响应
const responseBody = await parseResponse(res);
console.log(responseBody);
// 验证响应状态
assert.equal(res.status, 200);
assert.deepEqual(responseBody.success, true);
});
// // 测试 GET /api/v2/search/episodes 接口
// await t.test('GET /api/v2/search/episodes for search episodes', async () => {
// // 构造请求体
// const requestBody = {
// "fileName": "生万物 S01E28",
// "fileHash": "1234567890",
// "fileSize": 0,
// "videoDuration": 0,
// "matchMode": "fileNameOnly"
// };
//
// const matchUrl = `${urlPrefix}/${token}/api/v2/search/episodes?anime=子夜归`;
// const req = new MockRequest(matchUrl, { method: 'GET' });
//
// const res = await handleRequest(req);
//
// // 解析响应
// const responseBody = await parseResponse(res);
// console.log(responseBody);
//
// // 验证响应状态
// assert.equal(res.status, 200);
// assert.deepEqual(responseBody.success, true);
// });
});
+8 -1
View File
@@ -13,7 +13,14 @@ export const onRequest = async (context) => {
// 构造完整的 URL
let fullUrl;
try {
fullUrl = new URL(request.url, baseUrl).toString();
let targetUrl = request.url;
// 判断是否包含 node-functions/index.js,如果是则用 / 代替
if (request.url.includes('node-functions/index.js')) {
targetUrl = '/';
}
fullUrl = new URL(targetUrl, baseUrl).toString();
console.log('Request fullUrl:', fullUrl);
} catch (error) {
console.error('URL Construction Error:', error);