新增支持韩剧tv(韩小圈)弹幕源 (#35)

* 新增支持韩剧tv(韩小圈)弹幕源
This commit is contained in:
huangxd- authored and GitHub committed 2025-10-01 17:10:34 +08:00
1 parent 143f2ddd71
commit 6e6c900565
3 files changed
+297 -54

No files matched your search

+3 -3
View File
@@ -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 | 【可选】弹幕屏蔽词列表,默认为空,示例如下 |
+269 -49
View File
@@ -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};
+25 -2
View File
@@ -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);