From 2ad2d5fa1af16017f27e29fabe5b9dbeb2493e34 Mon Sep 17 00:00:00 2001
From: huangxd- <649924757@qq.com>
Date: Sun, 8 Feb 2026 23:16:26 +0800
Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=92=AA=E5=92=95=E6=BA=90?=
=?UTF-8?q?=E5=B9=B6=E5=A2=9E=E5=8A=A0=E8=AF=B7=E6=B1=82=E8=AE=B0=E5=BD=95?=
=?UTF-8?q?=E6=A0=87=E7=AD=BE=E9=A1=B5=20(#186)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 增加咪咕源
* 完善咪咕源,包括从豆瓣源获取咪咕弹幕
* 修改弹幕源简介
* EPISODE_TITLE_FILTER环境变量不再应用于剧标题过滤,只对集标题生效
* 增加处理简繁同源场景,比如吃->喫;注意->註意,实际不用转换
* 增加请求历史记录
* 增加请求记录ui界面
* 删除清空记录按钮
* 将请求记录的参数改为请求body体
* 对record.interface进行解码
* 请求参数为空的清空下不显示请求体
* 在没有请求体的情况下虚线也不显示
* 增加todayReqNum全局变量,用于记录今日请求数量
* 在UI界面显示今日请求总数
* 修改README
* 修复云部署的服务配置redis后请求记录还是0的问题
---
README.md | 21 +-
config/.env.example | 10 +-
danmu_api/apis/dandan-api.js | 37 +-
danmu_api/apis/system-api.js | 14 +-
danmu_api/configs/envs.js | 8 +-
danmu_api/configs/globals.js | 12 +-
danmu_api/sources/custom.js | 2 +-
danmu_api/sources/dandan.js | 2 +-
danmu_api/sources/douban.js | 17 +-
danmu_api/sources/hanjutv.js | 2 +-
danmu_api/sources/migu.js | 368 ++++++++++++
danmu_api/sources/xigua.js | 6 +-
danmu_api/ui/README.md | 22 +-
danmu_api/ui/css/components.css.js | 147 +++++
danmu_api/ui/js/main.js | 8 +-
danmu_api/ui/js/requestrecords.js | 111 ++++
danmu_api/ui/template.js | 18 +-
danmu_api/utils/cache-util.js | 8 +-
danmu_api/utils/codec-util.js | 4 +-
danmu_api/utils/danmu-util.js | 9 +
danmu_api/utils/migu-util.js | 869 +++++++++++++++++++++++++++++
danmu_api/utils/redis-util.js | 12 +-
danmu_api/utils/zh-util.js | 24 +-
danmu_api/worker.js | 93 ++-
danmu_api/worker.test.js | 25 +
forward/forward-widget.js | 6 +-
26 files changed, 1793 insertions(+), 62 deletions(-)
create mode 100644 danmu_api/sources/migu.js
create mode 100644 danmu_api/ui/js/requestrecords.js
create mode 100644 danmu_api/utils/migu-util.js
diff --git a/README.md b/README.md
index c83fdf2..bff936b 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@ LogVar 弹幕 API 服务器
---
-一个人人都能部署的基于 js 的弹幕 API 服务器,支持爱优腾芒哔人韩巴弹幕直接获取,兼容弹弹play的搜索、详情查询和弹幕获取接口规范,并提供日志记录,支持vercel/netlify/edgeone/cloudflare/docker/claw等部署方式,不用提前下载弹幕,没有nas或小鸡也能一键部署。
+一个人人都能部署的基于 js 的弹幕 API 服务器,支持爱优腾芒哔咪人韩巴狐乐西弹幕直接获取,兼容弹弹play的搜索、详情查询和弹幕获取接口规范,并提供日志记录,支持vercel/netlify/edgeone/cloudflare/docker/claw等部署方式,不用提前下载弹幕,没有nas或小鸡也能一键部署。
本项目仅为个人爱好开发,代码开源。如有任何侵权行为,请联系本人删除。
@@ -71,7 +71,7 @@ LogVar 弹幕 API 服务器
- 本地和Docker部署支持实时保存缓存到文件(挂载.cache目录即可)
- **部署支持**:支持本地运行、Docker 容器化、Vercel 一键部署、Netlify 一键部署、Edgeone 一键部署、Cloudflare 一键部署、Claw部署和 Docker 一键启动。
- **手动选择记忆**:支持记住之前搜索title时手动选择的anime,并在后续的match自动匹配时优选该anime【实验性】。
-- **手动搜索支持输入播放链接获取弹幕**:支持手动搜索的播放器输入爱优腾芒哔播放链接可获取弹幕,如`senplayer`。
+- **手动搜索支持输入播放链接获取弹幕**:支持手动搜索的播放器输入爱优腾芒哔咪狐乐西播放链接可获取弹幕,如`senplayer`。
- **弹幕转换功能**:支持通过环境变量配置弹幕转换规则,包括:
- 将顶部和底部弹幕转换为浮动弹幕(`CONVERT_TOP_BOTTOM_TO_SCROLL`)
- 转换弹幕颜色为白色或彩色(`CONVERT_COLOR`)
@@ -85,6 +85,7 @@ LogVar 弹幕 API 服务器
- 日志查看
- 接口调试
- 推送弹幕
+ - 请求记录
- 系统管理
## 前置条件
@@ -318,7 +319,7 @@ LogVar 弹幕 API 服务器
-4. 现已支持手动搜索标题输入爱优腾芒哔播放链接获取弹幕。
+4. 现已支持手动搜索标题输入爱优腾芒哔咪狐乐西播放链接获取弹幕。
### XML 格式说明
@@ -371,9 +372,9 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
| VOD_REQUEST_TIMEOUT | 【可选】VOD服务器单个请求超时时间(毫秒),防止慢速或失效的采集站阻塞搜索,默认为`10000`(10秒),建议值:`5000-15000`。由于`fastest`模式只返回最快响应的站点,可以设置较大的超时时间给慢速站点更多机会 |
| BILIBILI_COOKIE | 【可选】b站cookie(填入后能抓取完整弹幕和启用港澳台App接口),如 `buvid3=E2BCA ... eao6; theme-avatar-tip-show=SHOWED`,请自行通过浏览器或抓包工具抓取,热心网友测试后,弹幕获取实际最少只需取 `SESSDATA=xxxx` 字段,但如果需要使用港澳台区域稳定的App搜索接口还需要`bili_jct=xxxx`或`access_key=xxxx` 字段,不知道怎么获取cookie的,可以从工具 [cookie-butler](https://cookie-butler.do-u.me) 获取 |
| 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,sohu,leshi,xigua,renren,hanjutv,bahamut,dandan,animeko,custom` |
-| PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台,当配置合并平台的时候为指定期望的合并源;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, sohu, leshi, xigua, renren, hanjutv, bahamut, dandan, animeko, custom` |
-| MERGE_SOURCE_PAIRS | 【可选】源合并配置,配置后将对应源合并同时一起获取弹幕返回,默认为空,格式是`源字段&源字段&源字段`,示例:`imgo&iqiyi,dandan&bahamut&animeko,imgo`, 允许多组、允许同时存在、允许多源,允许填单源表示保留原结果,一组中第一个为主源其余为副源,副源往主源合并,主源如果没有结果会轮替下一个作为主源循环,目前允许合并的源字段有`tencent,youku,iqiyi,imgo,bilibili,sohu,leshi,xigua,renren,hanjutv,bahamut,dandan,animeko` |
+| SOURCE_ORDER | 【可选】源排序,用于按源对返回资源的排序(注意:先后顺序会影响自动匹配最终的返回),默认是`360,vod,renren,hanjutv`,表示360数据排在最前,hanjutv数据排在最后,示例:`360,renren`:只返回360数据和renren数据,且360数据靠前;当前可选择的源字段有 `360,vod,tmdb,douban,tencent,youku,iqiyi,imgo,bilibili,migu,sohu,leshi,xigua,renren,hanjutv,bahamut,dandan,animeko,custom` |
+| PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台,当配置合并平台的时候为指定期望的合并源;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, migu, sohu, leshi, xigua, renren, hanjutv, bahamut, dandan, animeko, custom` |
+| MERGE_SOURCE_PAIRS | 【可选】源合并配置,配置后将对应源合并同时一起获取弹幕返回,默认为空,格式是`源字段&源字段&源字段`,示例:`imgo&iqiyi,dandan&bahamut&animeko,imgo`, 允许多组、允许同时存在、允许多源,允许填单源表示保留原结果,一组中第一个为主源其余为副源,副源往主源合并,主源如果没有结果会轮替下一个作为主源循环,目前允许合并的源字段有`tencent,youku,iqiyi,imgo,bilibili,migu,sohu,leshi,xigua,renren,hanjutv,bahamut,dandan,animeko` |
| 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` |
@@ -437,13 +438,14 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
| ----------- | ----------- |
| 360 | qiyi, bilibili1, imgo, youku, qq |
| vod | qiyi, bilibili1, imgo, youku, qq |
-| tmdb | qiyi, bilibili1, youku, qq |
-| douban | qiyi, bilibili1, youku, qq |
+| tmdb | qiyi, bilibili1, youku, qq, migu |
+| douban | qiyi, bilibili1, youku, qq, migu |
| tencent | qq |
| youku | youku |
| iqiyi | qiyi |
| imgo | imgo |
| bilibili | bilibili1 |
+| migu | migu |
| sohu | sohu |
| leshi | leshi |
| xigua | xigua |
@@ -503,6 +505,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
│ │ ├── kan360.js # 360看源
│ │ ├── leshi.js # 乐视视频源
│ │ ├── mango.js # 芒果TV源
+│ │ ├── migu.js # 咪咕视频源
│ │ ├── other.js # 第三方弹幕服务器
│ │ ├── renren.js # 人人视频源
│ │ ├── sohu.js # 搜狐视频源
@@ -525,6 +528,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
│ │ ├── main.js # UI主脚本
│ │ ├── preview.js # 预览功能脚本
│ │ ├── pushdanmu.js # 推送弹幕脚本
+│ │ ├── requestrecords.js # 请求记录脚本
│ │ └── systemsettings.js # 系统设置脚本
│ └── utils/
│ ├── cache-util.js # 缓存数据处理工具
@@ -537,6 +541,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
│ ├── imdb-util.js # IMDB API请求工具
│ ├── log-util.js # 日志工具
│ ├── merge-util.js # 源合并处理工具
+│ ├── migu-util.js # 咪咕工具
│ ├── redis-util.js # redis工具
│ ├── time-util.js # 时间日期工具
│ ├── tmdb-util.js # TMDB API请求处理工具
diff --git a/config/.env.example b/config/.env.example
index d9d94ca..5f99679 100644
--- a/config/.env.example
+++ b/config/.env.example
@@ -68,9 +68,9 @@ YOUKU_CONCURRENCY=8
# ==================== 源排序配置 ====================
-# 数据源查询顺序(可选值:360, vod, tmdb, douban, tencent, youku, iqiyi, imgo, bilibili, renren, hanjutv, bahamut, dandan, sohu, leshi, xigua, animeko, custom)
-# 默认值:360,vod,renren,hanjutv(不包含 tencent, youku, iqiyi, imgo, bilibili, sohu , leshi, xigua等)
-# 如需使用腾讯源、优酷源、爱奇艺源、芒果源、B站源、搜狐源、乐视源、西瓜源,请手动添加:SOURCE_ORDER=360,vod,tencent,youku,iqiyi,imgo,bilibili,ren,hanjutv,sohu,leshi,xigua
+# 数据源查询顺序(可选值:360, vod, tmdb, douban, tencent, youku, iqiyi, imgo, bilibili, migu, renren, hanjutv, bahamut, dandan, sohu, leshi, xigua, animeko, custom)
+# 默认值:360,vod,renren,hanjutv(不包含 tencent, youku, iqiyi, imgo, bilibili, migu, sohu , leshi, xigua等)
+# 如需使用腾讯源、优酷源、爱奇艺源、芒果源、B站源、搜狐源、乐视源、西瓜源,请手动添加:SOURCE_ORDER=360,vod,tencent,youku,iqiyi,imgo,bilibili,migu,renren,hanjutv,sohu,leshi,xigua
# 如需使用巴哈姆特源,请手动添加:SOURCE_ORDER=360,vod,renren,hanjutv,bahamut
# 如需使用弹弹play源,请手动添加:SOURCE_ORDER=360,vod,renren,hanjutv,dandan
# 如需使用豆瓣源,请手动添加:SOURCE_ORDER=360,vod,douban,renren,hanjutv
@@ -81,12 +81,12 @@ SOURCE_ORDER=360,vod,renren,hanjutv
# 平台优先级排序(用于自动匹配)
# 默认值:空(自动匹配)
-# 可选值:qiyi, bilibili1, imgo, youku, qq, sohu, leshi, xigua, renren, hanjutv, bahamut, dandan, animeko, custom 或 qiyi&bilibili1&imgo,dandan&animeko
+# 可选值:qiyi, bilibili1, imgo, youku, qq, migu, sohu, leshi, xigua, renren, hanjutv, bahamut, dandan, animeko, custom 或 qiyi&bilibili1&imgo,dandan&animeko
# 说明:平台排序配置,可以配置自动匹配时的优选平台,当配置合并平台的时候可以指定期望的合并源
# PLATFORM_ORDER=
# ==================== 源合并配置 ====================
-# 数据源合并配置(可选值:tencent, youku, iqiyi, imgo, bilibili, renren, hanjutv, bahamut, dandan, sohu, leshi, xigua, animeko)
+# 数据源合并配置(可选值:tencent, youku, iqiyi, imgo, bilibili, migu, renren, hanjutv, bahamut, dandan, sohu, leshi, xigua, animeko)
# 默认值:空
# 说明:配置后将对应源合并同时一起获取弹幕返回,允许多组、允许同时存在、允许多源,允许填单源表示保留原结果,一组中第一个为主源其余为副源,副源往主源合并,主源如果没有结果会轮替下一个作为主源循环
# 格式:源字段&源字段&源字段,源字段
diff --git a/danmu_api/apis/dandan-api.js b/danmu_api/apis/dandan-api.js
index 298697c..465cf86 100644
--- a/danmu_api/apis/dandan-api.js
+++ b/danmu_api/apis/dandan-api.js
@@ -24,6 +24,7 @@ import TencentSource from "../sources/tencent.js";
import IqiyiSource from "../sources/iqiyi.js";
import MangoSource from "../sources/mango.js";
import BilibiliSource from "../sources/bilibili.js";
+import MiguSource from "../sources/migu.js";
import YoukuSource from "../sources/youku.js";
import SohuSource from "../sources/sohu.js";
import LeshiSource from "../sources/leshi.js";
@@ -48,12 +49,13 @@ const youkuSource = new YoukuSource();
const iqiyiSource = new IqiyiSource();
const mangoSource = new MangoSource();
const bilibiliSource = new BilibiliSource();
+const miguSource = new MiguSource();
const sohuSource = new SohuSource();
const leshiSource = new LeshiSource();
const xiguaSource = new XiguaSource();
const animekoSource = new AnimekoSource();
const otherSource = new OtherSource();
-const doubanSource = new DoubanSource(tencentSource, iqiyiSource, youkuSource, bilibiliSource);
+const doubanSource = new DoubanSource(tencentSource, iqiyiSource, youkuSource, bilibiliSource, miguSource);
const tmdbSource = new TmdbSource(doubanSource);
// 用于聚合请求的去重Map
@@ -156,6 +158,14 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null
platform = "youku";
} else if (queryTitle.includes(".bilibili.com")) {
platform = "bilibili1";
+ } else if (queryTitle.includes('.miguvideo.com')) {
+ platform = "migu";
+ } else if (queryTitle.includes('.sohu.com')) {
+ platform = "sohu";
+ } else if (queryTitle.includes('.le.com')) {
+ platform = "leshi";
+ } else if (queryTitle.includes('.douyin.com') || queryTitle.includes('.ixigua.com')) {
+ platform = "xigua";
}
const pageTitle = await getPageTitle(queryTitle);
@@ -204,6 +214,7 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null
if (source === "iqiyi") return iqiyiSource.search(queryTitle);
if (source === "imgo") return mangoSource.search(queryTitle);
if (source === "bilibili") return bilibiliSource.search(queryTitle);
+ if (source === "migu") return miguSource.search(queryTitle);
if (source === "sohu") return sohuSource.search(queryTitle);
if (source === "leshi") return leshiSource.search(queryTitle);
if (source === "xigua") return xiguaSource.search(queryTitle);
@@ -226,7 +237,7 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null
vod: animesVodResults, 360: animes360, tmdb: animesTmdb, douban: animesDouban, renren: animesRenren,
hanjutv: animesHanjutv, bahamut: animesBahamut, dandan: animesDandan, custom: animesCustom,
tencent: animesTencent, youku: animesYouku, iqiyi: animesIqiyi, imgo: animesImgo, bilibili: animesBilibili,
- sohu: animesSohu, leshi: animesLeshi, xigua: animesXigua, animeko: animesAnimeko
+ migu: animesMigu, sohu: animesSohu, leshi: animesLeshi, xigua: animesXigua, animeko: animesAnimeko
} = resultData;
// 按顺序处理每个来源的结果
@@ -279,6 +290,9 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null
} else if (key === 'bilibili') {
// 等待处理Bilibili来源
await bilibiliSource.handleAnimes(animesBilibili, queryTitle, curAnimes);
+ } else if (key === 'migu') {
+ // 等待处理Migu来源
+ await miguSource.handleAnimes(animesMigu, queryTitle, curAnimes);
} else if (key === 'sohu') {
// 等待处理Sohu来源
await sohuSource.handleAnimes(animesSohu, queryTitle, curAnimes);
@@ -308,12 +322,12 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null
if (globals.enableEpisodeFilter) {
const validAnimes = [];
for (const anime of curAnimes) {
- // 首先检查动漫名称是否包含过滤关键词
- const animeTitle = anime.animeTitle || '';
- if (globals.episodeTitleFilter.test(animeTitle)) {
- log("info", `[searchAnime] Anime ${anime.animeId} filtered by name: ${animeTitle}`);
- continue; // 跳过该动漫
- }
+ // // 首先检查动漫名称是否包含过滤关键词
+ // const animeTitle = anime.animeTitle || '';
+ // if (globals.episodeTitleFilter.test(animeTitle)) {
+ // log("info", `[searchAnime] Anime ${anime.animeId} filtered by name: ${animeTitle}`);
+ // continue; // 跳过该动漫
+ // }
const animeData = globals.animes.find(a => a.animeId === anime.animeId);
if (animeData && animeData.links) {
@@ -1094,6 +1108,7 @@ async function fetchMergedComments(url) {
else if (sourceName === 'iqiyi') sourceInstance = iqiyiSource;
else if (sourceName === 'imgo') sourceInstance = mangoSource;
else if (sourceName === 'bilibili') sourceInstance = bilibiliSource;
+ else if (sourceName === 'migu') sourceInstance = miguSource;
else if (sourceName === 'sohu') sourceInstance = sohuSource;
else if (sourceName === 'leshi') sourceInstance = leshiSource;
else if (sourceName === 'xigua') sourceInstance = xiguaSource;
@@ -1185,6 +1200,8 @@ export async function getComment(path, queryFormat, segmentFlag) {
danmus = await bilibiliSource.getComments(url, plat, segmentFlag);
} else if (url.includes('.youku.com')) {
danmus = await youkuSource.getComments(url, plat, segmentFlag);
+ } else if (url.includes('.miguvideo.com')) {
+ danmus = await miguSource.getComments(url, plat, segmentFlag);
} else if (url.includes('.sohu.com')) {
danmus = await sohuSource.getComments(url, plat, segmentFlag);
} else if (url.includes('.le.com')) {
@@ -1298,6 +1315,8 @@ export async function getCommentByUrl(videoUrl, queryFormat, segmentFlag) {
danmus = await bilibiliSource.getComments(url, "bilibili1", segmentFlag);
} else if (url.includes('.youku.com')) {
danmus = await youkuSource.getComments(url, "youku", segmentFlag);
+ } else if (url.includes('.miguvideo.com')) {
+ danmus = await miguSource.getComments(url, "migu", segmentFlag);
} else if (url.includes('.sohu.com')) {
danmus = await sohuSource.getComments(url, "sohu", segmentFlag);
} else if (url.includes('.le.com')) {
@@ -1383,6 +1402,8 @@ export async function getSegmentComment(segment, queryFormat) {
danmus = await bilibiliSource.getSegmentComments(segment);
} else if (platform === "youku") {
danmus = await youkuSource.getSegmentComments(segment);
+ } else if (platform === "migu") {
+ danmus = await miguSource.getSegmentComments(segment);
} else if (platform === "sohu") {
danmus = await sohuSource.getSegmentComments(segment);
} else if (platform === "leshi") {
diff --git a/danmu_api/apis/system-api.js b/danmu_api/apis/system-api.js
index 92f9c8a..ee44aa3 100644
--- a/danmu_api/apis/system-api.js
+++ b/danmu_api/apis/system-api.js
@@ -74,7 +74,7 @@ export function handleConfig(hasPermission = false) {
originalEnvVars: originalEnvVars, // 系统设置使用原始环境变量(已脱敏)
hasAdminToken: hasAdminToken, // 添加admin token配置状态
repository: "https://github.com/huangxd-/danmu_api.git",
- description: "一个人人都能部署的基于 js 的弹幕 API 服务器,支持爱优腾芒哔人韩巴弹幕直接获取,兼容弹弹play的搜索、详情查询和弹幕获取接口规范,并提供日志记录,支持vercel/netlify/edgeone/cloudflare/docker/claw等部署方式,不用提前下载弹幕,没有nas或小鸡也能一键部署。",
+ description: "一个人人都能部署的基于 js 的弹幕 API 服务器,支持爱优腾芒哔咪人韩巴狐乐西弹幕直接获取,兼容弹弹play的搜索、详情查询和弹幕获取接口规范,并提供日志记录,支持vercel/netlify/edgeone/cloudflare/docker/claw等部署方式,不用提前下载弹幕,没有nas或小鸡也能一键部署。",
notice: "本项目仅为个人爱好开发,代码开源。如有任何侵权行为,请联系本人删除。有问题提issue或私信机器人都ok,TG MSG ROBOT: [https://t.me/ddjdd_bot]; 推荐加互助群咨询,TG GROUP: [https://t.me/logvar_danmu_group]; 关注频道获取最新更新内容,TG CHANNEL: [https://t.me/logvar_danmu_channel]。"
});
}
@@ -196,3 +196,15 @@ export async function handleClearCache() {
return jsonResponse({ success: false, message: `Cache clear failed: ${error.message}` }, 500);
}
}
+
+/**
+ * 处理获取请求记录的请求
+ * @returns {Response} 包含请求记录的响应
+ */
+export function handleReqRecords() {
+ // 返回请求记录,按时间倒序排列(最新的在前)
+ const records = [...globals.reqRecords].reverse();
+ const todayReqNum = globals.todayReqNum || 0;
+ return jsonResponse({ records, todayReqNum }, 200);
+}
+
diff --git a/danmu_api/configs/envs.js b/danmu_api/configs/envs.js
index 2e1dc54..95cccc1 100644
--- a/danmu_api/configs/envs.js
+++ b/danmu_api/configs/envs.js
@@ -9,10 +9,10 @@ export class Envs {
static originalEnvVars = new Map();
static accessedEnvVars = new Map();
- static VOD_ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'sohu', 'leshi', 'xigua']; // vod允许的播放平台
- static ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'leshi', 'xigua', 'animeko', 'custom']; // 全部源允许的播放平台
- static ALLOWED_SOURCES = ['360', 'vod', 'tmdb', 'douban', 'tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'leshi', 'xigua', 'animeko', 'custom']; // 允许的源
- static MERGE_ALLOWED_SOURCES = ['tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'leshi', 'xigua', 'animeko']; // 允许的源合并
+ static VOD_ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'migu', 'sohu', 'leshi', 'xigua']; // vod允许的播放平台
+ static ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'migu', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'leshi', 'xigua', 'animeko', 'custom']; // 全部源允许的播放平台
+ static ALLOWED_SOURCES = ['360', 'vod', 'tmdb', 'douban', 'tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'migu', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'leshi', 'xigua', 'animeko', 'custom']; // 允许的源
+ static MERGE_ALLOWED_SOURCES = ['tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'migu', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'leshi', 'xigua', 'animeko']; // 允许的源合并
/**
* 获取环境变量
diff --git a/danmu_api/configs/globals.js b/danmu_api/configs/globals.js
index f2556c1..2e03deb 100644
--- a/danmu_api/configs/globals.js
+++ b/danmu_api/configs/globals.js
@@ -13,9 +13,10 @@ export const Globals = {
accessedEnvVars: {},
// 静态常量
- VERSION: '1.13.8',
+ VERSION: '1.14.0',
MAX_LOGS: 1000, // 日志存储,最多保存 1000 行
MAX_ANIMES: 100,
+ MAX_RECORDS: 100, // 请求记录最大数量
// 运行时状态
animes: [],
@@ -28,11 +29,15 @@ export const Globals = {
redisValid: false, // redis是否生效
redisCacheInitialized: false, // redis 缓存是否已初始化
lastSelectMap: new Map(), // 存储查询关键字上次选择的animeId,用于下次match自动匹配时优先选择该anime
+ reqRecords: [], // 记录请求历史,包括接口/参数/请求时间
+ todayReqNum: 0, // 今日请求数量统计
lastHashes: { // 存储上一次各变量哈希值
animes: null,
episodeIds: null,
episodeNum: null,
- lastSelectMap: null
+ lastSelectMap: null,
+ reqRecords: null,
+ todayReqNum: null
},
searchCache: new Map(), // 搜索结果缓存,存储格式:{ keyword: { results, timestamp } }
commentCache: new Map(), // 弹幕缓存,存储格式:{ videoUrl: { comments, timestamp } }
@@ -158,6 +163,7 @@ export const Globals = {
if (prop === 'version') return self.VERSION;
if (prop === 'maxLogs') return self.MAX_LOGS;
if (prop === 'maxAnimes') return self.MAX_ANIMES;
+ if (prop === 'maxRecords') return self.MAX_RECORDS;
if (prop === 'maxLastSelectMap') return self.MAX_LAST_SELECT_MAP;
// 暴露方法
@@ -203,4 +209,4 @@ export const globals = new Proxy({}, {
getOwnPropertyDescriptor(target, prop) {
return Object.getOwnPropertyDescriptor(Globals.getConfig(), prop);
}
-});
+});
\ No newline at end of file
diff --git a/danmu_api/sources/custom.js b/danmu_api/sources/custom.js
index e7d362f..79b5746 100644
--- a/danmu_api/sources/custom.js
+++ b/danmu_api/sources/custom.js
@@ -32,7 +32,7 @@ export default class CustomSource extends BaseSource {
}
// 正常情况下输出 JSON 字符串
- log("info", `customnSourceSearchresp: ${JSON.stringify(resp.data.animes)}`);
+ log("info", `[Custom] 搜索找到 ${resp.data.animes.length} 个有效结果`);
return resp.data.animes;
} catch (error) {
diff --git a/danmu_api/sources/dandan.js b/danmu_api/sources/dandan.js
index 4cc2318..fa21e27 100644
--- a/danmu_api/sources/dandan.js
+++ b/danmu_api/sources/dandan.js
@@ -32,7 +32,7 @@ export default class DandanSource extends BaseSource {
}
// 正常情况下输出 JSON 字符串
- log("info", `dandanSearchresp: ${JSON.stringify(resp.data.animes)}`);
+ log("info", `[Dandan] 搜索找到 ${resp.data.animes.length} 个有效结果`);
return resp.data.animes;
} catch (error) {
diff --git a/danmu_api/sources/douban.js b/danmu_api/sources/douban.js
index 43f6d1d..7525bc0 100644
--- a/danmu_api/sources/douban.js
+++ b/danmu_api/sources/douban.js
@@ -6,12 +6,13 @@ import { getDoubanDetail, searchDoubanTitles } from "../utils/douban-util.js";
// 获取豆瓣源播放链接
// =====================
export default class DoubanSource extends BaseSource {
- constructor(tencentSource, iqiyiSource, youkuSource, bilibiliSource) {
+ constructor(tencentSource, iqiyiSource, youkuSource, bilibiliSource, miguSource) {
super('BaseSource');
this.tencentSource = tencentSource;
this.iqiyiSource = iqiyiSource;
this.youkuSource = youkuSource;
this.bilibiliSource = bilibiliSource;
+ this.miguSource = miguSource;
}
async search(keyword) {
@@ -143,6 +144,20 @@ export default class DoubanSource extends BaseSource {
}
break;
}
+ case "miguvideo": {
+ let epId = null;
+ const decodeUrl = decodeURIComponent(vendor.uri);
+ const contentIdMatch = decodeUrl.match(/"contentID":"([^"]+)"/);
+ if (contentIdMatch && contentIdMatch[1]) {
+ epId = contentIdMatch[1];
+ }
+ if (epId) {
+ tmpAnimes[0].provider = "migu";
+ tmpAnimes[0].mediaId = `https://v3-sc.miguvideo.com/program/v4/cont/content-info/${epId}/1`;
+ await this.miguSource.handleAnimes(tmpAnimes, response.data?.title, doubanAnimes)
+ }
+ break;
+ }
}
}
return results;
diff --git a/danmu_api/sources/hanjutv.js b/danmu_api/sources/hanjutv.js
index e4f41e0..b5229bf 100644
--- a/danmu_api/sources/hanjutv.js
+++ b/danmu_api/sources/hanjutv.js
@@ -34,7 +34,7 @@ export default class HanjutvSource extends BaseSource {
}
// 正常情况下输出 JSON 字符串
- log("info", `hanjutvSearchresp: ${JSON.stringify(resp.data.seriesData.seriesList)}`);
+ log("info", `[Hanjutv] 搜索找到 ${resp.data.seriesData.seriesList.length} 个有效结果`);
let resList = [];
for (const anime of resp.data.seriesData.seriesList) {
diff --git a/danmu_api/sources/migu.js b/danmu_api/sources/migu.js
new file mode 100644
index 0000000..8be85d5
--- /dev/null
+++ b/danmu_api/sources/migu.js
@@ -0,0 +1,368 @@
+import BaseSource from './base.js';
+import { globals } from '../configs/globals.js';
+import { log } from "../utils/log-util.js";
+import { httpGet, httpPost } from "../utils/http-util.js";
+import { convertToAsciiSum } from "../utils/codec-util.js";
+import { hexToInt } from "../utils/danmu-util.js";
+import { generateValidStartDate, time_to_second } from "../utils/time-util.js";
+import { addAnime, removeEarliestAnime } from "../utils/cache-util.js";
+import { decrypt } from "../utils/migu-util.js";
+import { printFirst200Chars, titleMatches } from "../utils/common-util.js";
+import { SegmentListResponse } from '../models/dandan-model.js';
+
+// =====================
+// 获取咪咕视频弹幕
+// =====================
+class MiguSource extends BaseSource {
+ async search(keyword) {
+ try {
+ const searchUrl = `https://jadeite.migu.cn/search/v3/open-search`;
+
+ const payload = {
+ appVersion: "6.1.1.00",
+ ct: 101,
+ isCorrectWord: 1,
+ k: keyword,
+ mediaSource: 9000000,
+ packId: "1002581,1002601,1003861,1003862,1003863,1003864,1003865,1003866,1004041,1004121,1004261,1004262,1004281,1004321,1004262,1004281,1004322,1004261,1004421,1004422,1002781,1004301,1004641,1004761,1005061,1005261,1005301,1005321,1005361,1005362,1005341,1005342,1005521,1005722,1005721,1015749,1015761,1015760,1015762,1015763,1015768,1015786,1015790,1015812,1015813,1015814,1015815,1015816,1015817,1015820,1015819,1015821",
+ pageIdx: 1,
+ pageSize: 20,
+ sid: "1X4A395AL3XS3UV5MT8PQ1L5JUYK6KFM8VKDSDD48S5Y7YX5WY1MOUHRT6512988",
+ copyrightTerminal: 3,
+ searchScene: 2,
+ uiVersion: "A3.26.0"
+ };
+
+ const headers = {
+ "Accept": "application/json, text/plain, */*",
+ "Accept-Language": "zh-CN,zh;q=0.9",
+ "Connection": "keep-alive",
+ "Content-Type": "application/json",
+ "Origin": "https://www.miguvideo.com",
+ "Referer": "https://www.miguvideo.com/",
+ "Sec-Fetch-Dest": "empty",
+ "Sec-Fetch-Mode": "cors",
+ "Sec-Fetch-Site": "cross-site",
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36",
+ "appId": "miguvideo",
+ "sec-ch-ua": 'Not(A:Brand";v="8", "Chromium";v="144", "Google Chrome";v="144"',
+ "sec-ch-ua-mobile": "?0",
+ "sec-ch-ua-platform": "Windows",
+ "terminalId": "www"
+ };
+
+ const response = await httpPost(searchUrl, JSON.stringify(payload), {
+ headers: headers
+ });
+
+ if (!response || !response.data) {
+ log("info", "[Migu] 搜索响应为空");
+ return [];
+ }
+
+ const data = typeof response.data === "string" ? JSON.parse(response.data) : response.data;
+
+ const contentInfoList = data?.body?.contentInfoList;
+
+ const animes = [];
+ contentInfoList.forEach(contentInfo => {
+ const shortMediaAsset = contentInfo?.shortMediaAsset;
+
+ if (shortMediaAsset && shortMediaAsset.isLong) {
+ let epId;
+ if (shortMediaAsset?.extraData) {
+ epId = shortMediaAsset?.extraData?.episodes?.[0];
+ } else {
+ epId = shortMediaAsset?.pID;
+ }
+
+ animes.push({
+ name: shortMediaAsset.name,
+ type: shortMediaAsset.contDisplayName,
+ year: shortMediaAsset.year.trim(),
+ img: shortMediaAsset.h5pics?.highResolutionV,
+ url: `https://v3-sc.miguvideo.com/program/v4/cont/content-info/${epId}/1`,
+ epsId: epId
+ });
+ }
+ });
+
+ // 正常情况下输出 JSON 字符串
+ log("info", `[Migu] 搜索找到 ${animes.length} 个有效结果`);
+ return animes;
+ } catch (error) {
+ // 捕获请求中的错误
+ log("error", "getMiguAnimes error:", {
+ message: error.message,
+ name: error.name,
+ stack: error.stack,
+ });
+ return [];
+ }
+ }
+
+ extractEpId(url) {
+ // 先去除 ? 及其后的所有字符
+ const baseUrl = url.split('?')[0];
+
+ // 分割路径,获取最后一个 / 后的部分(非空)
+ const segments = baseUrl.split('/').filter(segment => segment !== '');
+ return segments[segments.length - 1] || '';
+ }
+
+ async getDetail(id) {
+ try {
+ const resp = await httpGet(`https://v3-sc.miguvideo.com/program/v4/cont/content-info/${id}/1`, {
+ headers: {
+ "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36"
+ }
+ });
+
+ // 判断 resp 和 resp.data 是否存在
+ if (!resp || !resp.data) {
+ log("info", "getMiguDetail: 请求失败或无数据返回");
+ return { duration: 0, epsID: null };
+ }
+
+ const duration = resp.data?.body?.data?.playing?.duration || 0;
+ const epsID = resp.data?.body?.data?.epsID || null;
+
+ return { duration, epsID };
+ } catch (error) {
+ // 捕获请求中的错误
+ log("error", "getMiguDetail error:", {
+ message: error.message,
+ name: error.name,
+ stack: error.stack,
+ });
+ return { duration: 0, epsID: null };
+ }
+ }
+
+ async getEpisodes(id) {
+ try {
+ const detailResp = await httpGet(id, {
+ headers: {
+ "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36"
+ }
+ });
+
+ // 判断 resp 和 resp.data 是否存在
+ if (!detailResp || !detailResp.data) {
+ log("info", "getMiguEposides: 请求失败或无数据返回");
+ return [];
+ }
+
+ const eps = detailResp.data?.body?.data?.datas;
+
+ if (eps) {
+ return eps;
+ } else {
+ // datas不存在,找playing/pID
+ const name = detailResp.data?.body?.data?.name;
+ const pID = detailResp.data?.body?.data?.playing?.pID;
+ if (pID) {
+ return [{
+ name,
+ pID
+ }];
+ }
+ log("info", "getMiguEposides: eps 不存在");
+ return [];
+ }
+ } catch (error) {
+ // 捕获请求中的错误
+ log("error", "getMiguEposides 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", "[Migu] sourceAnimes is not a valid array");
+ return [];
+ }
+
+ // 使用 map 和 async 时需要返回 Promise 数组,并等待所有 Promise 完成
+ const processMiguAnimes = await Promise.all(sourceAnimes
+ .filter(s => titleMatches(s.name || s.title, queryTitle))
+ .map(async (anime) => {
+ try {
+ const eps = await this.getEpisodes(anime.url || anime.mediaId);
+ let links = [];
+ for (const ep of eps) {
+ links.push({
+ "name": ep.name,
+ "url": `https://webapi.miguvideo.com/gateway/live_barrage/videox/barrage/v2/list/${anime.epsId ?? ep.pID}/${ep.pID}`,
+ "title": `【migu】 ${ep.name}`
+ });
+ }
+
+ if (links.length > 0) {
+ let transformedAnime = {
+ animeId: convertToAsciiSum(anime.epsId ?? eps[0]?.pID),
+ bangumiId: String(anime.epsId ?? eps[0]?.pID),
+ animeTitle: `${anime.name || anime.title}(${anime.year})【${anime.type}】from migu`,
+ type: anime.type,
+ typeDescription: anime.type,
+ imageUrl: anime.img ?? anime.imageUrl,
+ startDate: generateValidStartDate(anime.year),
+ episodeCount: links.length,
+ rating: 0,
+ isFavorited: true,
+ source: "migu",
+ };
+
+ tmpAnimes.push(transformedAnime);
+
+ addAnime({...transformedAnime, links: links});
+
+ if (globals.animes.length > globals.MAX_ANIMES) removeEarliestAnime();
+ }
+ } catch (error) {
+ log("error", `[Migu] Error processing anime: ${error.message}`);
+ }
+ })
+ );
+
+ this.sortAndPushAnimesByYear(tmpAnimes, curAnimes);
+
+ return processMiguAnimes;
+ }
+
+ async getEpisodeDanmu(id) {
+ log("info", "开始从本地请求咪咕视频弹幕...", id);
+
+ // 获取弹幕分段数据
+ const segmentResult = await this.getEpisodeDanmuSegments(id);
+ if (!segmentResult || !segmentResult.segmentList || segmentResult.segmentList.length === 0) {
+ return [];
+ }
+
+ const segmentList = segmentResult.segmentList;
+ log("info", `弹幕分段数量: ${segmentList.length}`);
+
+ // 并发请求所有弹幕段,限制并发数量为50
+ const MAX_CONCURRENT = 100;
+ const allComments = [];
+
+ // 将segmentList分批处理,每批最多MAX_CONCURRENT个请求
+ for (let i = 0; i < segmentList.length; i += MAX_CONCURRENT) {
+ const batch = segmentList.slice(i, i + MAX_CONCURRENT);
+
+ // 并发处理当前批次的请求
+ const batchPromises = batch.map(segment => this.getEpisodeSegmentDanmu(segment));
+ const batchResults = await Promise.allSettled(batchPromises);
+
+ // 处理结果
+ for (let j = 0; j < batchResults.length; j++) {
+ const result = batchResults[j];
+ const segment = batch[j];
+ const start = segment.segment_start;
+ const end = segment.segment_end;
+
+ if (result.status === 'fulfilled') {
+ const comments = result.value;
+
+ if (comments && comments.length > 0) {
+ allComments.push(...comments);
+ }
+ } else {
+ log("error", `获取弹幕段失败 (${start}-${end}s):`, result.reason.message);
+ }
+ }
+
+ // 批次之间稍作延迟,避免过于频繁的请求
+ if (i + MAX_CONCURRENT < segmentList.length) {
+ await new Promise(resolve => setTimeout(resolve, 200));
+ }
+ }
+
+ if (allComments.length === 0) {
+ log("info", `咪咕视频: 该视频暂无弹幕数据 (vid=${id})`);
+ return [];
+ }
+
+ printFirst200Chars(allComments);
+
+ return allComments;
+ }
+
+ async getEpisodeDanmuSegments(id) {
+ log("info", "获取咪咕视频弹幕分段列表...", id);
+
+ const itemId = this.extractEpId(id);
+ const detail = await this.getDetail(itemId);
+ const durationSec = time_to_second(detail.duration);
+ log("info", "itemId:", itemId);
+ log("info", "durationSec:", durationSec);
+ log("info", "epsID:", detail.epsID);
+
+ const segmentDuration = 30; // 每个分片30秒钟
+ const segmentList = [];
+
+ for (let i = 0; i < durationSec; i += segmentDuration) {
+ const segmentStart = i; // 转换为毫秒
+ const segmentEnd = Math.min(i + segmentDuration, durationSec); // 不超过总时长
+
+ const danmuUrl = `https://webapi.miguvideo.com/gateway/live_barrage/videox/barrage/v2/list/${detail.epsID ?? itemId}/${itemId}/${segmentStart}/${segmentEnd}/020`;
+
+ segmentList.push({
+ "type": "migu",
+ "segment_start": segmentStart,
+ "segment_end": segmentEnd,
+ "url": danmuUrl
+ });
+ }
+
+ return new SegmentListResponse({
+ "type": "migu",
+ "segmentList": segmentList
+ });
+ }
+
+ async getEpisodeSegmentDanmu(segment) {
+ try {
+ const response = await httpGet(segment.url, {
+ headers: {
+ "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36",
+ "appCode": "miguvideo_default_h5"
+ },
+ retries: 1,
+ });
+
+ // 处理响应数据并返回 contents 格式的弹幕
+ let contents = [];
+ if (response && response.data) {
+ const decodeData = await decrypt(response.data);
+ const parsedData = typeof response.data === "string" ? JSON.parse(decodeData) : decodeData;
+ const danmakuList = parsedData?.body?.result ?? [];
+ contents.push(...danmakuList);
+ }
+
+ return contents;
+ } catch (error) {
+ log("error", "请求分片弹幕失败:", error);
+ return []; // 返回空数组而不是抛出错误,保持与getEpisodeDanmu一致的行为
+ }
+ }
+
+ formatComments(comments) {
+ return comments.map(c => ({
+ cid: Number(c.cid),
+ p: `${c.playtime},1,${hexToInt(c.textcolor)},[migu]`,
+ m: c.msg,
+ t: c.playtime
+ }));
+ }
+
+}
+
+export default MiguSource;
diff --git a/danmu_api/sources/xigua.js b/danmu_api/sources/xigua.js
index ccbedbb..0cc1eec 100644
--- a/danmu_api/sources/xigua.js
+++ b/danmu_api/sources/xigua.js
@@ -82,7 +82,7 @@ class XiguaSource extends BaseSource {
}
// 正常情况下输出 JSON 字符串
- log("info", `xiguaSearchresp: ${JSON.stringify(animes)}`);
+ log("info", `[Xigua] 搜索找到 ${animes.length} 个有效结果`);
return animes;
} catch (error) {
// 捕获请求中的错误
@@ -111,7 +111,7 @@ class XiguaSource extends BaseSource {
// 判断 resp 和 resp.data 是否存在
if (!resp || !resp.data) {
log("info", "getXiguaDetail: 请求失败或无数据返回");
- return [];
+ return 0;
}
const match = resp.data.match(/"duration"\s*:\s*([\d.]+)/);
@@ -123,7 +123,7 @@ class XiguaSource extends BaseSource {
name: error.name,
stack: error.stack,
});
- return [];
+ return 0;
}
}
diff --git a/danmu_api/ui/README.md b/danmu_api/ui/README.md
index 61ffdb1..f0b5208 100644
--- a/danmu_api/ui/README.md
+++ b/danmu_api/ui/README.md
@@ -8,7 +8,8 @@
- [2. 日志查看](#2-日志查看)
- [3. 接口调试](#3-接口调试)
- [4. 推送弹幕](#4-推送弹幕)
- - [5. 系统配置](#5-系统配置)
+ - [5. 请求记录](#5-请求记录)
+ - [6. 系统配置](#6-系统配置)
- [安全说明](#安全说明)
- [部署平台支持](#部署平台支持)
- [部署平台环境变量配置指南](#部署平台环境变量配置指南)
@@ -33,7 +34,8 @@ UI 系统提供了以下主要功能模块:
2. **日志查看** - 实时查看系统运行日志
3. **接口调试** - 测试和调试 API 接口
4. **推送弹幕** - 向播放器推送弹幕
-5. **系统配置** - 环境变量配置和系统管理
+5. **请求记录** - 查看最近的 API 请求记录
+6. **系统配置** - 环境变量配置和系统管理
## 访问方式
@@ -83,7 +85,21 @@ UI 系统需要通过在 URL 中添加 TOKEN 来访问,以确保安全性:
- 推送地址格式如:`http://127.0.0.1:9978/action?do=refresh&type=danmaku&path=`
- 需要在同一局域网或使用公网 IP
-### 5. 系统配置
+
+### 5. 请求记录
+
+请求记录页面提供:
+
+- 查看最近一段时间内的 API 请求历史
+- 显示每条请求的详细信息,包括:
+ - 请求时间
+ - 请求方法 (GET, POST, etc.)
+ - 请求 URL
+ - 请求参数
+ - 请求来源 IP
+- 查看今日请求总数
+
+### 6. 系统配置
系统配置页面包含:
diff --git a/danmu_api/ui/css/components.css.js b/danmu_api/ui/css/components.css.js
index d72c677..0a6edc6 100644
--- a/danmu_api/ui/css/components.css.js
+++ b/danmu_api/ui/css/components.css.js
@@ -940,4 +940,151 @@ export const componentsCssContent = /* css */ `
border-color: #eee;
box-shadow: none;
}
+
+/* 请求记录样式 */
+.request-records-container {
+ border-radius: 8px;
+}
+
+.no-records {
+ text-align: center;
+ color: #fff;
+ padding: 60px;
+ font-style: italic;
+ font-size: 16px;
+}
+
+.record-item {
+ background: linear-gradient(145deg, #ffffff 0%, #f8f9fa 100%);
+ border: none;
+ border-radius: 16px;
+ padding: 10px;
+ margin-bottom: 10px;
+ box-shadow: 0 8px 32px rgba(0,0,0,0.15);
+ transition: transform 0.2s, box-shadow 0.2s;
+}
+
+.record-item:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 12px 40px rgba(0,0,0,0.2);
+}
+
+.record-header {
+ display: flex;
+ justify-content: flex-start;
+ align-items: center;
+ margin-bottom: 15px;
+ flex-wrap: wrap;
+ gap: 15px;
+}
+
+.record-method {
+ background: linear-gradient(135deg, #00b4db 0%, #0083b0 100%);
+ color: white;
+ padding: 8px 16px;
+ border-radius: 10px;
+ font-size: 14px;
+ font-weight: bold;
+ min-width: 60px;
+ text-align: center;
+ box-shadow: 0 4px 15px rgba(0,180,219,0.3);
+}
+
+.record-interface {
+ flex: 1;
+ font-family: 'Courier New', monospace;
+ font-weight: 600;
+ color: #2d3748;
+ word-break: break-all;
+ font-size: 15px;
+ background: #edf2f7;
+ padding: 8px 16px;
+ border-radius: 8px;
+}
+
+.record-ip {
+ background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
+ color: white;
+ padding: 8px 16px;
+ border-radius: 10px;
+ font-size: 13px;
+ min-width: 120px;
+ text-align: center;
+ font-weight: 500;
+ box-shadow: 0 4px 15px rgba(245,87,108,0.3);
+}
+
+.record-timestamp {
+ color: #718096;
+ font-size: 14px;
+ margin-bottom: 15px;
+ padding-bottom: 15px;
+ border-bottom: 2px dashed #e2e8f0;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.record-timestamp.no-params {
+ border-bottom: none;
+ margin-bottom: 0;
+ padding-bottom: 0;
+}
+
+.record-timestamp::before {
+ content: '🕐';
+ font-size: 16px;
+}
+
+.record-params {
+ background: #f5f5f5;
+ border-radius: 12px;
+ padding: 15px;
+ border: 1px solid #e0e0e0;
+}
+
+.record-params-title {
+ color: #667eea;
+ font-weight: 600;
+ font-size: 14px;
+ margin-bottom: 12px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.record-params-title::before {
+ content: '📋';
+ font-size: 16px;
+}
+
+.record-params pre {
+ margin: 0;
+ padding: 15px;
+ background: #ffffff;
+ color: #333;
+ border-radius: 8px;
+ font-family: 'Courier New', monospace;
+ font-size: 13px;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ overflow-x: auto;
+ line-height: 1.6;
+ border: 1px solid #ddd;
+}
+
+/* 请求记录移动端适配 */
+@media (max-width: 768px) {
+ .record-header {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .record-method,
+ .record-interface,
+ .record-ip {
+ width: 100%;
+ box-sizing: border-box;
+ }
+}
`;
diff --git a/danmu_api/ui/js/main.js b/danmu_api/ui/js/main.js
index a06bfde..2b18aca 100644
--- a/danmu_api/ui/js/main.js
+++ b/danmu_api/ui/js/main.js
@@ -363,8 +363,8 @@ function getDockerVersion() {
// 切换导航
function switchSection(section, event = null) {
- // 检查是否尝试访问受token保护的section(日志查看、接口调试、系统配置需要token访问)
- if (section === 'logs' || section === 'api' || section === 'env' || section === 'push') {
+ // 检查是否尝试访问受token保护的section(日志查看、接口调试、推送弹幕、请求记录、系统配置需要token访问)
+ if (section === 'logs' || section === 'api' || section === 'env' || section === 'push' || section === 'request-records') {
let _reverseProxy = customBaseUrl; // 使用全局配置
// 获取URL路径并提取token
@@ -443,7 +443,7 @@ function switchSection(section, event = null) {
event.target.classList.add('active');
}
- addLog(\`切换到\${section === 'env' ? '环境变量' : section === 'preview' ? '配置预览' : section === 'logs' ? '日志查看' : section === 'push' ? '推送弹幕' : '接口调试'}模块\`, 'info');
+ addLog(\`切换到\${section === 'env' ? '环境变量' : section === 'preview' ? '配置预览' : section === 'logs' ? '日志查看' : section === 'push' ? '推送弹幕' : section === 'request-records' ? '请求记录' : '接口调试'}模块\`, 'info');
}
});
} else {
@@ -456,7 +456,7 @@ function switchSection(section, event = null) {
event.target.classList.add('active');
}
- addLog(\`切换到\${section === 'env' ? '环境变量' : section === 'preview' ? '配置预览' : section === 'logs' ? '日志查看' : section === 'push' ? '推送弹幕' : '接口调试'}模块\`, 'info');
+ addLog(\`切换到\${section === 'env' ? '环境变量' : section === 'preview' ? '配置预览' : section === 'logs' ? '日志查看' : section === 'push' ? '推送弹幕' : section === 'request-records' ? '请求记录' : '接口调试'}模块\`, 'info');
// 如果切换到日志查看页面,则立即刷新日志
if (section === 'logs') {
diff --git a/danmu_api/ui/js/requestrecords.js b/danmu_api/ui/js/requestrecords.js
new file mode 100644
index 0000000..8c3cee7
--- /dev/null
+++ b/danmu_api/ui/js/requestrecords.js
@@ -0,0 +1,111 @@
+// language=JavaScript
+export const requestRecordsJsContent = /* javascript */ `
+// 请求记录相关功能
+async function renderRequestRecords() {
+ const recordsContainer = document.getElementById('request-records-list');
+ if (!recordsContainer) {
+ console.error('未找到请求记录容器');
+ return;
+ }
+
+ try {
+ // 从API获取请求记录
+ const response = await fetch(buildApiUrl('/api/reqrecords'));
+ if (!response.ok) {
+ throw new Error('获取请求记录失败');
+ }
+
+ const data = await response.json();
+ const records = data.records;
+ const todayReqNum = data.todayReqNum;
+
+ // 显示今日请求总数
+ const totalRequestsElement = document.getElementById('total-requests-today');
+ if (totalRequestsElement) {
+ totalRequestsElement.textContent = \`今日请求总数: \${todayReqNum}\`;
+ }
+
+ if (records.length === 0) {
+ recordsContainer.innerHTML = '
\${JSON.stringify(params, null, 2)}
+