diff --git a/README.md b/README.md index 9973890..128b984 100644 --- a/README.md +++ b/README.md @@ -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) > @@ -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);