From a4c2af4629fd92b652c06551a8613f449256c806 Mon Sep 17 00:00:00 2001 From: huangxd- <649924757@qq.com> Date: Mon, 15 Sep 2025 23:45:45 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=BE=83=E7=AE=80=E5=8D=95?= =?UTF-8?q?=E7=9A=84match=E6=8E=A5=E5=8F=A3=EF=BC=8C=E7=9B=AE=E5=89=8D?= =?UTF-8?q?=E5=B7=B2=E6=94=AF=E6=8C=81=E8=87=AA=E5=8A=A8=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 33 ++++++++-- danmu_api/worker.js | 131 ++++++++++++++++++++++++++++++++++++--- danmu_api/worker.test.js | 82 ++++++++++++++++-------- edgeone.json | 25 ++++++++ vercel.json | 5 +- 5 files changed, 234 insertions(+), 42 deletions(-) create mode 100644 edgeone.json diff --git a/README.md b/README.md index d80a6a7..39c2909 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 本项目仅为个人爱好开发,代码开源。如有任何侵权行为,请联系本人删除。 -纯为爱发电,自用顺便分享,如果真要打赏,可以支付宝发口令红包到我的tg私信机器人 https://t.me/ddjdd_bot ,感谢😊,有问题提issue或私信机器人都ok。 +有问题提issue或私信机器人都ok。https://t.me/ddjdd_bot ## 功能 - **API 接口**: @@ -68,7 +68,7 @@ 3. **测试 API**: 使用 `http://{ip}:9321/{TOKEN}` 访问上述 API 接口。 -## Docker 一键启动 +## Docker 一键启动 【推荐】 1. **拉取镜像**: ```bash docker pull logvar/danmu-api:latest @@ -80,10 +80,23 @@ ``` - 使用`-e TOKEN=your_token_here`设置`TOKEN`环境变量。 + ```yaml + services: + danmu-api: + image: logvar/danmu-api:latest + container_name: danmu-api + ports: + - "9321:9321" + environment: + - TOKEN=your_token_here # 请将your_token_here 替换为实际的 Token 值 + restart: unless-stopped # 可选配置,容器退出时自动重启(非必需,可根据需求删除) + ``` + - 或使用docker compose部署。 + 3. **测试 API**: 使用 `http://{ip}:9321/{TOKEN}` 访问上述 API 接口。 -## 部署到 Vercel +## 部署到 Vercel 【推荐】 ### 一键部署 点击以下按钮即可将项目快速部署到 Vercel: @@ -95,6 +108,10 @@ 1. 转到你的项目设置。 2. 在“Environment Variables”部分添加 `TOKEN` 变量,输入你的 API 令牌值。 3. 保存更改并重新部署。 +- 示例请求:`https://{your_domian}.vercel.app/87654321/api/v2/search/anime?keyword=子夜归` + +### 优化点 +Settings > Functions > Advanced Setting > Function Region 切换为 Hong Kong,能提高访问速度,体验更优 ## 部署到 Cloudflare @@ -109,6 +126,7 @@ 2. 转到“Settings” > “Variables”。 3. 添加 `TOKEN` 环境变量,输入你的 API 令牌值。 4. 保存并部署。 +- 示例请求:`https://{your_domian}.workers.dev/87654321/api/v2/search/anime?keyword=子夜归` ### 手动部署 创建一个worker,将`danmu_api/worker.js`里的代码直接拷贝到你创建的`worker.js`里,然后点击部署。 @@ -126,6 +144,13 @@ +## 环境变量列表 +| 变量名称 | 描述 | +| ----------- | ----------- | +| TOKEN | 用户token | +| OTHER_SERVER | 兜底第三方弹幕服务器,如 https://api.danmu.icu | +| VOD_SERVER | vod查询站点,如 https://www.caiji.cyou | + ## 项目结构 ``` danmu_api/ @@ -133,7 +158,6 @@ danmu_api/ │ └── workflows/ │ └── docker-image.yml ├── danmu_api/ -│ ├── README.md # 项目文档 │ ├── server.js # 本地node启动脚本 │ ├── worker.js # 主 API 服务器代码 │ ├── worker.test.js # 测试文件 @@ -141,6 +165,7 @@ danmu_api/ ├── Dockerfile ├── package.json ├── vercel.json +├── README.md ``` ## 注意事项 diff --git a/danmu_api/worker.js b/danmu_api/worker.js index 20dcb3d..f95e6f3 100644 --- a/danmu_api/worker.js +++ b/danmu_api/worker.js @@ -1,6 +1,6 @@ // 全局状态(Cloudflare 和 Vercel 都可能重用实例) // ⚠️ 不是持久化存储,每次冷启动会丢失 -const VERSION = "1.0.0"; +const VERSION = "1.0.1"; let animes = []; let episodeIds = []; let episodeNum = 10001; // 全局变量,用于自增 ID @@ -2441,6 +2441,107 @@ async function searchAnime(url) { }); } +// Extracted function for POST /api/v2/match +async function matchAnime(url, req) { + try { + // 获取请求体 + const body = await req.json(); + + // 验证请求体是否有效 + if (!body) { + log("error", "Request body is empty"); + return jsonResponse( + { errorCode: 400, success: false, errorMessage: "Empty request body" }, + 400 + ); + } + + // 处理请求体中的数据 + // 假设请求体包含一个字段,比如 { query: "anime name" } + const { fileName } = body; + if (!fileName) { + log("error", "Missing fileName parameter in request body"); + return jsonResponse( + { errorCode: 400, success: false, errorMessage: "Missing fileName parameter" }, + 400 + ); + } + + // 这里可以继续处理 query,比如调用其他服务或数据库查询 + log("info", `Processing anime match for query: ${fileName}`); + + const regex = /^(.+?)\s+S(\d+)E(\d+)$/; + const match = fileName.match(regex); + + let title = match ? match[1] : fileName; + let season = match ? parseInt(match[2]) : null; + let episode = match ? parseInt(match[3]) : null; + + log("info", "Parsed title, season, episode", { title, season, episode }); + + let originSearchUrl = new URL(req.url.replace("/match", `/search/anime?keyword=${title}`)); + const searchRes = await searchAnime(originSearchUrl); + const searchData = await searchRes.json(); + log("info", `searchData: ${searchData.animes}`); + + let resAnime; + let resEpisode; + for (const anime of searchData.animes) { + if (anime.animeTitle.includes(title)) { + let originBangumiUrl = new URL(req.url.replace("/match", `bangumi/${anime.bangumiId}`)); + const bangumiRes = await getBangumi(originBangumiUrl.pathname); + const bangumiData = await bangumiRes.json(); + log("info", bangumiData); + if (season && episode && bangumiData.bangumi.episodes.length >= episode) { + resEpisode = bangumiData.bangumi.episodes[episode-1]; + resAnime = anime; + break; + } else if (bangumiData.bangumi.episodes.length > 0) { + resEpisode = bangumiData.bangumi.episodes[0]; + resAnime = anime; + break; + } + } + } + + let resData = { + "errorCode": 0, + "success": true, + "errorMessage": "", + "isMatched": false, + "matches": [] + }; + + if (resEpisode) { + resData["isMatched"] = true; + resData["matches"] = [ + { + "episodeId": resEpisode.episodeId, + "animeId": resAnime.animeId, + "animeTitle": resAnime.animeTitle, + "episodeTitle": resEpisode.episodeTitle, + "type": resAnime.type, + "typeDescription": resAnime.typeDescription, + "shift": 0, + "imageUrl": resAnime.imageUrl + } + ] + } + + log("info", `resMatchData: ${resData}`); + + // 示例返回 + return jsonResponse(resData); + } catch (error) { + // 处理 JSON 解析错误或其他异常 + log("error", `Failed to parse request body: ${error.message}`); + return jsonResponse( + { errorCode: 400, success: false, errorMessage: "Invalid JSON body" }, + 400 + ); + } +} + // Extracted function for GET /api/v2/bangumi/:animeId async function getBangumi(path) { const animeId = parseInt(path.split("/").pop()); @@ -2552,25 +2653,29 @@ async function handleRequest(req, env) { let path = url.pathname; const method = req.method; - // GET / - if (path === "/" && method === "GET") { + function handleHomepage() { log("log", "Accessed homepage with repository information"); return jsonResponse({ message: "Welcome to the LogVar Danmu API server", version: VERSION, repository: "https://github.com/huangxd-/danmu_api.git", description: "一个人人都能部署的基于 js 的弹幕 API 服务器,支持爱优腾芒哔人弹幕直接获取,兼容弹弹play的搜索、详情查询和弹幕获取接口,并提供日志记录,支持vercel/cloudflare/docker/claw等部署方式,不用提前下载弹幕,没有nas或小鸡也能一键部署。", - notice: "本项目仅为个人爱好开发,代码开源。如有任何侵权行为,请联系本人删除。纯为爱发电,自用顺便分享,如果真要打赏,可以支付宝发口令红包到我的tg私信机器人 https://t.me/ddjdd_bot ,感谢😊,有问题提issue或私信机器人都ok。" + notice: "本项目仅为个人爱好开发,代码开源。如有任何侵权行为,请联系本人删除。有问题提issue或私信机器人都ok。https://t.me/ddjdd_bot" }); } + // GET / + if (path === "/" && method === "GET") { + return handleHomepage(); + } + if (path === "/favicon.ico" || path === "/robots.txt") { return new Response(null, { status: 204 }); } // --- 校验 token --- const parts = path.split("/").filter(Boolean); // 去掉空段 - if (parts.length < 2 || parts[0] !== token) { + if (parts.length < 1 || parts[0] !== token) { log("error", `Invalid or missing token in path: ${path}`); return jsonResponse( { errorCode: 401, success: false, errorMessage: "Unauthorized" }, @@ -2580,11 +2685,23 @@ async function handleRequest(req, env) { // 移除 token 部分,剩下的才是真正的路径 path = "/" + parts.slice(1).join("/"); + console.log(path); + + // GET / + if (path === "/" && method === "GET") { + return handleHomepage(); + } + // GET /api/v2/search/anime if (path === "/api/v2/search/anime" && method === "GET") { return searchAnime(url); } + // GET /api/v2/search/anime + if (path === "/api/v2/match" && method === "POST") { + return matchAnime(url, req); + } + // GET /api/v2/bangumi/:animeId if (path.startsWith("/api/v2/bangumi/") && method === "GET") { return getBangumi(path); @@ -2636,5 +2753,5 @@ export async function vercelHandler(req, res) { } // 为了测试导出 handleRequest -export { handleRequest, searchAnime, getBangumi, getComment, fetchTencentVideo, fetchIqiyi, fetchMangoTV, - fetchBilibili, fetchYouku, fetchOtherServer }; \ No newline at end of file +export { handleRequest, searchAnime, 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 04199dc..2fd3f6f 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, getBangumi, getComment, fetchTencentVideo, fetchIqiyi, fetchMangoTV, - fetchBilibili, fetchYouku, fetchOtherServer} = require('./worker'); +const { handleRequest, searchAnime, matchAnime, getBangumi, getComment, fetchTencentVideo, fetchIqiyi, fetchMangoTV, + fetchBilibili, fetchYouku, fetchOtherServer, httpGet, httpPost } = require('./worker'); // Mock Request class for testing class MockRequest { @@ -9,6 +9,7 @@ class MockRequest { this.url = url; this.method = options.method || 'GET'; this.headers = new Map(Object.entries(options.headers || {})); + this.json = options.body ? async () => options.body : undefined; // 模拟 POST 请求的 body } } @@ -66,33 +67,60 @@ test('worker.js API endpoints', async (t) => { // assert(res.length > 2, `Expected res.length > 2, but got ${res.length}`); // }); - await t.test('GET realistic danmu', async () => { - // tencent - // const keyword = "子夜归"; - // iqiyi - // const keyword = "赴山海"; - // mango - // const keyword = "锦月如歌"; - // bilibili - // const keyword = "国王排名"; - // youku - // const keyword = "黑白局"; - // renren - const keyword = "瑞克和莫蒂"; + // await t.test('GET realistic danmu', async () => { + // // tencent + // // const keyword = "子夜归"; + // // iqiyi + // // const keyword = "赴山海"; + // // mango + // // const keyword = "锦月如歌"; + // // bilibili + // // const keyword = "国王排名"; + // // youku + // // const keyword = "黑白局"; + // // renren + // const keyword = "瑞克和莫蒂"; + // + // const searchUrl = new URL(`${urlPrefix}/${token}/api/v2/search/anime?keyword=${keyword}`); + // const searchRes = await searchAnime(searchUrl); + // const searchData = await searchRes.json(); + // assert(searchData.animes.length > 0, `Expected searchData.animes.length > 0, but got ${searchData.animes.length}`); + // + // const bangumiUrl = new URL(`${urlPrefix}/${token}/api/v2/bangumi/${searchData.animes[0].animeId}`); + // const bangumiRes = await getBangumi(bangumiUrl.pathname); + // const bangumiData = await bangumiRes.json(); + // assert(bangumiData.bangumi.episodes.length > 0, `Expected bangumiData.bangumi.episodes.length > 0, but got ${bangumiData.bangumi.episodes.length}`); + // + // const commentUrl = new URL(`${urlPrefix}/${token}/api/v2/comment/${bangumiData.bangumi.episodes[0].episodeId}?withRelated=true&chConvert=1`); + // const commentRes = await getComment(commentUrl.pathname); + // const commentData = await commentRes.json(); + // assert(commentData.count > 0, `Expected commentData.count > 0, but got ${commentData.count}`); + // }); - const searchUrl = new URL(`${urlPrefix}/${token}/api/v2/search/anime?keyword=${keyword}`); - const searchRes = await searchAnime(searchUrl); - const searchData = await searchRes.json(); - assert(searchData.animes.length > 0, `Expected searchData.animes.length > 0, but got ${searchData.animes.length}`); + // 测试 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" + }; - const bangumiUrl = new URL(`${urlPrefix}/${token}/api/v2/bangumi/${searchData.animes[0].animeId}`); - const bangumiRes = await getBangumi(bangumiUrl.pathname); - const bangumiData = await bangumiRes.json(); - assert(bangumiData.bangumi.episodes.length > 0, `Expected bangumiData.bangumi.episodes.length > 0, but got ${bangumiData.bangumi.episodes.length}`); + // 模拟 POST 请求 + const matchUrl = `${urlPrefix}/${token}/api/v2/match`; // 注意路径与 handleRequest 中匹配 + const req = new MockRequest(matchUrl, { method: 'POST', body: requestBody }); - const commentUrl = new URL(`${urlPrefix}/${token}/api/v2/comment/${bangumiData.bangumi.episodes[0].episodeId}?withRelated=true&chConvert=1`); - const commentRes = await getComment(commentUrl.pathname); - const commentData = await commentRes.json(); - assert(commentData.count > 0, `Expected commentData.count > 0, but got ${commentData.count}`); + // 调用 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); }); }); \ No newline at end of file diff --git a/edgeone.json b/edgeone.json new file mode 100644 index 0000000..9878e66 --- /dev/null +++ b/edgeone.json @@ -0,0 +1,25 @@ +{ + "version": 1, + "routes": [ + { + "pattern": "/", + "function": "index" + }, + { + "pattern": "/api/*", + "function": "index" + }, + { + "pattern": "/*/api/*", + "function": "index" + } + ], + "functions": { + "index": { + "src": "danmu_api/worker.js" + } + }, + "env": { + "TOKEN": "87654321" + } +} \ No newline at end of file diff --git a/vercel.json b/vercel.json index 3064c23..69cfd87 100644 --- a/vercel.json +++ b/vercel.json @@ -19,8 +19,5 @@ "src": "/([^/]+)/api/(.*)", "dest": "/danmu_api/worker.js" } - ], - "env": { - "TOKEN": "87654321" - } + ] } \ No newline at end of file