From c5b7221997556feccabdebeb49b00b544f704a67 Mon Sep 17 00:00:00 2001
From: huangxd- <649924757@qq.com>
Date: Sun, 15 Mar 2026 12:57:32 +0800
Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=AE=B0=E4=BD=8F=E6=89=8B?=
=?UTF-8?q?=E5=8A=A8=E9=80=89=E6=8B=A9=E7=BB=93=E6=9E=9C=E5=8A=9F=E8=83=BD?=
=?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=94=AF=E6=8C=81=E8=AE=B0=E4=BD=8F=E9=9B=86?=
=?UTF-8?q?episode/=E5=BC=B9=E5=B9=95=E6=97=B6=E9=97=B4=E5=81=8F=E7=A7=BB?=
=?UTF-8?q?=E9=85=8D=E7=BD=AE/IP=E9=BB=91=E5=90=8D=E5=8D=95=E5=88=97?=
=?UTF-8?q?=E8=A1=A8=E9=85=8D=E7=BD=AE=20(#231)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 记住手动选择结果功能新增支持记住集episode,下次自动匹配时会对集进行偏移
* 更新README
* 新增IP黑名单列表配置,通过环境变量IP_BLACKLIST配置
* 增加上次匹配剧标题和本次弹幕获取据标题的匹配,防止两者不对应
* 新增弹幕时间偏移配置
* 补充forward插件相关配置
* 增加打印日志
* 修复findIndexById方法没有引用的问题
* 优化lastSelectMap,支持存储不同季
* 优化extractSeasonNumberFromAnimeTitle中尾部阿拉伯数字(如"某某 2" 或 "某某2",但不超过2位)
* 再次优化尾部阿拉伯数字问题
---
README.md | 13 +++-
config/.env.example | 23 +++++-
danmu_api/apis/dandan-api.js | 126 ++++++++++++++++++++----------
danmu_api/configs/envs.js | 134 +++++++++++++++++++++++++++++++-
danmu_api/configs/globals.js | 2 +-
danmu_api/utils/ai-util.js | 4 +
danmu_api/utils/cache-util.js | 101 +++++++++++++++++++++---
danmu_api/utils/common-util.js | 89 +++++++++++++++++++++
danmu_api/utils/danmu-util.js | 53 +++++++++++++
danmu_api/utils/hanjutv-util.js | 4 +
danmu_api/utils/log-util.js | 2 +-
danmu_api/utils/migu-util.js | 4 +
danmu_api/worker.js | 116 ++++++++++++++++++++++++++-
forward/forward-widget.js | 32 +++++---
forward/forward-widget.test.js | 3 +-
15 files changed, 628 insertions(+), 78 deletions(-)
diff --git a/README.md b/README.md
index b15676f..0cbe508 100644
--- a/README.md
+++ b/README.md
@@ -70,7 +70,7 @@ LogVar 弹幕 API 服务器
- Redis 分布式缓存支持,包括本地redis和upstash redis(可选)
- 本地和Docker部署支持实时保存缓存到文件(挂载.cache目录即可)
- **部署支持**:支持本地运行、Docker 容器化、Vercel 一键部署、Netlify 一键部署、Edgeone 一键部署、Cloudflare 一键部署、Claw部署和 Docker 一键启动。
-- **手动选择记忆**:支持记住之前搜索title时手动选择的anime,并在后续的match自动匹配时优选该anime【实验性】。
+- **手动选择记忆**:支持记住之前搜索title时手动选择的anime,并在后续的match自动匹配时优选该anime,支持记住集episode,下次自动匹配时会对集进行偏移【实验性】。
- **手动搜索支持输入播放链接获取弹幕**:支持手动搜索的播放器输入爱优腾芒哔咪狐乐西播放链接可获取弹幕,如`senplayer`。
- **弹幕转换功能**:支持通过环境变量配置弹幕转换规则,包括:
- 将顶部和底部弹幕转换为浮动弹幕(`CONVERT_TOP_BOTTOM_TO_SCROLL`)
@@ -78,6 +78,7 @@ LogVar 弹幕 API 服务器
- 解决部分播放器不支持顶部/底部弹幕和彩色弹幕的问题
- 增加点赞数显示,先去重再拼接点赞标记,点赞数缩写显示,≥5 才显示,避免低赞干扰
- **弹幕限制数量**:支持通过环境变量配置等间隔采样弹幕数量。
+- **弹幕时间偏移功能**:支持通过环境变量 `DANMU_OFFSET` 配置弹幕时间偏移,解决弹幕与视频不同步的问题。格式为 `剧名:秒`(全剧偏移)、`剧名/季:秒`(整季偏移)、`剧名/季/集:秒`(单集偏移),多条用逗号分隔。例如:`overlord/S01:90, re-zero/S02:120, re-zero/S02/E03:10`。正数表示弹幕延后(向右),负数表示弹幕提前(向左)。
- **弹幕分片请求**:
- `/api/v2/comment` 请求时支持定义 `segmentflag=true` 参数,用于请求弹幕分片列表
- `/api/v2/segmentcomment` 通过comment接口返回体中的Segment类JSON数据获取单独一个分片的弹幕数据
@@ -323,6 +324,10 @@ LogVar 弹幕 API 服务器
4. 现已支持手动搜索标题输入爱优腾芒哔咪狐乐西播放链接获取弹幕。
+`uz`使用:
+1. 弹幕拓展 -> 豆儿弹幕
+2. 豆儿弹幕API -> 填入你的API
+
### XML 格式说明
API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 `?format=xml` 指定。
@@ -393,12 +398,14 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
| LIKE_SWITCH | 【可选】弹幕点赞数显示开关,默认为`true`(开启),开启后会在弹幕内容后显示点赞数标记,≥5 才显示,避免低赞干扰 |
| DANMU_OUTPUT_FORMAT | 【可选】弹幕输出格式,默认为`json`,可选值:`json`(JSON格式)、`xml`(XML格式),支持通过查询参数`?format=xml`或`?format=json`覆盖此设置,优先级:查询参数 > 环境变量 > 默认值 |
| DANMU_SIMPLIFIED_TRADITIONAL | 【可选】弹幕简繁体转换设置:default(默认不转换)、simplified(繁转简)、traditional(简转繁) |
+| DANMU_OFFSET | 【可选】弹幕时间偏移配置,用于解决弹幕与视频不同步的问题。格式:剧名:秒(全剧偏移)或 剧名/季:秒(整季偏移)或 剧名/季/集:秒(单集偏移),多条用逗号分隔。例如:`overlord/S01:90, re-zero/S02:120, re-zero/S02/E03:10`。正数表示弹幕延后(向右),负数表示弹幕提前(向左)。 |
| PROXY_URL | 【可选】代理/反代地址,目前只对巴哈姆特、TMDB API、bilibili生效,支持格式:
正常代理:`http://127.0.0.1:7890`
万能反代:`@http://127.0.0.1`
特定反代:`源字段@http://127.0.0.1`,目前支持的字段有:`bahamut,tmdb,bilibili`(bilibili字段会启用阿b的港澳台番剧的搜索与获取)
混合配置/示例:`http://你的代理地址:28233,bahamut@你的巴哈反代地址,tmdb@你的tmdb反代地址,@你的万能反代地址`
优先级:特定反代 > 万能反代 > 正常代理,高优先级覆盖低优先级使用。
(注意:如果巴哈姆特请求不通,会拖慢搜索返回速度,如需使用bahamut源请在SOURCE_ORDER环境变量中手动添加`bahamut`)如果你使用docker部署并且访问不了bahamut源,请配置代理地址或者反代([Netlify反代教程](https://github.com/wan0ge/bahamut-api-proxy));vercel/netlify/cf中理应都自然能联通,不用填写 |
| TMDB_API_KEY | 【可选】TMDB API Key地址,目前只对巴哈姆特生效,配置后并行从TMDB获取日语原名搜索巴哈(如果TMDB条目类型不是动画或制作地区不是jp则不会进行巴哈搜索)可以解决巴哈译名不同导致的搜索无结果问题,例如大陆常用译名`间谍过家家`在巴哈译名为`間諜家家酒`,正常搜索无法搜索到,配置后可以解决这一问题但会稍微影响请求速度,[TMDBAPI](https://www.themoviedb.org/settings/api)获取方法参考:[TMDB API Key申请 - 绿联NAS私有云](https://www.ugnas.com/tutorial-detail/id-226.html) |
| RATE_LIMIT_MAX_REQUESTS | 【可选】限流配置:1分钟内同一IP最大请求次数,默认为`3`,设置为`0`表示不限流 |
+| IP_BLACKLIST | 【可选】IP 黑名单列表,命中则拒绝请求。支持逗号/分号/换行分隔,支持 `/regex/` 或 `/regex/i` 正则,支持 IPv4/IPv6 CIDR,例如:`192.168.1.10,10.0.0.0/24,2001:db8::/64,/^203\.0\.113\./` |
| LOG_LEVEL | 【可选】日志级别,默认为`info`,可选值:`error`(仅错误)、`warn`(错误和警告)、`info`(所有日志),生产环境建议使用`warn`,调试时使用`info` |
-| SEARCH_CACHE_MINUTES | 【可选】搜索结果缓存时间(分钟),默认为`1`,避免短期内重复的不必要API请求,同时保证获取最新的结果列表,可根据需要调整:Vercel/Cloudflare建议`1-5`分钟,Docker可设置`5-30`分钟,设置为`0`表示不缓存 |
-| COMMENT_CACHE_MINUTES | 【可选】弹幕缓存时间(分钟),默认为`1`,弹幕数据的缓存时间,独立于搜索结果缓存 |
+| SEARCH_CACHE_MINUTES | 【可选】搜索结果缓存时间(分钟),默认为`3`,避免短期内重复的不必要API请求,同时保证获取最新的结果列表,可根据需要调整:Vercel/Cloudflare建议`1-5`分钟,Docker可设置`5-30`分钟,设置为`0`表示不缓存 |
+| COMMENT_CACHE_MINUTES | 【可选】弹幕缓存时间(分钟),默认为`3`,弹幕数据的缓存时间,独立于搜索结果缓存,设置为`0`表示不缓存 |
| REMEMBER_LAST_SELECT | 【可选】是否记住手动选择结果,用于match自动匹配时优选上次的选择,默认为`true`,表示记住,请注意,该功能为实验性功能,会记住某个剧上次选择的结果作为下次自动匹配的优选,如不需要,请关闭 |
| MAX_LAST_SELECT_MAP | 【可选】最后选择映射缓存大小限制,默认为`100`,lastSelectMap最多保存的条目数,超过限制时删除最早的条目(FIFO),用于存储查询关键字上次选择的animeId |
| UPSTASH_REDIS_REST_URL | 【可选】Upstash redis url,需配合UPSTASH_REDIS_REST_TOKEN使用,用于持久化存储,不会因为冷启动而丢失过去的查询信息(在cf/eo/claw上配置后应该能更稳定点,也能解决小幻掉匹配的问题,但会稍微影响请求速度),获取方法请参考:`https://cloud.tencent.cn/developer/article/2424508` |
diff --git a/config/.env.example b/config/.env.example
index 6717cb3..01d2302 100644
--- a/config/.env.example
+++ b/config/.env.example
@@ -120,6 +120,14 @@ EPISODE_TITLE_FILTER=(特别|惊喜|纳凉)?企划(?!(书|案|部))|合伙人手
# 默认值:空
# BLOCKED_WORDS=
+# ==================== IP 黑名单配置 ====================
+
+# IP 黑名单列表(命中则拒绝请求)
+# 默认值:空
+# 说明:支持逗号/分号/换行分隔;支持使用 /regex/ 或 /regex/i 形式的正则;支持 IPv4/IPv6 CIDR
+# 示例:IP_BLACKLIST=192.168.1.10,10.0.0.0/24,2001:db8::/64,/^203\.0\.113\./
+# IP_BLACKLIST=
+
# ==================== 弹幕去重配置 ====================
# 分钟内合并去重(最大值:30,0 表示不去重)
@@ -151,6 +159,12 @@ CONVERT_COLOR=default
# 可选值:default, simplified, traditional
DANMU_SIMPLIFIED_TRADITIONAL=default
+# 弹幕时间偏移配置
+# 格式:剧名:秒 或 剧名/季:秒 或 剧名/季/集:秒,多条用逗号分隔
+# 示例:overlord/S01:90,re-zero/S02:120,re-zero/S02/E03:10,re-zero/S02/E07:150
+# 默认值:空
+DANMU_OFFSET=
+
# 弹幕点赞数显示开关
# 默认值:true(开启)
# 说明:开启后会在弹幕内容后显示点赞数标记,≥5 才显示,避免低赞干扰
@@ -249,17 +263,18 @@ LOG_LEVEL=info
# ==================== 缓存配置 ====================
# 搜索结果缓存时间(分钟)
-# 默认值:1
+# 默认值:3
# 说明:避免短期内重复的不必要 API 请求,同时保证获取最新的结果列表
# 可根据需要调整:Vercel/Cloudflare 建议 1-5 分钟,Docker 可设置 5-30 分钟
# 设置为 0 表示不缓存
-SEARCH_CACHE_MINUTES=1
+SEARCH_CACHE_MINUTES=3
# 弹幕缓存时间(分钟)
-# 默认值:1
+# 默认值:3
# 说明:弹幕数据的缓存时间,独立于搜索结果缓存
# 可根据需要调整
-COMMENT_CACHE_MINUTES=1
+# 设置为 0 表示不缓存
+COMMENT_CACHE_MINUTES=3
# 是否记住手动选择结果
# 默认值:true
diff --git a/danmu_api/apis/dandan-api.js b/danmu_api/apis/dandan-api.js
index f5c23d3..d1a31d6 100644
--- a/danmu_api/apis/dandan-api.js
+++ b/danmu_api/apis/dandan-api.js
@@ -7,10 +7,13 @@ import { setLocalRedisKey, updateLocalRedisCaches } from "../utils/local-redis-u
import {
setCommentCache, addAnime, findAnimeIdByCommentId, findTitleById, findUrlById, getCommentCache, getPreferAnimeId,
getSearchCache, removeEarliestAnime, setPreferByAnimeId, setSearchCache, storeAnimeIdsToMap, writeCacheToFile,
- updateLocalCaches
+ updateLocalCaches, setLastSearch, getLastSearch, findAnimeTitleById, findIndexById
} from "../utils/cache-util.js";
-import { formatDanmuResponse, convertToDanmakuJson } from "../utils/danmu-util.js";
-import { extractEpisodeTitle, convertChineseNumber, parseFileName, createDynamicPlatformOrder, normalizeSpaces, extractYear } from "../utils/common-util.js";
+import { formatDanmuResponse, convertToDanmakuJson, getOffset, applyDanmuOffset } from "../utils/danmu-util.js";
+import {
+ extractEpisodeTitle, convertChineseNumber, parseFileName, createDynamicPlatformOrder, normalizeSpaces,
+ extractYear, titleMatches, extractAnimeInfo, extractEpisodeNumberFromTitle
+} from "../utils/common-util.js";
import { getTMDBChineseTitle } from "../utils/tmdb-util.js";
import { applyMergeLogic, mergeDanmakuList, MERGE_DELIMITER, alignSourceTimelines } from "../utils/merge-util.js";
import AIClient from '../utils/ai-util.js';
@@ -413,31 +416,6 @@ function filterSameEpisodeTitle(filteredTmpEpisodes) {
return filteredEpisodes;
}
-// 从集标题中提取集数(支持多种格式:第1集、第01集、EP01、E01等)
-function extractEpisodeNumberFromTitle(episodeTitle) {
- if (!episodeTitle) return null;
-
- // 匹配格式:第1集、第01集、第10集等
- const chineseMatch = episodeTitle.match(/第(\d+)集/);
- if (chineseMatch) {
- return parseInt(chineseMatch[1], 10);
- }
-
- // 匹配格式:EP01、EP1、E01、E1等
- const epMatch = episodeTitle.match(/[Ee][Pp]?(\d+)/);
- if (epMatch) {
- return parseInt(epMatch[1], 10);
- }
-
- // 匹配格式:01、1(纯数字,通常在标题开头或结尾)
- const numberMatch = episodeTitle.match(/(?:^|\s)(\d+)(?:\s|$)/);
- if (numberMatch) {
- return parseInt(numberMatch[1], 10);
- }
-
- return null;
-}
-
/**
* 计算平台匹配得分 (新增函数 - 用于支持合并源模糊匹配和杂质过滤)
* @param {string} candidatePlatform 候选平台字符串 (e.g., "bilibili&dandan")
@@ -629,7 +607,24 @@ async function matchAniAndEpByAi(season, episode, year, searchData, title, req,
}
}
-async function matchAniAndEp(season, episode, year, searchData, title, req, platform, preferAnimeId) {
+function computeTargetEpisode(offsets, season, episode, filteredEpisodes, targetEpisode) {
+ const seasonKey = String(season);
+ const match = offsets[seasonKey].match(/^([^:]+):(.+)$/);
+ const offsetEpisode = Number(match?.[1]) || 0;
+ const offsetEpisodeTitle = match?.[2] || '';
+ // 计算本次获取和保存的Episode差值
+ const offset = episode - offsetEpisode;
+ // 通过offsetEpisodeTitle获取保存的所在集index
+ const offsetIndex = filteredEpisodes.findIndex(episode => episode.episodeTitle === offsetEpisodeTitle);
+ if (offsetIndex !== -1) {
+ // 计算本次获取的目标index
+ targetEpisode = offsetIndex + offset + 1;
+ log("info", `Applying offset "${offsets[seasonKey]}" for S${season}E${episode} -> ${targetEpisode}`);
+ }
+ return targetEpisode;
+}
+
+async function matchAniAndEp(season, episode, year, searchData, title, req, platform, preferAnimeId, offsets) {
// 定义最佳匹配结果容器
let bestRes = {
anime: null,
@@ -707,8 +702,13 @@ async function matchAniAndEp(season, episode, year, searchData, title, req, plat
log("info", "过滤后的集标题", filteredEpisodes.map(episode => episode.episodeTitle));
+ let targetEpisode = episode;
+ if (offsets && offsets[String(season)] !== undefined) {
+ targetEpisode = computeTargetEpisode(offsets, season, episode, filteredEpisodes, targetEpisode);
+ }
+
// 匹配集数 (注意:findEpisodeByNumber 已增强支持模糊平台匹配)
- matchedEpisode = findEpisodeByNumber(filteredEpisodes, episode, platform);
+ matchedEpisode = findEpisodeByNumber(filteredEpisodes, targetEpisode, platform);
} else {
// 电影模式逻辑
if (bangumiData.bangumi.episodes.length > 0) {
@@ -765,7 +765,7 @@ async function matchAniAndEp(season, episode, year, searchData, title, req, plat
return { resEpisode: bestRes.episode, resAnime: bestRes.anime };
}
-async function fallbackMatchAniAndEp(searchData, req, season, episode, year, resEpisode, resAnime) {
+async function fallbackMatchAniAndEp(searchData, req, season, episode, year, resEpisode, resAnime, offsets) {
for (const anime of searchData.animes) {
// 年份匹配优先(如果提供了年份)
if (year && !matchYear(anime, year)) {
@@ -786,8 +786,15 @@ async function fallbackMatchAniAndEp(searchData, req, season, episode, year, res
// 过滤集标题一致的 episode,且保留首次出现的集标题的 episode
const filteredEpisodes = filterSameEpisodeTitle(filteredTmpEpisodes);
+ log("info", "过滤后的集标题", filteredEpisodes.map(episode => episode.episodeTitle));
+
+ let targetEpisode = episode;
+ if (offsets && offsets[String(season)] !== undefined) {
+ targetEpisode = computeTargetEpisode(offsets, season, episode, filteredEpisodes, targetEpisode);
+ }
+
// 使用新的集数匹配策略
- const matchedEpisode = findEpisodeByNumber(filteredEpisodes, episode, null);
+ const matchedEpisode = findEpisodeByNumber(filteredEpisodes, targetEpisode, null);
if (matchedEpisode) {
resEpisode = matchedEpisode;
resAnime = anime;
@@ -881,7 +888,7 @@ export async function extractTitleSeasonEpisode(cleanFileName) {
}
// Extracted function for POST /api/v2/match
-export async function matchAnime(url, req) {
+export async function matchAnime(url, req, clientIp) {
try {
// 获取请求体
const body = await req.json();
@@ -913,6 +920,10 @@ export async function matchAnime(url, req) {
let {title, season, episode, year} = await extractTitleSeasonEpisode(cleanFileName);
+ if (clientIp) {
+ setLastSearch(clientIp, { title, season, episode });
+ }
+
// 使用剧名映射表转换剧名
if (globals.titleMappingTable && globals.titleMappingTable.size > 0) {
const mappedTitle = globals.titleMappingTable.get(title);
@@ -929,8 +940,8 @@ export async function matchAnime(url, req) {
title = simplifiedTitle;
}
- // 获取prefer animeIdgetPreferAnimeId
- const [preferAnimeId, preferSource] = getPreferAnimeId(title);
+ // 获取 prefer animeId(按 season 维度)
+ const [preferAnimeId, preferSource, offsets] = getPreferAnimeId(title, season);
log("info", `prefer animeId: ${preferAnimeId} from ${preferSource}`);
let originSearchUrl = new URL(req.url.replace("/match", `/search/anime?keyword=${title}`));
@@ -956,7 +967,7 @@ export async function matchAnime(url, req) {
} else {
// AI匹配失败或未配置,使用传统匹配方式
for (const platform of dynamicPlatformOrder) {
- const __ret = await matchAniAndEp(season, episode, year, searchData, title, req, platform, preferAnimeId);
+ const __ret = await matchAniAndEp(season, episode, year, searchData, title, req, platform, preferAnimeId, offsets);
resEpisode = __ret.resEpisode;
resAnime = __ret.resAnime;
@@ -968,7 +979,7 @@ export async function matchAnime(url, req) {
// 如果都没有找到则返回第一个满足剧集数的剧集
if (!resAnime) {
- const __ret = await fallbackMatchAniAndEp(searchData, req, season, episode, year, resEpisode, resAnime);
+ const __ret = await fallbackMatchAniAndEp(searchData, req, season, episode, year, resEpisode, resAnime, offsets);
resEpisode = __ret.resEpisode;
resAnime = __ret.resAnime;
}
@@ -1336,8 +1347,9 @@ async function fetchMergedComments(url) {
}
// Extracted function for GET /api/v2/comment/:commentId
-export async function getComment(path, queryFormat, segmentFlag) {
+export async function getComment(path, queryFormat, segmentFlag, clientIp) {
const commentId = parseInt(path.split("/").pop());
+ let animeTitle = findAnimeTitleById(commentId);
let url = findUrlById(commentId);
let title = findTitleById(commentId);
let plat = title ? (title.match(/【(.*?)】/) || [null])[0]?.replace(/[【】]/g, '') : null;
@@ -1413,9 +1425,29 @@ export async function getComment(path, queryFormat, segmentFlag) {
}
}
- const [animeId, source] = findAnimeIdByCommentId(commentId);
+ const [animeId, source, episodeTitle] = findAnimeIdByCommentId(commentId);
if (animeId && source) {
- setPreferByAnimeId(animeId, source);
+ let lastTitle = null;
+ let lastSeason = null;
+ let offset = null;
+
+ if (clientIp) {
+ const lastSearch = getLastSearch(clientIp);
+ if (lastSearch && lastSearch.title && lastSearch.season && lastSearch.episode && episodeTitle) {
+ lastTitle = lastSearch.title;
+ lastSeason = lastSearch.season;
+ offset = `${lastSearch.episode}:${episodeTitle}`;
+ log("info", `Calculated episode offset for IP ${clientIp}: Query E${lastSearch.episode}, Selected ${episodeTitle} -> Offset ${offset} (Season ${lastSeason})`);
+ }
+ }
+
+ log("info", `animeTitle:${animeTitle}; lastTitle:${lastTitle}; titleMatches:${titleMatches(animeTitle, lastTitle)}`)
+
+ if (titleMatches(animeTitle, lastTitle)) {
+ log("info", `excute setPreferByAnimeId`)
+ setPreferByAnimeId(animeId, source, lastSeason, offset);
+ }
+
if (globals.localCacheValid && animeId) {
writeCacheToFile('lastSelectMap', JSON.stringify(Object.fromEntries(globals.lastSelectMap)));
}
@@ -1427,6 +1459,20 @@ export async function getComment(path, queryFormat, segmentFlag) {
}
}
+ // 应用弹幕时间偏移
+ if (animeTitle && episodeTitle && globals.danmuOffset) {
+ let { baseTitle, season, episode } = extractAnimeInfo(animeTitle, episodeTitle);
+ season ||= 1;
+ episode ||= findIndexById(commentId) + 1;
+ const seasonStr = `S${season.toString().padStart(2, '0')}`;
+ const episodeStr = `E${episode.toString().padStart(2, '0')}`;
+ const offset = getOffset(baseTitle, seasonStr, episodeStr);
+ if (offset !== 0) {
+ log("info", `Applying danmu offset: ${offset}s for ${baseTitle}/${seasonStr}/${episodeStr}`);
+ danmus = applyDanmuOffset(danmus, offset);
+ }
+ }
+
// 缓存弹幕结果
if (!segmentFlag) {
if (danmus && danmus.comments) danmus = danmus.comments;
diff --git a/danmu_api/configs/envs.js b/danmu_api/configs/envs.js
index d1ae884..239aa56 100644
--- a/danmu_api/configs/envs.js
+++ b/danmu_api/configs/envs.js
@@ -285,6 +285,128 @@ export class Envs {
}
}
+ /**
+ * 解析 IP 黑名单列表
+ * @description 支持逗号/分号/换行分隔,支持 /regex/ 或 /regex/i 的正则格式,支持 IPv4/IPv6 CIDR(如 127.0.0.0/24、2001:db8::/64)
+ * @returns {Array} IP 黑名单规则列表
+ */
+ static resolveIpBlacklist() {
+ const rawList = this.get('IP_BLACKLIST', '', 'string', false).trim();
+
+ if (!rawList) {
+ this.accessedEnvVars.set('IP_BLACKLIST', []);
+ return [];
+ }
+
+ const entries = rawList
+ .split(/[\n,;]+/)
+ .map(item => item.trim())
+ .filter(Boolean);
+
+ const rules = [];
+
+ for (const entry of entries) {
+ try {
+ if (entry.startsWith('/') && entry.lastIndexOf('/') > 0) {
+ const lastSlashIndex = entry.lastIndexOf('/');
+ const pattern = entry.slice(1, lastSlashIndex);
+ const flags = entry.slice(lastSlashIndex + 1);
+ rules.push({ type: 'regex', value: new RegExp(pattern, flags) });
+ continue;
+ }
+
+ if (entry.includes('/')) {
+ const [ip, prefix] = entry.split('/').map(s => s.trim());
+ const prefixNum = Number(prefix);
+ const isIpv4 = this.isValidIpv4(ip);
+ const isIpv6 = this.isValidIpv6(ip);
+ if (Number.isInteger(prefixNum)) {
+ if (isIpv4 && prefixNum >= 0 && prefixNum <= 32) {
+ rules.push({ type: 'cidr', ip, prefix: prefixNum });
+ continue;
+ }
+ if (isIpv6 && prefixNum >= 0 && prefixNum <= 128) {
+ rules.push({ type: 'cidr', ip, prefix: prefixNum });
+ continue;
+ }
+ }
+ }
+
+ if (this.isValidIpv4(entry) || this.isValidIpv6(entry)) {
+ rules.push({ type: 'exact', value: entry });
+ continue;
+ }
+
+ const escaped = entry.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ rules.push({ type: 'regex', value: new RegExp(`^${escaped}$`) });
+ } catch (error) {
+ console.warn(`Invalid IP_BLACKLIST entry: ${entry}, skipped.`);
+ }
+ }
+
+ this.accessedEnvVars.set('IP_BLACKLIST', entries);
+
+ return rules;
+ }
+
+ /**
+ * 校验 IPv4 地址合法性
+ * @param {string} ip IPv4 地址
+ * @returns {boolean}
+ */
+ static isValidIpv4(ip) {
+ if (!ip || typeof ip !== 'string') return false;
+ const parts = ip.split('.');
+ if (parts.length !== 4) return false;
+ return parts.every(part => {
+ if (!/^(\d{1,3})$/.test(part)) return false;
+ const num = Number(part);
+ return num >= 0 && num <= 255;
+ });
+ }
+
+ /**
+ * 校验 IPv6 地址合法性(支持 :: 缩写与 IPv4 映射)
+ * @param {string} ip IPv6 地址
+ * @returns {boolean}
+ */
+ static isValidIpv6(ip) {
+ if (!ip || typeof ip !== 'string') return false;
+ const normalized = ip.trim();
+ if (!normalized.includes(':')) return false;
+
+ const [left, right] = normalized.split('::');
+ if (normalized.split('::').length > 2) return false;
+
+ const leftParts = left ? left.split(':').filter(Boolean) : [];
+ let rightParts = right ? right.split(':').filter(Boolean) : [];
+
+ const expandIpv4Part = (parts) => {
+ if (parts.length === 0) return parts;
+ const last = parts[parts.length - 1];
+ if (!last.includes('.')) return parts;
+ if (!this.isValidIpv4(last)) return null;
+ const nums = last.split('.').map(n => Number(n));
+ const high = ((nums[0] << 8) | nums[1]).toString(16);
+ const low = ((nums[2] << 8) | nums[3]).toString(16);
+ return [...parts.slice(0, -1), high, low];
+ };
+
+ const leftExpanded = expandIpv4Part(leftParts);
+ if (!leftExpanded) return false;
+ rightParts = expandIpv4Part(rightParts);
+ if (!rightParts) return false;
+
+ const totalParts = leftExpanded.length + rightParts.length;
+ if (totalParts > 8) return false;
+
+ const isValidGroup = (part) => /^[0-9a-fA-F]{1,4}$/.test(part);
+ if (!leftExpanded.every(isValidGroup)) return false;
+ if (!rightParts.every(isValidGroup)) return false;
+
+ return true;
+ }
+
/**
* 解析剧名过滤正则
* @description 用于控制剧名过滤规则,没有默认值
@@ -377,7 +499,7 @@ export class Envs {
'VOD_REQUEST_TIMEOUT': { category: 'source', type: 'number', description: 'VOD请求超时时间,默认10000', min: 5000, max: 30000 },
'BILIBILI_COOKIE': { category: 'source', type: 'text', description: 'B站Cookie' },
'YOUKU_CONCURRENCY': { category: 'source', type: 'number', description: '优酷并发配置,默认8', min: 1, max: 16 },
- 'REAL_TIME_PULL_DANDAN': { category: 'source', type: 'boolean', description: '弹弹第三方弹幕源实时拉取开关,默认为false(关闭),可选值:true、false' },
+ 'REAL_TIME_PULL_DANDAN': { category: 'source', type: 'boolean', description: '弹弹第三方弹幕源实时拉取开关,默认为false(关闭),可选值:true、false' },
// 匹配配置
'PLATFORM_ORDER': { category: 'match', type: 'multi-select', options: this.ALLOWED_PLATFORMS, description: '平台排序配置,可以配置自动匹配时的优选平台。\n当配置合并平台的时候,可以指定期望的合并源,\n示例:一个结果返回了"dandan&bilibili1&animeko"和"youku"时,\n当配置"youku"时返回"youku" \n当配置"dandan&animeko"时返回"dandan&bilibili1&animeko"' },
@@ -403,6 +525,7 @@ export class Envs {
'DANMU_OUTPUT_FORMAT': { category: 'danmu', type: 'select', options: ['json', 'xml'], description: '弹幕输出格式,默认json' },
'DANMU_PUSH_URL': { category: 'danmu', type: 'text', description: '弹幕推送地址,示例 http://127.0.0.1:9978/action?do=refresh&type=danmaku&path= ' },
'LIKE_SWITCH': { category: 'danmu', type: 'boolean', description: '弹幕点赞数显示开关,默认开启' },
+ 'DANMU_OFFSET': { category: 'danmu', type: 'text', description: '弹幕时间偏移配置,格式:剧名:秒 或 剧名/季:秒 或 剧名/季/集:秒,多条用逗号分隔,例如:overlord/S01:90,re-zero/S02:120,re-zero/S02/E03:10,re-zero/S02/E07:150' },
// 缓存配置
'SEARCH_CACHE_MINUTES': { category: 'cache', type: 'number', description: '搜索结果缓存时间(分钟),默认1', min: 1, max: 120 },
@@ -421,6 +544,7 @@ export class Envs {
'DEPLOY_PLATFROM_PROJECT': { category: 'system', type: 'text', description: '部署平台项目名称' },
'DEPLOY_PLATFROM_TOKEN': { category: 'system', type: 'text', description: '部署平台访问令牌' },
'NODE_TLS_REJECT_UNAUTHORIZED': { category: 'system', type: 'number', description: '在建立 HTTPS 连接时是否验证服务器的 SSL/TLS 证书,0表示忽略,默认为1', min: 0, max: 1 },
+ 'IP_BLACKLIST': { category: 'system', type: 'text', description: 'IP 黑名单列表,支持逗号/分号/换行分隔,支持 /regex/ 或 /regex/i 正则,支持 IPv4/IPv6 CIDR(如 10.0.0.0/4、2001:db8::/64)。命中则拒绝请求' },
};
return {
@@ -437,7 +561,7 @@ export class Envs {
vodRequestTimeout: this.get('VOD_REQUEST_TIMEOUT', '10000', 'string'), // vod超时时间(默认10秒)
bilibliCookie: this.get('BILIBILI_COOKIE', '', 'string', true), // b站cookie
youkuConcurrency: Math.min(this.get('YOUKU_CONCURRENCY', 8, 'number'), 16), // 优酷并发配置
- realTimePullDandan: this.get('REAL_TIME_PULL_DANDAN', false, 'boolean'), // 弹弹第三方数据源实时拉取开关
+ realTimePullDandan: this.get('REAL_TIME_PULL_DANDAN', false, 'boolean'), // 弹弹第三方数据源实时拉取开关
platformOrderArr: this.resolvePlatformOrder(), // 自动匹配优选平台
animeTitleFilter: this.resolveAnimeTitleFilter(), // 剧名正则过滤
episodeTitleFilter: this.resolveEpisodeTitleFilter(), // 剧集标题正则过滤
@@ -448,6 +572,7 @@ export class Envs {
danmuSimplifiedTraditional: this.get('DANMU_SIMPLIFIED_TRADITIONAL', 'default', 'string'), // 弹幕简繁体转换设置:default(默认不转换)、simplified(繁转简)、traditional(简转繁)
danmuPushUrl: this.get('DANMU_PUSH_URL', '', 'string'), // 代理/反代地址
likeSwitch: this.get('LIKE_SWITCH', true, 'boolean'), // 弹幕点赞数显示开关,默认开启
+ danmuOffset: this.get('DANMU_OFFSET', '', 'string'), // 弹幕时间偏移配置
tmdbApiKey: this.get('TMDB_API_KEY', '', 'string', true), // TMDB API KEY
redisUrl: this.get('UPSTASH_REDIS_REST_URL', '', 'string', true), // upstash redis url
redisToken: this.get('UPSTASH_REDIS_REST_TOKEN', '', 'string', true), // upstash redis url
@@ -455,8 +580,8 @@ export class Envs {
rateLimitMaxRequests: this.get('RATE_LIMIT_MAX_REQUESTS', 3, 'number'), // 限流配置:时间窗口内最大请求次数(默认 3,0表示不限流)
enableAnimeEpisodeFilter: this.get('ENABLE_ANIME_EPISODE_FILTER', false, 'boolean'), // 控制手动搜索的时候是否根据ANIME_TITLE_FILTER进行剧名过滤以及根据EPISODE_TITLE_FILTER进行集标题过滤(默认 false,禁用过滤)
logLevel: this.get('LOG_LEVEL', 'info', 'string'), // 日志级别配置(默认 info,可选值:error, warn, info)
- searchCacheMinutes: this.get('SEARCH_CACHE_MINUTES', 1, 'number'), // 搜索结果缓存时间配置(分钟,默认 1)
- commentCacheMinutes: this.get('COMMENT_CACHE_MINUTES', 1, 'number'), // 弹幕缓存时间配置(分钟,默认 1)
+ searchCacheMinutes: this.get('SEARCH_CACHE_MINUTES', 3, 'number'), // 搜索结果缓存时间配置(分钟,默认 3)
+ commentCacheMinutes: this.get('COMMENT_CACHE_MINUTES', 3, 'number'), // 弹幕缓存时间配置(分钟,默认 3)
convertTopBottomToScroll: this.get('CONVERT_TOP_BOTTOM_TO_SCROLL', false, 'boolean'), // 顶部/底部弹幕转换为浮动弹幕配置(默认 false,禁用转换)
convertColor: this.get('CONVERT_COLOR', 'default', 'string'), // 弹幕转换颜色配置,支持 default、white、color(默认 default,禁用转换)
danmuOutputFormat: this.get('DANMU_OUTPUT_FORMAT', 'json', 'string'), // 弹幕输出格式配置(默认 json,可选值:json, xml)
@@ -464,6 +589,7 @@ export class Envs {
titleToChinese: this.get('TITLE_TO_CHINESE', false, 'boolean'), // 外语标题转换中文开关
animeTitleSimplified: this.get('ANIME_TITLE_SIMPLIFIED', false, 'boolean'), // 搜索的剧名标题自动繁转简
titleMappingTable: this.resolveTitleMappingTable(), // 剧名映射表,用于自动匹配时替换标题进行搜索
+ ipBlacklist: this.resolveIpBlacklist(), // IP 黑名单(支持正则)
aiBaseUrl: this.get('AI_BASE_URL', 'https://api.openai.com/v1', 'string'), // AI服务基础URL
aiModel: this.get('AI_MODEL', 'gpt-4o', 'string'), // AI模型名称
aiApiKey: this.get('AI_API_KEY', '', 'string', true), // AI服务API密钥
diff --git a/danmu_api/configs/globals.js b/danmu_api/configs/globals.js
index 4f09105..1626722 100644
--- a/danmu_api/configs/globals.js
+++ b/danmu_api/configs/globals.js
@@ -13,7 +13,7 @@ export const Globals = {
accessedEnvVars: {},
// 静态常量
- VERSION: '1.16.2',
+ VERSION: '1.17.0',
MAX_LOGS: 1000, // 日志存储,最多保存 1000 行
MAX_ANIMES: 100,
MAX_RECORDS: 100, // 请求记录最大数量
diff --git a/danmu_api/utils/ai-util.js b/danmu_api/utils/ai-util.js
index 6331eb5..7f3a62e 100644
--- a/danmu_api/utils/ai-util.js
+++ b/danmu_api/utils/ai-util.js
@@ -1,5 +1,9 @@
import { httpPost } from "./http-util.js";
+// =====================
+// AI客户端工具
+// =====================
+
export default class AIClient {
constructor({ apiKey, baseURL = 'https://api.openai.com/v1', model = 'gpt-4o', systemPrompt = '' }) {
this.apiKey = apiKey
diff --git a/danmu_api/utils/cache-util.js b/danmu_api/utils/cache-util.js
index 7a7b98e..1d509d8 100644
--- a/danmu_api/utils/cache-util.js
+++ b/danmu_api/utils/cache-util.js
@@ -8,6 +8,25 @@ let fs, path;
// cache数据结构处理函数
// =====================
+// 用于存储最后一次搜索的上下文 (IP -> Context)
+const lastSearchMap = new Map();
+
+export function setLastSearch(ip, data) {
+ lastSearchMap.set(ip, { ...data, timestamp: Date.now() });
+ // 简单的清理逻辑:如果map太大,清理一半
+ if (lastSearchMap.size > 200) {
+ for (const [key, value] of lastSearchMap) {
+ if (Date.now() - value.timestamp > 3600 * 1000) { // 清理超过1小时的
+ lastSearchMap.delete(key);
+ }
+ }
+ }
+}
+
+export function getLastSearch(ip) {
+ return lastSearchMap.get(ip);
+}
+
// 检查搜索缓存是否有效(未过期)
export function isSearchCacheValid(keyword) {
if (!globals.searchCache.has(keyword)) {
@@ -130,6 +149,17 @@ export function findUrlById(id) {
return null;
}
+// 根据 ID 查找 episodeIds 数组下标
+export function findIndexById(id) {
+ const index = globals.episodeIds.findIndex(episode => episode.id === id);
+ if (index !== -1) {
+ log("info", `Found index for ID ${id}: ${index}`);
+ return index;
+ }
+ log("error", `No index found for ID: ${id}`);
+ return -1;
+}
+
// 根据 ID 查找 TITLE
export function findTitleById(id) {
const episode = globals.episodeIds.find(episode => episode.id === id);
@@ -141,6 +171,22 @@ export function findTitleById(id) {
return null;
}
+// 根据 ID 查找 animeTitle
+export function findAnimeTitleById(id) {
+ for (const anime of globals.animes) {
+ if (!anime.links || !Array.isArray(anime.links)) {
+ continue;
+ }
+ const match = anime.links.find(link => link.id === id);
+ if (match) {
+ log("info", `Found animeTitle for ID ${id}: ${anime.animeTitle}`);
+ return anime.animeTitle;
+ }
+ }
+ log("error", `No animeTitle found for ID: ${id}`);
+ return null;
+}
+
// 添加 anime 对象到 animes,并将其 links 添加到 episodeIds
export function addAnime(anime) {
anime = Anime.fromJson(anime);
@@ -235,10 +281,23 @@ export function storeAnimeIdsToMap(curAnimes, key) {
uniqueAnimeIds.add(anime.animeId);
}
- // 保存旧的prefer值
+ // 保存旧的 prefer/source/offsets(兼容旧结构)
const oldValue = globals.lastSelectMap.get(key);
const oldPrefer = oldValue?.prefer;
const oldSource = oldValue?.source;
+ const oldPreferBySeason = oldValue?.preferBySeason;
+ const oldSourceBySeason = oldValue?.sourceBySeason;
+ const oldOffsets = oldValue?.offsets;
+
+ const preferBySeason = oldPreferBySeason ? { ...oldPreferBySeason } : {};
+ const sourceBySeason = oldSourceBySeason ? { ...oldSourceBySeason } : {};
+
+ if (oldPrefer !== undefined) {
+ preferBySeason.default = oldPrefer;
+ }
+ if (oldSource !== undefined) {
+ sourceBySeason.default = oldSource;
+ }
// 如果key已存在,先删除它(为了更新顺序,保证 FIFO)
if (globals.lastSelectMap.has(key)) {
@@ -248,7 +307,9 @@ export function storeAnimeIdsToMap(curAnimes, key) {
// 添加新记录,保留prefer字段
globals.lastSelectMap.set(key, {
animeIds: [...uniqueAnimeIds],
- ...(oldPrefer !== undefined && { prefer: oldPrefer, source: oldSource })
+ ...(Object.keys(preferBySeason).length > 0 && { preferBySeason }),
+ ...(Object.keys(sourceBySeason).length > 0 && { sourceBySeason }),
+ ...(oldOffsets !== undefined && { offsets: oldOffsets })
});
// 检查是否超过 MAX_LAST_SELECT_MAP,超过则删除最早的
@@ -264,19 +325,26 @@ export function findAnimeIdByCommentId(commentId) {
for (const anime of globals.animes) {
for (const link of anime.links) {
if (link.id === commentId) {
- return [anime.animeId, anime.source];
+ return [anime.animeId, anime.source, link.title];
}
}
}
- return [null, null];
+ return [null, null, null];
}
// 通过 animeId 查找 lastSelectMap 中 animeIds 包含该 animeId 的 key,并设置其 prefer 为 animeId
-export function setPreferByAnimeId(animeId, source) {
+export function setPreferByAnimeId(animeId, source, season = null, offset = null) {
for (const [key, value] of globals.lastSelectMap.entries()) {
if (value.animeIds && value.animeIds.includes(animeId)) {
- value.prefer = animeId;
- value.source = source;
+ const seasonKey = season === null ? 'default' : String(season);
+ value.preferBySeason = value.preferBySeason || {};
+ value.sourceBySeason = value.sourceBySeason || {};
+ value.preferBySeason[seasonKey] = animeId;
+ value.sourceBySeason[seasonKey] = source;
+ if (season !== null && offset !== null) {
+ value.offsets = value.offsets || {};
+ value.offsets[seasonKey] = offset;
+ }
globals.lastSelectMap.set(key, value); // 确保更新被保存
return key; // 返回被修改的 key
}
@@ -284,13 +352,22 @@ export function setPreferByAnimeId(animeId, source) {
return null; // 如果没有找到匹配的 key,返回 null
}
-// 通过title查询优选animeId
-export function getPreferAnimeId(title) {
+// 通过 title 查询优选 animeId(按 season 维度)
+export function getPreferAnimeId(title, season = null) {
const value = globals.lastSelectMap.get(title);
- if (!value || !value.prefer) {
- return [null, null];
+ if (!value) {
+ return [null, null, null];
}
- return [value.prefer, value.source];
+
+ const seasonKey = season === null ? 'default' : String(season);
+ const preferBySeason = value.preferBySeason || {};
+ const sourceBySeason = value.sourceBySeason || {};
+
+ const prefer = preferBySeason[seasonKey] ?? preferBySeason.default ?? value.prefer ?? null;
+ const source = sourceBySeason[seasonKey] ?? sourceBySeason.default ?? value.source ?? null;
+ const offsets = value.offsets || null;
+
+ return [prefer, source, offsets];
}
// 清理所有过期的 IP 记录(超过 1 分钟没有请求的 IP)
diff --git a/danmu_api/utils/common-util.js b/danmu_api/utils/common-util.js
index 3f3ff87..aa0b07d 100644
--- a/danmu_api/utils/common-util.js
+++ b/danmu_api/utils/common-util.js
@@ -305,3 +305,92 @@ export function validateType(value, expectedType) {
throw new TypeError(`${value} 必须是 ${expectedType},但传入的是 ${fieldName}`);
}
}
+
+// 从 animeTitle 中提取季数和纯剧名
+export function extractSeasonNumberFromAnimeTitle(animeTitle) {
+ if (!animeTitle) return { season: null, baseTitle: null };
+
+ const normalizedAnimeTitle = normalizeSpaces(animeTitle);
+ const match = normalizedAnimeTitle.match(/^(.*?)\(\d{4}\)/);
+ const titleWithoutYear = match ? match[1].trim() : normalizedAnimeTitle.split("(")[0].trim();
+
+ // 1) 明确季数标识:第X季/期/部
+ const explicitSeasonMatch = titleWithoutYear.match(/第\s*([0-9一二三四五六七八九十壹贰叁肆伍陆柒捌玖拾]+)\s*[季期部]/);
+ if (explicitSeasonMatch) {
+ return {
+ season: convertChineseNumber(explicitSeasonMatch[1]),
+ baseTitle: titleWithoutYear.replace(explicitSeasonMatch[0], "").trim(),
+ };
+ }
+
+ // 2) S2/Season 2
+ const seasonMatch = titleWithoutYear.match(/(?:S(?:eason)?|Season)\s*(\d+)/i);
+ if (seasonMatch) {
+ return {
+ season: parseInt(seasonMatch[1], 10),
+ baseTitle: titleWithoutYear.replace(seasonMatch[0], "").trim(),
+ };
+ }
+
+ // 3) Part 2
+ const partMatch = titleWithoutYear.match(/Part\s*(\d+)/i);
+ if (partMatch) {
+ return {
+ season: parseInt(partMatch[1], 10),
+ baseTitle: titleWithoutYear.replace(partMatch[0], "").trim(),
+ };
+ }
+
+ // 4) 尾部阿拉伯数字(如"某某 2" 或 "某某2",但不超过2位)
+ const trailingNumber = titleWithoutYear.match(/(?:^|\s|[^\d])(\d{1,2})$/);
+ if (trailingNumber) {
+ return {
+ season: parseInt(trailingNumber[1], 10),
+ baseTitle: titleWithoutYear.slice(0, titleWithoutYear.lastIndexOf(trailingNumber[1])).trim(),
+ };
+ }
+
+ // 5) 尾部中文数字(如"某某二")
+ const trailingChinese = titleWithoutYear.match(/([一二三四五六七八九十壹贰叁肆伍陆柒捌玖拾]+)$/);
+ if (trailingChinese) {
+ return {
+ season: convertChineseNumber(trailingChinese[1]),
+ baseTitle: titleWithoutYear.replace(trailingChinese[0], "").trim(),
+ };
+ }
+
+ return { season: null, baseTitle: titleWithoutYear };
+}
+
+// 从集标题中提取集数(支持多种格式:第1集、第01集、EP01、E01等)
+export function extractEpisodeNumberFromTitle(episodeTitle) {
+ if (!episodeTitle) return null;
+
+ // 匹配格式:第1集、第01集、第10集等
+ const chineseMatch = episodeTitle.match(/第(\d+)集/);
+ if (chineseMatch) {
+ return parseInt(chineseMatch[1], 10);
+ }
+
+ // 匹配格式:EP01、EP1、E01、E1等
+ const epMatch = episodeTitle.match(/[Ee][Pp]?(\d+)/);
+ if (epMatch) {
+ return parseInt(epMatch[1], 10);
+ }
+
+ // 匹配格式:01、1(纯数字,通常在标题开头或结尾)
+ const numberMatch = episodeTitle.match(/(?:^|\s)(\d+)(?:\s|$)/);
+ if (numberMatch) {
+ return parseInt(numberMatch[1], 10);
+ }
+
+ return null;
+}
+
+// 从标题中提取动漫名称、季数和集数
+export function extractAnimeInfo(animeTitle, episodeTitle) {
+ let {season, baseTitle} = extractSeasonNumberFromAnimeTitle(animeTitle);
+ let episode = extractEpisodeNumberFromTitle(episodeTitle);
+
+ return { baseTitle, season, episode };
+}
diff --git a/danmu_api/utils/danmu-util.js b/danmu_api/utils/danmu-util.js
index bca50f6..a8f0dbd 100644
--- a/danmu_api/utils/danmu-util.js
+++ b/danmu_api/utils/danmu-util.js
@@ -504,3 +504,56 @@ export function formatDanmuResponse(danmuData, queryFormat) {
// 默认返回 JSON
return jsonResponse(danmuData);
}
+
+// 解析偏移量配置
+function parseOffset(env) {
+ const map = new Map();
+ if (!env) return map;
+ for (const entry of env.split(',')) {
+ const [path, sec] = entry.trim().split(':');
+ if (path && sec !== undefined) {
+ const normalizedPath = path.trim().split('/').map((segment, index) => {
+ if (index === 0) return segment; // 剧名不处理
+ const seasonMatch = segment.match(/^S(\d+)$/i);
+ if (seasonMatch) return `S${String(parseInt(seasonMatch[1])).padStart(2, '0')}`;
+ const episodeMatch = segment.match(/^E(\d+)$/i);
+ if (episodeMatch) return `E${String(parseInt(episodeMatch[1])).padStart(2, '0')}`;
+ return segment;
+ }).join('/');
+ map.set(normalizedPath, parseInt(sec));
+ }
+ }
+ return map;
+}
+
+// 获取偏移量
+export function getOffset(anime, season, episode) {
+ const map = parseOffset(globals.danmuOffset);
+ log("info", `getOffset params: ${anime}, ${season}, ${episode}`);
+ log("info", `getOffset map: ${JSON.stringify([...map])}`);
+ return (
+ map.get(`${anime}/${season}/${episode}`) ?? // 集级
+ map.get(`${anime}/${season}`) ?? // 季级
+ map.get(anime) ?? // 剧级
+ 0
+ );
+}
+
+// 应用弹幕时间偏移
+export function applyDanmuOffset(danmus, offset) {
+ if (offset === 0) return danmus;
+
+ return danmus.map(danmu => {
+ // 解析时间参数
+ const pValues = danmu.p.split(',');
+ const time = parseFloat(pValues[0]) || 0;
+ const newTime = Math.max(0, time + offset); // 确保时间不为负数
+ pValues[0] = newTime.toFixed(3); // 保留3位小数
+
+ return {
+ ...danmu,
+ p: pValues.join(','),
+ t: newTime // 如果有t字段也更新
+ };
+ });
+}
diff --git a/danmu_api/utils/hanjutv-util.js b/danmu_api/utils/hanjutv-util.js
index 3621de8..6daa7c9 100644
--- a/danmu_api/utils/hanjutv-util.js
+++ b/danmu_api/utils/hanjutv-util.js
@@ -1,5 +1,9 @@
import { md5, stringToUtf8Bytes, utf8BytesToString, bytesToBase64, base64ToBytes, invSubBytes, subWord, keyExpansion, invShiftRows } from "./codec-util.js";
+// =====================
+// 韩剧tv工具
+// =====================
+
// 移动端参数
const HANJUTV_VERSION = "6.5.3";
const HANJUTV_VC = "a_7980";
diff --git a/danmu_api/utils/log-util.js b/danmu_api/utils/log-util.js
index 3a564d4..8541799 100644
--- a/danmu_api/utils/log-util.js
+++ b/danmu_api/utils/log-util.js
@@ -1,7 +1,7 @@
import { globals } from '../configs/globals.js';
// =====================
-// 路由请求相关
+// 日志记录工具
// =====================
export function log(level, ...args) {
diff --git a/danmu_api/utils/migu-util.js b/danmu_api/utils/migu-util.js
index 01f4a1e..9b556f0 100644
--- a/danmu_api/utils/migu-util.js
+++ b/danmu_api/utils/migu-util.js
@@ -12,6 +12,10 @@
import { stringToUtf8Bytes, utf8BytesToString } from "./codec-util.js";
+// =====================
+// 咪咕解码工具
+// =====================
+
// WASM binary embedded as base64 (from gateway-crypto.wasm)
const WASM_BASE64 = `
AGFzbQEAAAABWA5gAn9/AX9gA39/fwF/YAJ/fwBgA39/fwBgAX8Bf2ABfwBgBX9/f39/AX9gBX9/f39/AGAAAX9gBH9/f38AYAR/
diff --git a/danmu_api/worker.js b/danmu_api/worker.js
index 4cb0db3..59cc4c1 100644
--- a/danmu_api/worker.js
+++ b/danmu_api/worker.js
@@ -52,6 +52,18 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
log("info", `request path: ${path}`);
log("info", `client ip: ${clientIp}`);
+ // --- IP 黑名单拦截 ---
+ if (globals.ipBlacklist?.length) {
+ const isBlocked = globals.ipBlacklist.some(rule => matchIpBlacklistRule(rule, clientIp));
+ if (isBlocked) {
+ log("warn", `[IP Blacklist] Blocked request from IP: ${clientIp}`);
+ return jsonResponse(
+ { errorCode: 403, success: false, errorMessage: "Forbidden" },
+ 403
+ );
+ }
+ }
+
// --- 校验 token ---
const parts = path.split("/").filter(Boolean); // 去掉空段
@@ -280,7 +292,7 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
// GET /api/v2/match
if (path === "/api/v2/match" && method === "POST") {
- return matchAnime(url, req);
+ return matchAnime(url, req, clientIp);
}
// GET /api/v2/bangumi/:animeId
@@ -402,7 +414,7 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
log("info", `[Rate Limit] IP ${clientIp} request count: ${recentRequests.length}/${globals.rateLimitMaxRequests}`);
}
- return getComment(path, queryFormat, segmentFlag);
+ return getComment(path, queryFormat, segmentFlag, clientIp);
}
// POST /api/v2/segmentcomment - 接收segment类的JSON请求体
@@ -505,6 +517,106 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
return jsonResponse({ message: "Not found" }, 404);
}
+function matchIpBlacklistRule(rule, clientIp) {
+ if (!rule || !clientIp || clientIp === 'unknown') return false;
+
+ if (rule.type === 'exact') {
+ return rule.value === clientIp;
+ }
+
+ if (rule.type === 'regex') {
+ return rule.value.test(clientIp);
+ }
+
+ if (rule.type === 'cidr') {
+ return isIpInCidr(clientIp, rule.ip, rule.prefix);
+ }
+
+ return false;
+}
+
+function isIpInCidr(ip, cidrIp, prefix) {
+ const isIpv6 = ip.includes(':') || cidrIp.includes(':');
+ if (isIpv6) {
+ const ipBytes = ipv6ToBytes(ip);
+ const cidrBytes = ipv6ToBytes(cidrIp);
+ if (!ipBytes || !cidrBytes || prefix < 0 || prefix > 128) return false;
+ const fullBytes = Math.floor(prefix / 8);
+ const remainingBits = prefix % 8;
+
+ for (let i = 0; i < fullBytes; i++) {
+ if (ipBytes[i] !== cidrBytes[i]) return false;
+ }
+
+ if (remainingBits > 0) {
+ const mask = (0xff << (8 - remainingBits)) & 0xff;
+ return (ipBytes[fullBytes] & mask) === (cidrBytes[fullBytes] & mask);
+ }
+
+ return true;
+ }
+
+ const ipInt = ipv4ToInt(ip);
+ const cidrInt = ipv4ToInt(cidrIp);
+ if (ipInt === null || cidrInt === null || prefix < 0 || prefix > 32) return false;
+ const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
+ return (ipInt & mask) === (cidrInt & mask);
+}
+
+function ipv4ToInt(ip) {
+ const parts = ip.split('.');
+ if (parts.length !== 4) return null;
+ const nums = parts.map(part => Number(part));
+ if (nums.some(num => Number.isNaN(num) || num < 0 || num > 255)) return null;
+ return ((nums[0] << 24) >>> 0) + (nums[1] << 16) + (nums[2] << 8) + nums[3];
+}
+
+function ipv6ToBytes(ip) {
+ if (!ip || typeof ip !== 'string' || !ip.includes(':')) return null;
+ const normalized = ip.trim();
+ const segments = normalized.split('::');
+ if (segments.length > 2) return null;
+
+ let leftParts = segments[0] ? segments[0].split(':').filter(Boolean) : [];
+ let rightParts = segments[1] ? segments[1].split(':').filter(Boolean) : [];
+
+ const expandIpv4Part = (parts) => {
+ if (parts.length === 0) return parts;
+ const last = parts[parts.length - 1];
+ if (!last.includes('.')) return parts;
+ const ipv4Int = ipv4ToInt(last);
+ if (ipv4Int === null) return null;
+ const high = ((ipv4Int >>> 16) & 0xffff).toString(16);
+ const low = (ipv4Int & 0xffff).toString(16);
+ return [...parts.slice(0, -1), high, low];
+ };
+
+ leftParts = expandIpv4Part(leftParts);
+ rightParts = expandIpv4Part(rightParts);
+ if (!leftParts || !rightParts) return null;
+
+ if (segments.length === 1) {
+ if (leftParts.length !== 8) return null;
+ } else {
+ const totalParts = leftParts.length + rightParts.length;
+ if (totalParts > 8) return null;
+ const missing = 8 - totalParts;
+ rightParts = new Array(missing).fill('0').concat(rightParts);
+ }
+
+ const parts = leftParts.concat(rightParts);
+ if (parts.length !== 8) return null;
+
+ const bytes = [];
+ for (const part of parts) {
+ if (!/^[0-9a-fA-F]{1,4}$/.test(part)) return null;
+ const value = parseInt(part, 16);
+ bytes.push((value >> 8) & 0xff, value & 0xff);
+ }
+
+ return bytes;
+}
+
function isRunningOnVercel() {
if (typeof process === 'undefined' || !process.env) {
return false;
diff --git a/forward/forward-widget.js b/forward/forward-widget.js
index 478a95f..f60a5b1 100644
--- a/forward/forward-widget.js
+++ b/forward/forward-widget.js
@@ -345,6 +345,17 @@ WidgetMetadata = {
},
],
},
+ {
+ name: "danmuOffset",
+ title: "弹幕时间偏移配置,格式:剧名:秒 或 剧名/季:秒 或 剧名/季/集:秒,多条用逗号分隔,示例:overlord/S01:90,re-zero/S02:120,re-zero/S02/E03:10",
+ type: "input",
+ placeholders: [
+ {
+ title: "示例",
+ value: "",
+ },
+ ],
+ },
{
name: "convertTopBottomToScroll",
title: "顶部/底部弹幕转换为浮动弹幕,默认false",
@@ -480,7 +491,7 @@ if (typeof window !== 'undefined') {
let globals;
async function initGlobals(sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
- danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch) {
+ danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch) {
// 将传入的参数设置到环境变量中,以便Globals可以访问它们
const env = {};
@@ -502,6 +513,7 @@ async function initGlobals(sourceOrder, otherServer, customSourceApiUrl, vodServ
if (groupMinute !== undefined) env.GROUP_MINUTE = groupMinute;
if (danmuLimit !== undefined) env.DANMU_LIMIT = danmuLimit;
if (danmuSimplifiedTraditional !== undefined) env.DANMU_SIMPLIFIED_TRADITIONAL = danmuSimplifiedTraditional;
+ if (danmuOffset !== undefined) env.DANMU_OFFSET = danmuOffset;
if (convertTopBottomToScroll !== undefined) env.CONVERT_TOP_BOTTOM_TO_SCROLL = convertTopBottomToScroll;
if (convertColor !== undefined) env.CONVERT_COLOR = convertColor;
if (likeSwitch !== undefined) env.LIKE_SWITCH = likeSwitch;
@@ -573,11 +585,11 @@ const PREFIX_URL = "http://localhost:9321"
async function searchDanmu(params) {
const { tmdbId, type, title, season, link, videoUrl, sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
- danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch } = params;
+ danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch } = params;
await initGlobals(sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
- danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch);
+ danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch);
let simplifiedTitle = title
// 如果启用了搜索关键字繁转简,则进行转换
@@ -653,11 +665,11 @@ async function searchDanmu(params) {
async function getDetailById(params) {
const { animeId, sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
- danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch } = params;
+ danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch } = params;
await initGlobals(sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
- danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch);
+ danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch);
const response = await getBangumi(`${PREFIX_URL}/api/v2/bangumi/${animeId}`);
const resJson = await response.json();
@@ -672,11 +684,11 @@ async function getDetailById(params) {
async function getCommentsById(params) {
const { commentId, link, videoUrl, season, episode, tmdbId, type, title, segmentTime, sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
- danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch } = params;
+ danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch } = params;
await initGlobals(sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
- danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch);
+ danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch);
if (commentId) {
const storeKey = season && episode ? `${tmdbId}.${season}.${episode}` : `${tmdbId}`;
@@ -693,7 +705,7 @@ async function getCommentsById(params) {
if (lastCommentId === commentId && segmentList) {
return await getDanmuWithSegmentTime({ segmentTime, tmdbId, season, episode, sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
- danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch })
+ danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch })
} else {
Widget.storage.remove(storeKey);
Widget.storage.remove(commentIdKey);
@@ -719,11 +731,11 @@ async function getCommentsById(params) {
async function getDanmuWithSegmentTime(params) {
const { segmentTime, tmdbId, season, episode, sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
- danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch } = params;
+ danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch } = params;
await initGlobals(sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
- danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch);
+ danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch);
const storeKey = season && episode ? `${tmdbId}.${season}.${episode}` : `${tmdbId}`;
const segmentList = Widget.storage.get(storeKey);
diff --git a/forward/forward-widget.test.js b/forward/forward-widget.test.js
index 40616a4..d8c4bb2 100644
--- a/forward/forward-widget.test.js
+++ b/forward/forward-widget.test.js
@@ -216,7 +216,7 @@ async function testNewFlow() {
episode: 21,
airDate: '2025-07-18',
episodeName: '第2期上:首次大约会!温柔医生为爱冲锋',
- sourceOrder: 'douban',
+ sourceOrder: '360',
otherServer: 'https://api.danmu.icu',
customSourceApiUrl: '',
vodServers: '金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top',
@@ -233,6 +233,7 @@ async function testNewFlow() {
groupMinute: 1,
danmuLimit: 0,
danmuSimplifiedTraditional: 'false',
+ danmuOffset: '',
convertTopBottomToScroll: 'false',
convertColor: 'default',
likeSwitch: 'true',