支持自定义弹幕源 (#121)

* 解决api配置反代后的ui路径问题

* ui修改body和header背景色

* 修复segmentcomment接口调试时format选择xml不生效的问题

* Revert "修复segmentcomment接口调试时format选择xml不生效的问题"

This reverts commit 997a101cb0537d95ebdb62e9f1267af7e94475a9.

* 修复分片弹幕请求xml还是返回json的问题

* 支持自定义弹幕源

* 修复在反代场景下api端点显示

* 修复导航栏标签切换问题

* 修复iqiyi源的弹幕emoji不显示的问题

* 完善readme中的项目结构

* 修改小版本号
This commit is contained in:
huangxd- authored and GitHub committed 2025-12-28 11:02:50 +08:00
1 parent 28893cf9b2
commit f199f69d8b
18 files changed
+368 -67

No files matched your search

+16 -11
View File
@@ -303,7 +303,7 @@ LogVar 弹幕 API 服务器
> cf部署可能不稳定,推荐用vercel/netlify部署。
## API食用指南
支持 forward/senplayer/hills/小幻/yamby/eplayerx/afusekt/uz影视/dscloud/lenna/danmaku-anywhere/omnibox/ChaiChaiEmbyTV 等支持弹幕API的播放器。
支持 forward/senplayer/hills/小幻/yamby/eplayerx/afusekt/uz影视/dscloud/lenna/danmaku-anywhere/omnibox/ChaiChaiEmbyTV/moontv 等支持弹幕API的播放器。
配合 dd-danmaku 扩展新增对 Emby Web 端弹幕的支持,具体使用方法参考 [PR #98](https://github.com/huangxd-/danmu_api/pull/98) 。
@@ -364,13 +364,14 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
| TOKEN | 【可选】自定义用户token,不填默认为`87654321` |
| ADMIN_TOKEN | 【可选】系统管理访问令牌,如果未配置此值,则无法访问系统管理功能,需要先配置后在URL中填入此token才能打开系统管理 |
| OTHER_SERVER | 【可选】兜底第三方弹幕服务器,不填默认为`https://api.danmu.icu`,其他可选:`https://fc.lyz05.cn`,`https://dmku.hls.one`,`https://se.678.ooo`,`https://danmu.56uxi.com`,`https://dm.lxlad.com` |
| CUSTOM_SOURCE_API_URL | 【可选】自定义弹幕源API地址,默认为空,配置后还需在SOURCE_ORDER添加custom源 |
| VOD_SERVERS | 【可选】VOD服务器列表,支持多个服务器并发查询,格式:`名称@URL,名称@URL,...`,示例:`金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top`,不填默认为`金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top` |
| VOD_RETURN_MODE | 【可选】VOD返回模式,可选值:`all`(返回所有站点结果)、`fastest`(只返回最快的站点结果),默认为`fastest`。当配置多个VOD站点时,`all`模式会返回所有站点的结果(结果较多),`fastest`模式只返回首先响应成功的站点结果(结果较少,避免重复) |
| VOD_REQUEST_TIMEOUT | 【可选】VOD服务器单个请求超时时间(毫秒),防止慢速或失效的采集站阻塞搜索,默认为`10000`(10秒),建议值:`5000-15000`。由于`fastest`模式只返回最快响应的站点,可以设置较大的超时时间给慢速站点更多机会 |
| BILIBILI_COOKIE | 【可选】b站cookie(填入后能抓取完整弹幕),如 `buvid3=E2BCA ... eao6; theme-avatar-tip-show=SHOWED`,请自行通过浏览器或抓包工具抓取,热心网友测试后,实际最少只需取 `SESSDATA=xxxx` 字段 |
| YOUKU_CONCURRENCY | 【可选】youku弹幕请求并发数,用于加快youku弹幕请求速度,不填默认为`8`,最高`16` |
| SOURCE_ORDER | 【可选】源排序,用于按源对返回资源的排序(注意:先后顺序会影响自动匹配最终的返回),默认是`360,vod,renren,hanjutv`,表示360数据排在最前,hanjutv数据排在最后,示例:`360,renren`:只返回360数据和renren数据,且360数据靠前;当前可选择的源字段有 `360,vod,tmdb,douban,tencent,youku,iqiyi,imgo,bilibili,renren,hanjutv,bahamut,dandan` |
| PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, renren, hanjutv, bahamut, dandan` |
| SOURCE_ORDER | 【可选】源排序,用于按源对返回资源的排序(注意:先后顺序会影响自动匹配最终的返回),默认是`360,vod,renren,hanjutv`,表示360数据排在最前,hanjutv数据排在最后,示例:`360,renren`:只返回360数据和renren数据,且360数据靠前;当前可选择的源字段有 `360,vod,tmdb,douban,tencent,youku,iqiyi,imgo,bilibili,renren,hanjutv,bahamut,dandan,custom` |
| PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, renren, hanjutv, bahamut, dandan, custom` |
| EPISODE_TITLE_FILTER | 【可选】剧集标题正则过滤,按正则关键字对剧集或综艺的集标题进行过滤,适用于过滤一些预告或综艺非正式集,只支持match自动匹配,默认值如下 |
| ENABLE_EPISODE_FILTER | 【可选】是否在手动选择接口中启用集标题过滤,默认为`false`(禁用),启用后 GET /api/v2/bangumi/{id} 和 GET /api/v2/search/anime 接口会过滤掉预告、花絮等特殊集,以及名称包含特殊关键词的动漫。 |
| STRICT_TITLE_MATCH | 【可选】是否启用严格标题匹配模式,默认为`false`(宽松模糊匹配),启用后只匹配标题开头或完全匹配的结果。例如:搜索"遮天"时,`false`会匹配"古惑仔3之只手遮天",`true`只匹配"遮天"、"遮天 第一季"等。可选值:`true`、`false` |
@@ -444,18 +445,12 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
| hanjutv | hanjutv |
| bahamut | bahamut |
| dandan | dandan |
| custom | custom |
## 项目结构
```
danmu_api/
├── .github/
│ └── workflows/
│ ├── docker-image.yml # Docker镜像构建和推送工作流
│ └── sync_fork.yml # Fork同步上游仓库工作流(vercel自动同步配置文件)
├── config/
│ ├── .env.example # .env 配置文件示例
│ └── config.yaml.example # YAML 配置文件示例(无法编辑 .env 时使用)
├── .gitignore
├── build-forward-widget.js # 构建forward弹幕插件脚本
├── Dockerfile
├── edgeone.json # edgeone pages 配置文件
├── LICENSE
@@ -464,6 +459,9 @@ danmu_api/
├── README.md
├── vercel.json # vercel 配置文件
├── wrangler.toml # cloudflare worker 配置文件
├── config/
│ ├── .env.example # .env 配置文件示例
│ └── config.yaml.example # YAML 配置文件示例(无法编辑 .env 时使用)
├── danmu_api/
│ ├── esm-shim.js # Node.js低版本兼容层
│ ├── server.js # 本地node启动脚本
@@ -490,6 +488,7 @@ danmu_api/
│ │ ├── bahamut.js # 巴哈姆特源
│ │ ├── base.js # 弹幕源获取基类
│ │ ├── bilibili.js # b站源
│ │ ├── custom.js # 自定义弹幕源
│ │ ├── dandan.js # 弹弹play源
│ │ ├── douban.js # 豆瓣源
│ │ ├── hanjutv.js # 韩剧TV源
@@ -530,6 +529,10 @@ danmu_api/
│ ├── time-util.js # 时间日期工具
│ ├── tmdb-util.js # TMDB API请求处理工具
│ └── zh-util.js # 中文繁简转换工具
├── forward/
│ ├── custom-polyfill.js # 自定义polyfill
│ ├── forward-widget.js # forward弹幕插件
│ └── forward-widget.test.js # forward弹幕插件测试文件
├── netlify/
│ └── functions/
│ └── api.js # netlify 中间处理逻辑
@@ -555,7 +558,9 @@ danmu_api/
- 一键部署需要将项目推送到公开的 Git 仓库(如 GitHub),并更新按钮中的仓库地址。
- 运行 Docker 容器时,需通过 `-e TOKEN=87654321` 传递 `TOKEN` 环境变量。
- cloudflare貌似被哔风控了。
- cloudflare貌似有单次请求数量限制,会导致后半部分没有弹幕。
- 如果想更换兜底第三方弹幕服务器,请添加环境变量`OTHER_SERVER`,示例`https://api.danmu.icu`。
- 如果想使用自定义弹幕源,请添加环境变量`CUSTOM_SOURCE_API_URL`,并在`SOURCE_ORDER`环境变量中添加`custom`源。
- 如果想更换vod站点,请添加环境变量`VOD_SERVERS`,示例`金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top`(支持多个服务器并发查询)。
- 当配置多个VOD站点时,可通过`VOD_RETURN_MODE`环境变量控制返回结果方式:`all`(返回所有站点结果)或`fastest`(默认,只返回最快的站点结果,避免结果过多)。
- 推荐vercel/netlify部署,cloudflare/edgeone/claw不稳定,当然最稳定还是自己本地docker部署最佳。
+6 -2
View File
@@ -30,6 +30,10 @@ TOKEN=87654321
# 默认值:https://api.danmu.icu
OTHER_SERVER=https://api.danmu.icu
# 自定义弹幕源API地址
# 默认为空,配置后还需在SOURCE_ORDER添加custom源
# CUSTOM_SOURCE_API_URL=
# ==================== VOD 配置 ====================
# VOD 服务器配置(支持多服务器)
@@ -68,7 +72,7 @@ YOUKU_CONCURRENCY=8
# ==================== 源排序配置 ====================
# 数据源查询顺序(可选值:360, vod, tmdb, douban, tencent, youku, iqiyi, imgo, bilibili, renren, hanjutv, bahamut, dandan)
# 数据源查询顺序(可选值:360, vod, tmdb, douban, tencent, youku, iqiyi, imgo, bilibili, renren, hanjutv, bahamut, dandan, custom)
# 默认值:360,vod,renren,hanjutv(不包含 tencent, youku, iqiyi, imgo 和 bilibili)
# 如需使用腾讯源、优酷源、爱奇艺源、芒果源或B站源,请手动添加:SOURCE_ORDER=360,vod,tencent,youku,iqiyi,imgo,bilibili,renren,hanjutv
# 如需使用巴哈姆特源,请手动添加:SOURCE_ORDER=360,vod,renren,hanjutv,bahamut
@@ -81,7 +85,7 @@ SOURCE_ORDER=360,vod,renren,hanjutv
# 平台优先级排序(用于自动匹配)
# 默认值:空(自动匹配)
# 可选值:qiyi, bilibili1, imgo, youku, qq, renren, hanjutv, bahamut, dandan
# 可选值:qiyi, bilibili1, imgo, youku, qq, renren, hanjutv, bahamut, dandan, custom
# PLATFORM_ORDER=
# ==================== 剧集标题过滤 ====================
+6 -2
View File
@@ -26,6 +26,10 @@ TOKEN: "87654321"
# 默认值:https://api.danmu.icu
OTHER_SERVER: "https://api.danmu.icu"
# 自定义弹幕源API地址
# 默认为空,配置后还需在SOURCE_ORDER添加custom源
# CUSTOM_SOURCE_API_URL:
# ==================== VOD 配置 ====================
# VOD 服务器配置(支持多服务器)
@@ -64,7 +68,7 @@ YOUKU_CONCURRENCY: 8
# ==================== 源排序配置 ====================
# 数据源查询顺序(可选值:360, vod, tmdb, douban, tencent, youku, iqiyi, imgo, bilibili, renren, hanjutv, bahamut, dandan)
# 数据源查询顺序(可选值:360, vod, tmdb, douban, tencent, youku, iqiyi, imgo, bilibili, renren, hanjutv, bahamut, dandan, custom)
# 默认值:360,vod,renren,hanjutv(不包含 tencent, youku, iqiyi, imgo 和 bilibili)
# 如需使用腾讯源、优酷源、爱奇艺源、芒果源或B站源,请手动添加:SOURCE_ORDER=360,vod,tencent,youku,iqiyi,imgo,bilibili,renren,hanjutv
# 如需使用巴哈姆特源,请手动添加:SOURCE_ORDER=360,vod,renren,hanjutv,bahamut
@@ -77,7 +81,7 @@ SOURCE_ORDER: "360,vod,renren,hanjutv"
# 平台优先级排序(用于自动匹配)
# 默认值:空(自动匹配)
# 可选值:qiyi, bilibili1, imgo, youku, qq, renren, hanjutv, bahamut, dandan
# 可选值:qiyi, bilibili1, imgo, youku, qq, renren, hanjutv, bahamut, dandan, custom
# PLATFORM_ORDER: ""
# ==================== 剧集标题过滤 ====================
+12 -2
View File
@@ -18,6 +18,7 @@ import RenrenSource from "../sources/renren.js";
import HanjutvSource from "../sources/hanjutv.js";
import BahamutSource from "../sources/bahamut.js";
import DandanSource from "../sources/dandan.js";
import CustomSource from "../sources/custom.js";
import TencentSource from "../sources/tencent.js";
import IqiyiSource from "../sources/iqiyi.js";
import MangoSource from "../sources/mango.js";
@@ -36,6 +37,7 @@ const renrenSource = new RenrenSource();
const hanjutvSource = new HanjutvSource();
const bahamutSource = new BahamutSource();
const dandanSource = new DandanSource();
const customSource = new CustomSource();
const tencentSource = new TencentSource();
const youkuSource = new YoukuSource();
const iqiyiSource = new IqiyiSource();
@@ -184,6 +186,7 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null
if (source === "hanjutv") return hanjutvSource.search(queryTitle);
if (source === "bahamut") return bahamutSource.search(queryTitle);
if (source === "dandan") return dandanSource.search(queryTitle);
if (source === "custom") return customSource.search(queryTitle);
if (source === "tencent") return tencentSource.search(queryTitle);
if (source === "youku") return youkuSource.search(queryTitle);
if (source === "iqiyi") return iqiyiSource.search(queryTitle);
@@ -205,8 +208,8 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null
// 解构出返回的结果
const {
vod: animesVodResults, 360: animes360, tmdb: animesTmdb, douban: animesDouban, renren: animesRenren,
hanjutv: animesHanjutv, bahamut: animesBahamut, dandan: animesDandan, tencent: animesTencent, youku: animesYouku,
iqiyi: animesIqiyi, imgo: animesImgo, bilibili: animesBilibili
hanjutv: animesHanjutv, bahamut: animesBahamut, dandan: animesDandan, custom: animesCustom,
tencent: animesTencent, youku: animesYouku, iqiyi: animesIqiyi, imgo: animesImgo, bilibili: animesBilibili
} = resultData;
// 按顺序处理每个来源的结果
@@ -241,6 +244,9 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null
} else if (key === 'dandan') {
// 等待处理弹弹play来源
await dandanSource.handleAnimes(animesDandan, queryTitle, curAnimes);
} else if (key === 'custom') {
// 等待处理自定义弹幕源来源
await customSource.handleAnimes(animesCustom, queryTitle, curAnimes);
} else if (key === 'tencent') {
// 等待处理Tencent来源
await tencentSource.handleAnimes(animesTencent, queryTitle, curAnimes);
@@ -935,6 +941,8 @@ export async function getComment(path, queryFormat, segmentFlag) {
danmus = await bahamutSource.getComments(url, plat, segmentFlag);
} else if (plat === "dandan") {
danmus = await dandanSource.getComments(url, plat, segmentFlag);
} else if (plat === "custom") {
danmus = await customSource.getComments(url, plat, segmentFlag);
}
}
@@ -1105,6 +1113,8 @@ export async function getSegmentComment(segment, queryFormat) {
danmus = await renrenSource.getSegmentComments(segment);
} else if (platform === "dandan") {
danmus = await dandanSource.getSegmentComments(segment);
} else if (platform === "custom") {
danmus = await customSource.getSegmentComments(segment);
} else if (platform === "other_server") {
danmus = await otherSource.getSegmentComments(segment);
}
+4 -2
View File
@@ -10,8 +10,8 @@ export class Envs {
static accessedEnvVars = new Map();
static VOD_ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq']; // vod允许的播放平台
static ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'renren', 'hanjutv', 'bahamut', 'dandan']; // 全部源允许的播放平台
static ALLOWED_SOURCES = ['360', 'vod', 'tmdb', 'douban', 'tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'renren', 'hanjutv', 'bahamut', 'dandan']; // 允许的源
static ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'renren', 'hanjutv', 'bahamut', 'dandan', 'custom']; // 全部源允许的播放平台
static ALLOWED_SOURCES = ['360', 'vod', 'tmdb', 'douban', 'tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'renren', 'hanjutv', 'bahamut', 'dandan', 'custom']; // 允许的源
/**
* 获取环境变量
@@ -206,6 +206,7 @@ export class Envs {
// 源配置
'SOURCE_ORDER': { category: 'source', type: 'multi-select', options: this.ALLOWED_SOURCES, description: '源排序配置,默认360,vod,renren,hanjutv' },
'OTHER_SERVER': { category: 'source', type: 'text', description: '第三方弹幕服务器,默认https://api.danmu.icu' },
'CUSTOM_SOURCE_API_URL': { category: 'source', type: 'text', description: '自定义弹幕源API地址,默认为空,配置后还需在SOURCE_ORDER添加custom源' },
'VOD_SERVERS': { category: 'source', type: 'text', description: 'VOD站点配置,格式:名称@URL,名称@URL,默认金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top' },
'VOD_RETURN_MODE': { category: 'source', type: 'select', options: ['all', 'fastest'], description: 'VOD返回模式:all(所有站点)或 fastest(最快的站点),默认fastest' },
'VOD_REQUEST_TIMEOUT': { category: 'source', type: 'number', description: 'VOD请求超时时间,默认10000', min: 5000, max: 30000 },
@@ -254,6 +255,7 @@ export class Envs {
adminToken: this.get('ADMIN_TOKEN', '', 'string', true), // admin token,用于系统管理访问控制
sourceOrderArr: this.resolveSourceOrder(), // 源排序
otherServer: this.get('OTHER_SERVER', 'https://api.danmu.icu', 'string'), // 第三方弹幕服务器
customSourceApiUrl: this.get('CUSTOM_SOURCE_API_URL', '', 'string', true), // 自定义弹幕源API地址,默认为空,配置后还需在SOURCE_ORDER添加custom源
vodServers: this.resolveVodServers(), // vod站点配置,格式:名称@URL,名称@URL
vodReturnMode: this.get('VOD_RETURN_MODE', 'fastest', 'string').toLowerCase(), // vod返回模式:all(所有站点)或 fastest(最快的站点)
vodRequestTimeout: this.get('VOD_REQUEST_TIMEOUT', '10000', 'string'), // vod超时时间(默认10秒)
+1 -1
View File
@@ -13,7 +13,7 @@ export const Globals = {
accessedEnvVars: {},
// 静态常量
VERSION: '1.10.0',
VERSION: '1.10.1',
MAX_LOGS: 500, // 日志存储,最多保存 500 行
MAX_ANIMES: 100,
+203
View File
@@ -0,0 +1,203 @@
import BaseSource from './base.js';
import { globals } from '../configs/globals.js';
import { log } from "../utils/log-util.js";
import { httpGet } from "../utils/http-util.js";
import { addAnime, removeEarliestAnime } from "../utils/cache-util.js";
import { simplized } from "../utils/zh-util.js";
import { SegmentListResponse } from '../models/dandan-model.js';
// =====================
// 获取自定义源弹幕
// =====================
export default class CustomSource extends BaseSource {
async search(keyword) {
try {
const resp = await httpGet(`${globals.customSourceApiUrl}/api/v2/search/anime?keyword=${keyword}`, {
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("info", "customSourceSearchresp: 请求失败或无数据返回");
return [];
}
// 判断 seriesData 是否存在
if (!resp.data.animes) {
log("info", "customSourceSearchresp: seriesData 或 seriesList 不存在");
return [];
}
// 正常情况下输出 JSON 字符串
log("info", `customnSourceSearchresp: ${JSON.stringify(resp.data.animes)}`);
return resp.data.animes;
} catch (error) {
// 捕获请求中的错误
log("error", "getCustomSourceAnimes error:", {
message: error.message,
name: error.name,
stack: error.stack,
});
return [];
}
}
async getEpisodes(id) {
try {
const resp = await httpGet(`${globals.customSourceApiUrl}/api/v2/bangumi/${id}`, {
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("info", "getCustomSourceEposides: 请求失败或无数据返回");
return [];
}
// 判断 seriesData 是否存在
if (!resp.data.bangumi || !resp.data.bangumi.episodes) {
log("info", `getCustomSourceEposides: episodes 不存在. Response: ${JSON.stringify(resp.data)}`);
return [];
}
// 正常情况下输出 JSON 字符串
log("info", `getCustomSourceEposides: ${JSON.stringify(resp.data.bangumi.episodes)}`);
return resp.data.bangumi.episodes;
} catch (error) {
// 捕获请求中的错误
log("error", "getCustomSourceEposides error:", {
message: error.message,
name: error.name,
stack: error.stack,
});
return [];
}
}
async handleAnimes(sourceAnimes, queryTitle, curAnimes) {
const tmpAnimes = [];
// 添加错误处理,确保sourceAnimes是数组
if (!sourceAnimes || !Array.isArray(sourceAnimes)) {
log("error", "[Custom Source] sourceAnimes is not a valid array");
return [];
}
// 使用 map 和 async 时需要返回 Promise 数组,并等待所有 Promise 完成
const processCustomSourceAnimes = await Promise.all(sourceAnimes
.map(async (anime) => {
try {
const eps = await this.getEpisodes(anime.bangumiId);
let links = [];
for (const ep of eps) {
const epTitle = ep.episodeTitle && ep.episodeTitle.trim() !== "" ? `${ep.episodeTitle}` : `第${ep.episodeNumber}集`;
links.push({
"name": epTitle,
"url": ep.episodeId.toString(),
"title": `【custom】 ${epTitle}`
});
}
if (links.length > 0) {
let transformedAnime = {
animeId: anime.animeId,
bangumiId: String(anime.bangumiId),
animeTitle: `${anime.animeTitle}(${new Date(anime.startDate).getFullYear()})【${anime.typeDescription}】from custom`,
type: anime.type,
typeDescription: anime.typeDescription,
imageUrl: anime.imageUrl,
startDate: anime.startDate,
episodeCount: links.length,
rating: anime.rating,
isFavorited: true,
source: "custom",
};
tmpAnimes.push(transformedAnime);
addAnime({...transformedAnime, links: links});
if (globals.animes.length > globals.MAX_ANIMES) removeEarliestAnime();
}
} catch (error) {
log("error", `[Custom Source] Error processing anime: ${error.message}`);
}
})
);
this.sortAndPushAnimesByYear(tmpAnimes, curAnimes);
return processCustomSourceAnimes;
}
async getEpisodeDanmu(id) {
let allDanmus = [];
try {
const resp = await httpGet(`${globals.customSourceApiUrl}/api/v2/comment/${id}`, {
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",
},
retries: 1,
});
// 将当前请求的 episodes 拼接到总数组
if (resp.data && resp.data.comments) {
allDanmus = resp.data.comments;
}
return allDanmus;
} catch (error) {
// 捕获请求中的错误
log("error", "fetchCustomSourceEpisodeDanmu error:", {
message: error.message,
name: error.name,
stack: error.stack,
});
return allDanmus; // 返回已收集的 episodes
}
}
async getEpisodeDanmuSegments(id) {
log("info", "获取Custom Source弹幕分段列表...", id);
return new SegmentListResponse({
"type": "custom",
"segmentList": [{
"type": "custom",
"segment_start": 0,
"segment_end": 30000,
"url": id
}]
});
}
async getEpisodeSegmentDanmu(segment) {
return this.getEpisodeDanmu(segment.url);
}
formatComments(comments) {
return comments.map(c => ({
cid: c.cid,
p: `${c.p.replace(/([A-Za-z]+)([0-9a-fA-F]{6})/, (_, platform, hexColor) => {
// 转换 hexColor 为十进制颜色值
const r = parseInt(hexColor.substring(0, 2), 16);
const g = parseInt(hexColor.substring(2, 4), 16);
const b = parseInt(hexColor.substring(4, 6), 16);
const decimalColor = r * 256 * 256 + g * 256 + b;
return `${platform}${decimalColor}`;
})}`,
// 根据 globals.danmuSimplified 控制是否繁转简
m: globals.danmuSimplified ? simplized(c.m) : c.m,
}));
}
}
+2 -2
View File
@@ -2,7 +2,7 @@ import BaseSource from './base.js';
import { log } from "../utils/log-util.js";
import { buildQueryString, httpGet} from "../utils/http-util.js";
import { printFirst200Chars, titleMatches } from "../utils/common-util.js";
import { md5, convertToAsciiSum } from "../utils/codec-util.js";
import { md5, convertToAsciiSum, decodeHtmlEntities } from "../utils/codec-util.js";
import { generateValidStartDate } from "../utils/time-util.js";
import { addAnime, removeEarliestAnime } from "../utils/cache-util.js";
import { globals } from '../configs/globals.js';
@@ -898,7 +898,7 @@ export default class IqiyiSource extends BaseSource {
};
content.timepoint = parseFloat(item["showTime"]);
content.color = parseInt(item["color"], 16);
content.content = item["content"];
content.content = decodeHtmlEntities(item["content"]);
content.size = 25;
return content;
});
+2 -2
View File
@@ -9,7 +9,7 @@ export const baseCssContent = /* css */ `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: linear-gradient(135deg, #a0b9e8ff 0%, #e39db4ff 100%);
min-height: 100vh;
padding: 20px;
}
@@ -24,7 +24,7 @@ body {
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: linear-gradient(135deg, #1a2980 0%, #26d0ce 100%);
color: white;
padding: 20px;
display: flex;
+2 -1
View File
@@ -24,7 +24,8 @@ export const componentsCssContent = /* css */ `
.nav-btn.active {
background: white;
color: #667eea;
color: #1a2980;
font-weight: bold;
}
/* 环境变量样式 */
+10
View File
@@ -192,6 +192,16 @@ function testApi() {
if (config.method === 'GET') {
const queryString = new URLSearchParams(params).toString();
url = url + '?' + queryString;
} else if (config.method === 'POST' && apiKey === 'getSegmentComment') {
// 对于 getSegmentComment 接口,需要将 format 参数添加到 URL 查询参数中
const queryParams = {};
if (params.format) {
queryParams.format = params.format;
}
if (Object.keys(queryParams).length > 0) {
const queryString = new URLSearchParams(queryParams).toString();
url = url + '?' + queryString;
}
}
}
+31 -14
View File
@@ -118,12 +118,16 @@ let originalToken = '';
// 构建带token的API请求路径
function buildApiUrl(path, isSystemPath = false) {
// 如果是系统管理路径且有admin token,则使用admin token
let res;
let _reverseProxy = "";
// 如果是系统管理路径且有admin token,则使用admin token
if (isSystemPath && currentAdminToken && currentAdminToken.trim() !== '' && currentAdminToken.trim() !== '*'.repeat(currentAdminToken.length)) {
return '/' + currentAdminToken + path;
res = '/' + currentAdminToken + path;
} else {
// 否则使用普通token
res = (currentToken ? '/' + currentToken : "") + path;
}
// 否则使用普通token
return (currentToken ? '/' + currentToken : "") + path;
return _reverseProxy + res;
}
// 从API加载真实环境变量数据
@@ -180,6 +184,8 @@ function updateApiEndpoint() {
return fetch(buildApiUrl('/api/config', true))
.then(response => response.json())
.then(config => {
let _reverseProxy = "";
// 获取当前页面的协议、主机和端口
const protocol = window.location.protocol;
const host = window.location.host;
@@ -187,7 +193,7 @@ function updateApiEndpoint() {
const adminToken = config.originalEnvVars?.ADMIN_TOKEN;
// 获取URL路径并提取token
const urlPath = window.location.pathname;
const urlPath = window.location.pathname.replace(_reverseProxy, "");
const pathParts = urlPath.split('/').filter(part => part !== '');
const urlToken = pathParts.length > 0 ? pathParts[0] : '';
let apiToken = '********';
@@ -204,7 +210,7 @@ function updateApiEndpoint() {
}
// 构造API端点URL
const apiEndpoint = protocol + '//' + host + '/' + apiToken;
const apiEndpoint = protocol + '//' + host + _reverseProxy + '/' + apiToken;
const apiEndpointElement = document.getElementById('api-endpoint');
if (apiEndpointElement) {
apiEndpointElement.textContent = apiEndpoint;
@@ -216,7 +222,7 @@ function updateApiEndpoint() {
// 出错时显示默认值
const protocol = window.location.protocol;
const host = window.location.host;
const apiEndpoint = protocol + '//' + host + '/********';
const apiEndpoint = protocol + '//' + host + _reverseProxy + '/********';
const apiEndpointElement = document.getElementById('api-endpoint');
if (apiEndpointElement) {
apiEndpointElement.textContent = apiEndpoint;
@@ -250,11 +256,13 @@ function getDockerVersion() {
}
// 切换导航
function switchSection(section) {
function switchSection(section, event = null) {
// 检查是否尝试访问受token保护的section(日志查看、接口调试、系统配置需要token访问)
if (section === 'logs' || section === 'api' || section === 'env' || section === 'push') {
let _reverseProxy = "";
// 获取URL路径并提取token
const urlPath = window.location.pathname;
const urlPath = window.location.pathname.replace(_reverseProxy, "");
const pathParts = urlPath.split('/').filter(part => part !== '');
const urlToken = pathParts.length > 0 ? pathParts[0] : '';
@@ -285,7 +293,10 @@ function switchSection(section) {
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
document.getElementById(\`\${section}-section\`).classList.add('active');
event.target.classList.add('active');
// 在异步回调中使用传入的event参数来设置按钮的active状态
if (event && event.target) {
event.target.classList.add('active');
}
addLog(\`切换到\${section === 'env' ? '环境变量' : section === 'preview' ? '配置预览' : section === 'logs' ? '日志查看' : section === 'push' ? '推送弹幕' : '接口调试'}模块\`, 'info');
}
@@ -296,7 +307,9 @@ function switchSection(section) {
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
document.getElementById(\`\${section}-section\`).classList.add('active');
event.target.classList.add('active');
if (event && event.target) {
event.target.classList.add('active');
}
addLog(\`切换到\${section === 'env' ? '环境变量' : section === 'preview' ? '配置预览' : section === 'logs' ? '日志查看' : section === 'push' ? '推送弹幕' : '接口调试'}模块\`, 'info');
@@ -313,17 +326,21 @@ function switchSection(section) {
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
document.getElementById(\`\${section}-section\`).classList.add('active');
event.target.classList.add('active');
if (event && event.target) {
event.target.classList.add('active');
}
addLog(\`切换到\${section === 'env' ? '环境变量' : section === 'preview' ? '配置预览' : section === 'logs' ? '日志查看' : section === 'push' ? '推送弹幕' : '接口调试'}模块\`, 'info');
}
}
// 切换类别
function switchCategory(category) {
function switchCategory(category, event = null) {
currentCategory = category;
document.querySelectorAll('.category-btn').forEach(b => b.classList.remove('active'));
event.target.classList.add('active');
if (event && event.target) {
event.target.classList.add('active');
}
renderEnvList();
}
+1 -1
View File
@@ -5,7 +5,7 @@ function renderPreview() {
const preview = document.getElementById('preview-area');
// 从API获取真实配置数据
fetch('/api/config')
fetch(buildApiUrl('/api/config'))
.then(response => response.json())
.then(config => {
// 使用从API获取的分类环境变量
+3 -1
View File
@@ -255,8 +255,10 @@ function confirmDeploymentByLogs() {
// 检查URL中的token是否与currentAdminToken匹配
function checkAdminToken() {
let _reverseProxy = "";
// 获取URL路径并提取token
const urlPath = window.location.pathname;
const urlPath = window.location.pathname.replace(_reverseProxy, "");
const pathParts = urlPath.split('/').filter(part => part !== '');
const urlToken = pathParts.length > 0 ? pathParts[0] : currentToken; // 如果没有路径段,使用默认token
+11 -11
View File
@@ -49,11 +49,11 @@ export const HTML_TEMPLATE = /* html */ `
</div>
</div>
<div class="nav-buttons">
<button class="nav-btn active" onclick="switchSection('preview')">配置预览</button>
<button class="nav-btn" onclick="switchSection('logs')">日志查看</button>
<button class="nav-btn" onclick="switchSection('api')">接口调试</button>
<button class="nav-btn" onclick="switchSection('push')">推送弹幕</button>
<button class="nav-btn" onclick="switchSection('env')" id="env-nav-btn">系统配置</button>
<button class="nav-btn active" onclick="switchSection('preview', event)">配置预览</button>
<button class="nav-btn" onclick="switchSection('logs', event)">日志查看</button>
<button class="nav-btn" onclick="switchSection('api', event)">接口调试</button>
<button class="nav-btn" onclick="switchSection('push', event)">推送弹幕</button>
<button class="nav-btn" onclick="switchSection('env', event)" id="env-nav-btn">系统配置</button>
</div>
</div>
@@ -200,12 +200,12 @@ export const HTML_TEMPLATE = /* html */ `
</div>
<div class="env-categories">
<button class="category-btn active" onclick="switchCategory('api')">🔗 API配置</button>
<button class="category-btn" onclick="switchCategory('source')">📜 源配置</button>
<button class="category-btn" onclick="switchCategory('match')">🔍 匹配配置</button>
<button class="category-btn" onclick="switchCategory('danmu')">🔣 弹幕配置</button>
<button class="category-btn" onclick="switchCategory('cache')">💾 缓存配置</button>
<button class="category-btn" onclick="switchCategory('system')">⚙️ 系统配置</button>
<button class="category-btn active" onclick="switchCategory('api', event)">🔗 API配置</button>
<button class="category-btn" onclick="switchCategory('source', event)">📜 源配置</button>
<button class="category-btn" onclick="switchCategory('match', event)">🔍 匹配配置</button>
<button class="category-btn" onclick="switchCategory('danmu', event)">🔣 弹幕配置</button>
<button class="category-btn" onclick="switchCategory('cache', event)">💾 缓存配置</button>
<button class="category-btn" onclick="switchCategory('system', event)">⚙️ 系统配置</button>
</div>
<div class="env-list" id="env-list"></div>
+32 -2
View File
@@ -714,7 +714,7 @@ export function generateSign(path, timestamp, params, secretKey) {
signStr += secretKey;
return md5(signStr);
}
}
// ========== X-CA-Sign 生成函数 ==========
export function generateXCaSign(path, timestamp, queryString, secretKey) {
@@ -722,4 +722,34 @@ export function generateXCaSign(path, timestamp, queryString, secretKey) {
if (queryString) signStr += `?${queryString}`;
return createHmacSha256(secretKey, signStr);
}
}
// 兼容版本的 fromCodePoint
function fromCodePoint(codePoint) {
// BMP 范围内的字符可以直接用 fromCharCode
if (codePoint <= 0xFFFF) {
return String.fromCharCode(codePoint);
}
// 超出 BMP 的字符需要转换为代理对
codePoint -= 0x10000;
const highSurrogate = (codePoint >> 10) + 0xD800;
const lowSurrogate = (codePoint & 0x3FF) + 0xDC00;
return String.fromCharCode(highSurrogate, lowSurrogate);
}
/**
* 将HTML实体转换为实际字符
* @param {string} str - 包含HTML实体的字符串
* @returns {string} 转换后的字符串
*/
export function decodeHtmlEntities(str) {
// 处理十进制HTML实体 &#12345;
return str.replace(/&#(\d+);/g, (match, num) => {
return fromCodePoint(parseInt(num, 10));
})
// 处理十六进制HTML实体 &#x123A;
.replace(/&#x([0-9a-fA-F]+);/g, (match, hex) => {
return fromCodePoint(parseInt(hex, 16));
});
}
+25 -13
View File
@@ -16,7 +16,7 @@ WidgetMetadata = {
// 源配置
{
name: "sourceOrder",
title: "源排序配置,默认'360,vod,renren,hanjutv',可选['360', 'vod', 'tmdb', 'douban', 'tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'renren', 'hanjutv', 'bahamut', 'dandan']",
title: "源排序配置,默认'360,vod,renren,hanjutv',可选['360', 'vod', 'tmdb', 'douban', 'tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'renren', 'hanjutv', 'bahamut', 'dandan', 'custom']",
type: "input",
placeholders: [
{
@@ -68,6 +68,17 @@ WidgetMetadata = {
},
],
},
{
name: "customSourceApiUrl",
title: "自定义弹幕源API地址,默认为空,配置后还需在SOURCE_ORDER添加custom源",
type: "input",
placeholders: [
{
title: "自定义",
value: "",
},
],
},
{
name: "vodServers",
title: "VOD站点配置,格式:名称@URL,名称@URL,默认金蝉'https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top'",
@@ -140,12 +151,12 @@ WidgetMetadata = {
// 匹配配置
{
name: "platformOrder",
title: "平台优选配置,可选['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'renren', 'hanjutv', 'bahamut', 'dandan']",
title: "平台优选配置,可选['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'renren', 'hanjutv', 'bahamut', 'dandan', 'custom']",
type: "input",
placeholders: [
{
title: "配置1",
value: "qq,qiyi,imgo,bilibili1,youku,renren,hanjutv,bahamut,dandan",
value: "qq,qiyi,imgo,bilibili1,youku,renren,hanjutv,bahamut,dandan,custom",
},
{
title: "配置2",
@@ -410,7 +421,7 @@ if (typeof window !== 'undefined') {
// 初始化全局配置
let globals;
async function initGlobals(sourceOrder, otherServer, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
async function initGlobals(sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableEpisodeFilter, strictTitleMatch, blockedWords, groupMinute,
danmuLimit, danmuSimplified, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey) {
// 将传入的参数设置到环境变量中,以便Globals可以访问它们
@@ -418,6 +429,7 @@ async function initGlobals(sourceOrder, otherServer, vodServers, vodReturnMode,
if (sourceOrder !== undefined) env.SOURCE_ORDER = sourceOrder;
if (otherServer !== undefined) env.OTHER_SERVER = otherServer;
if (customSourceApiUrl !== undefined) env.CUSTOM_SOURCE_API_URL = customSourceApiUrl;
if (vodServers !== undefined) env.VOD_SERVERS = vodServers;
if (vodReturnMode !== undefined) env.VOD_RETURN_MODE = vodReturnMode;
if (vodRequestTimeout !== undefined) env.VOD_REQUEST_TIMEOUT = vodRequestTimeout;
@@ -498,11 +510,11 @@ async function removeCaches() {
const PREFIX_URL = "http://localhost:9321"
async function searchDanmu(params) {
const { tmdbId, type, title, season, link, videoUrl, sourceOrder, otherServer, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
const { tmdbId, type, title, season, link, videoUrl, sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableEpisodeFilter, strictTitleMatch, blockedWords, groupMinute,
danmuLimit, danmuSimplified, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey } = params;
await initGlobals(sourceOrder, otherServer, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
await initGlobals(sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableEpisodeFilter, strictTitleMatch, blockedWords, groupMinute,
danmuLimit, danmuSimplified, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey);
@@ -571,11 +583,11 @@ async function searchDanmu(params) {
}
async function getDetailById(params) {
const { animeId, sourceOrder, otherServer, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
const { animeId, sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableEpisodeFilter, strictTitleMatch, blockedWords, groupMinute,
danmuLimit, danmuSimplified, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey } = params;
await initGlobals(sourceOrder, otherServer, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
await initGlobals(sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableEpisodeFilter, strictTitleMatch, blockedWords, groupMinute,
danmuLimit, danmuSimplified, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey);
@@ -590,11 +602,11 @@ async function getDetailById(params) {
}
async function getCommentsById(params) {
const { commentId, link, videoUrl, season, episode, tmdbId, type, title, segmentTime, sourceOrder, otherServer, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
const { commentId, link, videoUrl, season, episode, tmdbId, type, title, segmentTime, sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableEpisodeFilter, strictTitleMatch, blockedWords, groupMinute,
danmuLimit, danmuSimplified, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey } = params;
await initGlobals(sourceOrder, otherServer, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
await initGlobals(sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableEpisodeFilter, strictTitleMatch, blockedWords, groupMinute,
danmuLimit, danmuSimplified, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey);
@@ -611,7 +623,7 @@ async function getCommentsById(params) {
log("info", "segmentList:", segmentList);
if (lastCommentId === commentId && segmentList) {
return await getDanmuWithSegmentTime({ segmentTime, tmdbId, season, episode, otherServer, vodServers, bilibiliCookie, sourceOrder, blockedWords, groupMinute,
return await getDanmuWithSegmentTime({ segmentTime, tmdbId, season, episode, otherServer, customSourceApiUrl, vodServers, bilibiliCookie, sourceOrder, blockedWords, groupMinute,
vodReturnMode, vodRequestTimeout, platformOrder, episodeTitleFilter, enableEpisodeFilter, strictTitleMatch,
danmuLimit, danmuSimplified, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey })
} else {
@@ -637,11 +649,11 @@ async function getCommentsById(params) {
}
async function getDanmuWithSegmentTime(params) {
const { segmentTime, tmdbId, season, episode, sourceOrder, otherServer, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
const { segmentTime, tmdbId, season, episode, sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableEpisodeFilter, strictTitleMatch, blockedWords, groupMinute,
danmuLimit, danmuSimplified, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey } = params;
await initGlobals(sourceOrder, otherServer, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
await initGlobals(sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableEpisodeFilter, strictTitleMatch, blockedWords, groupMinute,
danmuLimit, danmuSimplified, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey);
+1
View File
@@ -218,6 +218,7 @@ async function testNewFlow() {
episodeName: '第2期上:首次大约会!温柔医生为爱冲锋',
sourceOrder: 'douban',
otherServer: 'https://api.danmu.icu',
customSourceApiUrl: '',
vodServers: '金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top',
vodReturnMode: 'fastest',
vodRequestTimeout: 10000,