diff --git a/README.md b/README.md
index 9973890..128b984 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
# LogVar 弹幕 API 服务器
[](https://github.com/huangxd-/damnu_api)
-
+

[](https://t.me/logvar_danmu_channel)
[](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)
+>
+> 也可直接用国际站的部署按钮一键部署,默认选择"全球可用区(不含中国大陆)" [](https://edgeone.ai/pages/new?template=https://github.com/huangxd-/danmu_api&project-name=danmu-api&root-directory=.%2F&env=TOKEN)
>
@@ -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,
+> 注意:小幻在填写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 数增长趋势
diff --git a/danmu_api/worker.js b/danmu_api/worker.js
index 0c242e4..d9c3da3 100644
--- a/danmu_api/worker.js
+++ b/danmu_api/worker.js
@@ -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 };
\ No newline at end of file
+export { handleRequest, searchAnime, searchEpisodes, matchAnime, getBangumi, getComment, fetchTencentVideo, fetchIqiyi,
+ fetchMangoTV, fetchBilibili, fetchYouku, fetchOtherServer, httpGet, httpPost };
\ No newline at end of file
diff --git a/danmu_api/worker.test.js b/danmu_api/worker.test.js
index 2fd3f6f..1d6608c 100644
--- a/danmu_api/worker.test.js
+++ b/danmu_api/worker.test.js
@@ -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);
+ // });
});
\ No newline at end of file
diff --git a/node-functions/index.js b/node-functions/index.js
index 75c9b04..27cd4cc 100644
--- a/node-functions/index.js
+++ b/node-functions/index.js
@@ -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);