diff --git a/README.md b/README.md index b4b0af0..f230c26 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ LogVar 弹幕 API 服务器 --- -一个人人都能部署的基于 js 的弹幕 API 服务器,支持爱优腾芒哔人弹幕直接获取,兼容弹弹play的搜索、详情查询和弹幕获取接口,并提供日志记录,支持vercel/cloudflare/docker/claw等部署方式,不用提前下载弹幕,没有nas或小鸡也能一键部署。 +一个人人都能部署的基于 js 的弹幕 API 服务器,支持爱优腾芒哔人韩弹幕直接获取,兼容弹弹play的搜索、详情查询和弹幕获取接口,并提供日志记录,支持vercel/cloudflare/docker/claw等部署方式,不用提前下载弹幕,没有nas或小鸡也能一键部署。 本项目仅为个人爱好开发,代码开源。如有任何侵权行为,请联系本人删除。 @@ -223,8 +223,8 @@ Settings > Functions > Advanced Setting > Function Region 切换为 Hong Kong, | VOD_SERVER | 【可选】vod查询站点,不填默认为`https://www.caiji.cyou` | | BILIBILI_COOKIE | 【可选】b站cookie(填入后能抓取完整弹幕),如 `buvid3=E2BCA ... eao6; theme-avatar-tip-show=SHOWED`,请自行通过浏览器或抓包工具抓取,热心网友测试后,实际最少只需取 `SESSDATA=xxxx` 字段 | | YOUKU_CONCURRENCY | 【可选】youku弹幕请求并发数,用于加快youku弹幕请求速度,不填默认为`8`,最高`16` | -| SOURCE_ORDER | 【可选】源排序,用于按源对返回资源的排序(注意:先后顺序会影响自动匹配最终的返回),默认是`vod,360,renren`,表示vod数据排在最前,renren数据排在最后,示例:`360,renren`:只返回360数据和renren数据,且360数据靠前 | -| PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, renren` | +| SOURCE_ORDER | 【可选】源排序,用于按源对返回资源的排序(注意:先后顺序会影响自动匹配最终的返回),默认是`vod,360,renren,hanjutv`,表示vod数据排在最前,renren数据排在最后,示例:`360,renren`:只返回360数据和renren数据,且360数据靠前 | +| PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, renren, hanjutv` | | EPISODE_TITLE_FILTER | 【可选】剧集标题正则过滤,按正则关键字对剧集或综艺的集标题进行过滤,适用于过滤一些预告或综艺非正式集,默认值如下 | | BLOCKED_WORDS | 【可选】弹幕屏蔽词列表,默认为空,示例如下 | diff --git a/danmu_api/worker.js b/danmu_api/worker.js index b8f8752..2fddab5 100644 --- a/danmu_api/worker.js +++ b/danmu_api/worker.js @@ -1,6 +1,6 @@ // 全局状态(Cloudflare 和 Vercel 都可能重用实例) // ⚠️ 不是持久化存储,每次冷启动会丢失 -const VERSION = "1.1.6"; +const VERSION = "1.2.0"; let animes = []; let episodeIds = []; let episodeNum = 10001; // 全局变量,用于自增 ID @@ -69,7 +69,7 @@ function resolveYoukuConcurrency(env) { return Math.min(DEFAULT_YOUKU_CONCURRENCY, 16); } -const DEFAULT_SOURCE_ORDER = "vod,360,renren"; // 默认 源排序 +const DEFAULT_SOURCE_ORDER = "vod,360,renren,hanjutv"; // 默认 源排序 let sourceOrderArr = []; function resolveSourceOrder(env) { @@ -83,7 +83,7 @@ function resolveSourceOrder(env) { } // 解析并校验 sourceOrder - const allowedSources = ['vod', '360', 'renren']; + const allowedSources = ['vod', '360', 'renren', "hanjutv"]; // 转换为数组并去除空格,过滤无效项 const orderArr = sourceOrder @@ -113,13 +113,13 @@ function resolvePlatformOrder(env) { } // 解析并校验 platformOrder - const allowedPlatforms = ["qiyi", "bilibili1", "imgo", "youku", "qq", "renren"]; + const _allowedPlatforms = ["qiyi", "bilibili1", "imgo", "youku", "qq", "renren", "hanjutv"]; // 转换为数组并去除空格,过滤无效项 const orderArr = platformOrder .split(',') .map(s => s.trim()) // 去除空格 - .filter(s => allowedPlatforms.includes(s)); // 只保留有效来源 + .filter(s => _allowedPlatforms.includes(s)); // 只保留有效来源 // 如果没有有效的来源,使用默认顺序 if (orderArr.length === 0) { @@ -981,6 +981,20 @@ const extractEpTitle = (title) => { return match ? match[1] : null; // 返回#号中的内容,若没有则返回null }; +// djb2 哈希算法将string转成id +function convertToAsciiSum(sid) { + let hash = 5381; + for (let i = 0; i < sid.length; i++) { + hash = (hash * 33) ^ sid.charCodeAt(i); + } + return hash >>> 0; +} + +function canConvertToNumber(url) { + const number = Number(url); + return !isNaN(number) && url === String(number); +} + // ===================== // 获取腾讯弹幕 // ===================== @@ -2468,36 +2482,6 @@ async function performNetworkSearch( // --------------------- // 人人视频URL信息提取 // --------------------- -async function getInfoFromUrl(url) { - const m = String(url).match(/\/v\/(\d+)/); - if (!m) return null; - const dramaId = m[1]; - const detail = await fetchDramaDetail(dramaId); - if (!detail) return null; - const titleClean = String(detail.dramaInfo.title).replace(/<[^>]+>/g,"").replace(/:/g,":"); - const searchResults = await search(titleClean); - const bestMatch = searchResults.find(r=>r.mediaId===dramaId); - if (bestMatch && !bestMatch.episodeCount) bestMatch.episodeCount = (detail.episodeList?.length || 0); - if (bestMatch) return bestMatch; - return { - provider: "renren", - mediaId: dramaId, - title: titleClean, - type: "tv_series", - season: null, - episodeCount: (detail.episodeList?.length || null) - }; -} - -function getIdFromUrl(url) { - const m = String(url).match(/\/v\/\d+\/(\d+)/); - return m ? m[1] : null; -} - -function formatEpisodeIdForComments(providerEpisodeId) { - return String(providerEpisodeId); -} - async function fetchDramaDetail(dramaId) { const url = `https://api.rrmj.plus/m-station/drama/page`; const params = { hsdrOpen:0,isAgeLimit:0,dramaId:String(dramaId),hevcOpen:1 }; @@ -2507,12 +2491,6 @@ async function fetchDramaDetail(dramaId) { return decoded?.data || null; } -async function _episodeCountFromSid(dramaId) { - const detail = await fetchDramaDetail(dramaId); - if (!detail || !detail.episodeList) return null; - return detail.episodeList.filter(ep => String(ep.sid).trim()).length; -} - async function getEpisodes(mediaId, targetEpisodeIndex=null, dbMediaType=null) { const detail = await fetchDramaDetail(mediaId); if (!detail || !detail.episodeList) return []; @@ -2617,6 +2595,186 @@ async function getRenRenComments(episodeId, progressCallback=null){ return formatted; } +// --------------------- +// hanjutv视频弹幕 +// --------------------- +async function hanjutvSearch(keyword) { + try { + const resp = await httpGet(`https://hxqapi.hiyun.tv/wapi/search/aggregate/search?keyword=${keyword}&scope=101&page=1`, { + headers: { + "Content-Type": "application/json", + "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", + }, + }); + + // 判断 resp 和 resp.data 是否存在 + if (!resp || !resp.data) { + log("log", "hanjutvSearchresp: 请求失败或无数据返回"); + return []; + } + + // 判断 seriesData 是否存在 + if (!resp.data.seriesData || !resp.data.seriesData.seriesList) { + log("log", "hanjutvSearchresp: seriesData 或 seriesList 不存在"); + return []; + } + + // 正常情况下输出 JSON 字符串 + log("log", `hanjutvSearchresp: ${JSON.stringify(resp.data.seriesData.seriesList)}`); + + let resList = []; + for (const anime of resp.data.seriesData.seriesList) { + const animeId = convertToAsciiSum(anime.sid); + resList.push({ ...anime, animeId }); + } + return resList; + } catch (error) { + // 捕获请求中的错误 + log("error", "getHanjutvAnimes error:", { + message: error.message, + name: error.name, + stack: error.stack, + }); + return []; + } +} + +async function getHanjutvDetail(sid) { + try { + const resp = await httpGet(`https://hxqapi.hiyun.tv/wapi/series/series/detail?sid=${sid}`, { + headers: { + "Content-Type": "application/json", + "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", + }, + }); + + // 判断 resp 和 resp.data 是否存在 + if (!resp || !resp.data) { + log("log", "getHanjutvDetail: 请求失败或无数据返回"); + return []; + } + + // 判断 seriesData 是否存在 + if (!resp.data.series) { + log("log", "getHanjutvDetail: series 不存在"); + return []; + } + + // 正常情况下输出 JSON 字符串 + log("log", `getHanjutvDetail: ${JSON.stringify(resp.data.series)}`); + + return resp.data.series; + } catch (error) { + // 捕获请求中的错误 + log("error", "getHanjutvDetail error:", { + message: error.message, + name: error.name, + stack: error.stack, + }); + return []; + } +} + +async function getHanjutvEpisodes(sid) { + try { + const resp = await httpGet(`https://hxqapi.hiyun.tv/wapi/series/series/detail?sid=${sid}`, { + headers: { + "Content-Type": "application/json", + "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", + }, + }); + + // 判断 resp 和 resp.data 是否存在 + if (!resp || !resp.data) { + log("log", "getHanjutvEposides: 请求失败或无数据返回"); + return []; + } + + // 判断 seriesData 是否存在 + if (!resp.data.episodes) { + log("log", "getHanjutvEposides: episodes 不存在"); + return []; + } + + const sortedEpisodes = resp.data.episodes.sort((a, b) => a.serialNo - b.serialNo); + + // 正常情况下输出 JSON 字符串 + log("log", `getHanjutvEposides: ${JSON.stringify(sortedEpisodes)}`); + + return sortedEpisodes; + } catch (error) { + // 捕获请求中的错误 + log("error", "getHanjutvEposides error:", { + message: error.message, + name: error.name, + stack: error.stack, + }); + return []; + } +} + +async function fetchHanjutvEpisodeDanmu(sid) { + let allDanmus = []; + let fromAxis = 0; + const maxAxis = 100000000; + + try { + while (fromAxis < maxAxis) { + const resp = await httpGet(`https://hxqapi.zmdcq.com/api/danmu/playItem/list?fromAxis=${fromAxis}&pid=${sid}&toAxis=${maxAxis}`, { + headers: { + "Content-Type": "application/json", + "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", + }, + }); + + // 将当前请求的 episodes 拼接到总数组 + if (resp.data && resp.data.danmus) { + allDanmus = allDanmus.concat(resp.data.danmus); + } + + // 获取 nextAxis,更新 fromAxis + const nextAxis = resp.data.nextAxis || maxAxis; + if (nextAxis >= maxAxis) { + break; // 如果 nextAxis 达到或超过最大值,退出循环 + } + fromAxis = nextAxis; + } + + return allDanmus; + } catch (error) { + // 捕获请求中的错误 + log("error", "fetchHanjutvEpisodeDanmu error:", { + message: error.message, + name: error.name, + stack: error.stack, + }); + return allDanmus; // 返回已收集的 episodes + } +} + +function formatHanjutvComments(items) { + return items.map(c=>({ + cid: Number(c.did), + p: `${c.t.toFixed(2)},${c.tp},${Number(c.sc)},[hanjutv]`, + m: c.con, + t: c.t + })); +} + +async function getHanjutvComments(pid, progressCallback=null){ + if(progressCallback) await progressCallback(5,"开始获取弹幕韩剧TV弹幕"); + log("log", "开始获取弹幕韩剧TV弹幕"); + const raw = await fetchHanjutvEpisodeDanmu(pid); + if(progressCallback) await progressCallback(85,`原始弹幕 ${raw.length} 条,正在规范化`); + log("log", `原始弹幕 ${raw.length} 条,正在规范化`); + const formatted = formatHanjutvComments(raw); + if(progressCallback) await progressCallback(100,`弹幕处理完成,共 ${formatted.length} 条`); + log("log", `弹幕处理完成,共 ${formatted.length} 条`); + // 输出前五条弹幕 + log("log", "Top 5 danmus:", JSON.stringify(formatted.slice(0, 5), null, 2)); + return formatted; +} + // ===================== // 路由请求相关 // ===================== @@ -2916,6 +3074,59 @@ async function handleRenrenAnimes(animesRenren, queryTitle, curAnimes) { return processRenrenAnimes; } +async function handleHanjutvAnimes(animesHanjutv, queryTitle, curAnimes) { + const cateMap = {1: "韩剧", 2: "综艺", 3: "电影", 5: "美剧"} + + function getCategory(key) { + return cateMap[key] || "其他"; + } + + // 使用 map 和 async 时需要返回 Promise 数组,并等待所有 Promise 完成 + const processHanjutvAnimes = await Promise.all(animesHanjutv + .filter(s => s.name.includes(queryTitle)) + .map(async (anime) => { + const detail = await getHanjutvDetail(anime.sid); + const eps = await getHanjutvEpisodes(anime.sid); + let links = []; + for (const ep of eps) { + const epTitle = ep.title && ep.title.trim() !== "" ? `第${ep.serialNo}集:${ep.title}` : `第${ep.serialNo}集`; + links.push({ + "name": ep.title, + "url": ep.pid, + "title": `【hanjutv】${anime.name}(${new Date(anime.updateTime).getFullYear()}) #${epTitle}#` + }); + } + + if (links.length > 0) { + let transformedAnime = { + animeId: anime.animeId, + bangumiId: String(anime.animeId), + animeTitle: `${anime.name}(${new Date(anime.updateTime).getFullYear()})【${getCategory(detail.category)}】from hanjutv`, + type: getCategory(detail.category), + typeDescription: getCategory(detail.category), + imageUrl: anime.image.thumb, + startDate: `${new Date(anime.updateTime).getFullYear()}-01-01T00:00:00`, + episodeCount: links.length, + rating: detail.rank, + isFavorited: true, + }; + + curAnimes.push(transformedAnime); + + const exists = animes.some(existingAnime => existingAnime.animeId === transformedAnime.animeId); + if (!exists) { + const transformedAnimeCopy = {...transformedAnime, links: links}; + addAnime(transformedAnimeCopy); + } + + if (animes.length > MAX_ANIMES) removeEarliestAnime(); + } + }) + ); + + return processHanjutvAnimes; +} + // Extracted function for GET /api/v2/search/anime async function searchAnime(url) { const queryTitle = url.searchParams.get("keyword"); @@ -2930,6 +3141,7 @@ async function searchAnime(url) { if (source === "vod") return getVodAnimes(queryTitle); if (source === "360") return get360Animes(queryTitle); if (source === "renren") return renrenSearch(queryTitle); + if (source === "hanjutv") return hanjutvSearch(queryTitle); }); // 执行所有请求并等待结果 @@ -2944,7 +3156,7 @@ async function searchAnime(url) { }); // 解构出返回的结果 - const { vod: animesVod, 360: animes360, renren: animesRenren } = resultData; + const { vod: animesVod, 360: animes360, renren: animesRenren, hanjutv: animesHanjutv } = resultData; // 按顺序处理每个来源的结果 for (const key of sourceOrderArr) { @@ -2957,6 +3169,9 @@ async function searchAnime(url) { } else if (key === 'renren') { // 等待处理Renren来源 await handleRenrenAnimes(animesRenren, queryTitle, curAnimes); + } else if (key === 'hanjutv') { + // 等待处理Hanjutv来源 + await handleHanjutvAnimes(animesHanjutv, queryTitle, curAnimes); } } } catch (error) { @@ -3331,25 +3546,29 @@ async function getComment(path) { log("log", "开始从本地请求弹幕...", url); let danmus = []; if (url.includes('.qq.com')) { - danmus = await fetchTencentVideo(url); + danmus = await fetchTencentVideo(url); } if (url.includes('.iqiyi.com')) { - danmus = await fetchIqiyi(url); + danmus = await fetchIqiyi(url); } if (url.includes('.mgtv.com')) { - danmus = await fetchMangoTV(url); + danmus = await fetchMangoTV(url); } if (url.includes('.bilibili.com')) { - danmus = await fetchBilibili(url); + danmus = await fetchBilibili(url); } if (url.includes('.youku.com')) { - danmus = await fetchYouku(url); + danmus = await fetchYouku(url); } // 请求人人弹幕 const urlPattern = /^(https?:\/\/)?([\w.-]+)\.([a-z]{2,})(\/.*)?$/i; if (!urlPattern.test(url)) { + if (canConvertToNumber(url)) { danmus = await getRenRenComments(url); + } else { + danmus = await getHanjutvComments(url); + } } // 如果弹幕为空,则请求第三方弹幕服务器作为兜底 @@ -3381,7 +3600,7 @@ async function handleRequest(req, env) { 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或小鸡也能一键部署。", + description: "一个人人都能部署的基于 js 的弹幕 API 服务器,支持爱优腾芒哔人韩弹幕直接获取,兼容弹弹play的搜索、详情查询和弹幕获取接口,并提供日志记录,支持vercel/cloudflare/docker/claw等部署方式,不用提前下载弹幕,没有nas或小鸡也能一键部署。", notice: "本项目仅为个人爱好开发,代码开源。如有任何侵权行为,请联系本人删除。有问题提issue或私信机器人都ok。https://t.me/ddjdd_bot" }); } @@ -3517,4 +3736,5 @@ export async function vercelHandler(req, res) { // 为了测试导出 handleRequest export { handleRequest, searchAnime, searchEpisodes, matchAnime, getBangumi, getComment, fetchTencentVideo, fetchIqiyi, - fetchMangoTV, fetchBilibili, fetchYouku, fetchOtherServer, httpGet, httpPost }; + fetchMangoTV, fetchBilibili, fetchYouku, fetchOtherServer, httpGet, httpPost, hanjutvSearch, getHanjutvEpisodes, + getHanjutvComments, getHanjutvDetail}; diff --git a/danmu_api/worker.test.js b/danmu_api/worker.test.js index 3136f7d..ba387c9 100644 --- a/danmu_api/worker.test.js +++ b/danmu_api/worker.test.js @@ -1,7 +1,8 @@ const test = require('node:test'); const assert = require('node:assert').strict; const { handleRequest, searchAnime, matchAnime, searchEpisode, getBangumi, getComment, fetchTencentVideo, fetchIqiyi, - fetchMangoTV, fetchBilibili, fetchYouku, fetchOtherServer, httpGet, httpPost } = require('./worker'); + fetchMangoTV, fetchBilibili, fetchYouku, fetchOtherServer, httpGet, httpPost, + hanjutvSearch, getHanjutvEpisodes, getHanjutvComments, getHanjutvDetail } = require('./worker'); // Mock Request class for testing class MockRequest { @@ -67,6 +68,26 @@ test('worker.js API endpoints', async (t) => { // assert(res.length > 2, `Expected res.length > 2, but got ${res.length}`); // }); + // await t.test('GET hanjutv search', async () => { + // const res = await hanjutvSearch("犯罪现场Zero"); + // assert(res.length > 0, `Expected res.length > 0, but got ${res.length}`); + // }); + + // await t.test('GET hanjutv detail', async () => { + // const res = await gethanjutvDetail("Tc9lkfijFSDQ8SiUCB6T"); + // // assert(res.length > 0, `Expected res.length > 0, but got ${res.length}`); + // }); + + // await t.test('GET hanjutv episodes', async () => { + // const res = await getHanjutvEpisodes("4EuRcD6T6y8XEQePtDsf"); + // assert(res.length > 0, `Expected res.length > 0, but got ${res.length}`); + // }); + + // await t.test('GET hanjutv danmu', async () => { + // const res = await getHanjutvComments("12tY0Ktjzu5TCBrfTolNO"); + // assert(res.length > 0, `Expected res.length > 0, but got ${res.length}`); + // }); + await t.test('GET realistic danmu', async () => { // tencent // const keyword = "子夜归"; @@ -79,7 +100,9 @@ test('worker.js API endpoints', async (t) => { // youku // const keyword = "黑白局"; // renren - const keyword = "瑞克和莫蒂"; + // const keyword = "瑞克和莫蒂"; + // hanjutv + const keyword = "请回答1988"; const searchUrl = new URL(`${urlPrefix}/${token}/api/v2/search/anime?keyword=${keyword}`); const searchRes = await searchAnime(searchUrl);