增加tmdb标题替换相关函数,使通过tmdb原名搜索的结果可以参与自动匹配与合并 (#170)
* tmdb-util.js:增加标题替换相关函数,使调用者可以用别名替换翻译不同的标题 * animeko.js:支持接口回落、支持剧集翻页、提升整体结果准确率 * bahamut.js:调用tmdb的标题替换函数 使通过tmdb原名搜索的结果可以参与自动匹配与合并 * bilibili.js:调用tmdb的标题替换函数、条目原名也扔给common-util工具对比 使通过tmdb原名搜索的结果可以参与自动匹配与合并;修复使用条目原名搜索出来的结果会被过滤掉 * tmdb-util.js:利用新增的季度信息识别剥离搜索关键词中的季度信息 因为tmdb不支持中文的季度信息后缀搜索,“间谍过家家 第二季”会无结果 * envs.js:更新默认的集过滤正则,使用负向零宽断言防误杀一些词 * README.md:更新正则说明 * envs.js:更新集过滤正则 * Modify EPISODE_TITLE_FILTER regex in README Updated the EPISODE_TITLE_FILTER regex pattern in README.md to include additional terms and improve filtering capabilities. * envs.js:增加动画会使用的正式|角色|先导PV正则 * Update README.md
This commit is contained in:
1 parent
6ca1398563
commit
82c32c7e8a
6 files changed
+378
-75
No files matched your search
@@ -403,7 +403,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
|
||||
|
||||
```regex
|
||||
# EPISODE_TITLE_FILTER 默认值
|
||||
(特别|惊喜|纳凉)?企划|合伙人手记|超前(营业|vlog)?|速览|vlog|reaction|纯享|加更(版|篇)?|抢先(看|版|集|篇)?|抢鲜|预告|花絮(独家)?|特辑|彩蛋|专访|幕后(故事|花絮|独家)?|直播(陪看|回顾)?|未播(片段)?|衍生|番外|会员(专享|加长|尊享|专属|版)?|片花|精华|看点|速看|解读|影评|解说|吐槽|盘点|拍摄花絮|制作花絮|幕后花絮|未播花絮|独家花絮|花絮特辑|先导预告|终极预告|正式预告|官方预告|彩蛋片段|删减片段|未播片段|番外彩蛋|精彩片段|精彩看点|精彩回顾|精彩集锦|看点解析|看点预告|NG镜头|NG花絮|番外篇|番外特辑|制作特辑|拍摄特辑|幕后特辑|导演特辑|演员特辑|片尾曲|插曲|高光回顾|背景音乐|OST|音乐MV|歌曲MV|前季回顾|剧情回顾|往期回顾|内容总结|剧情盘点|精选合集|剪辑合集|混剪视频|独家专访|演员访谈|导演访谈|主创访谈|媒体采访|发布会采访|采访|陪看(记)?|试看版|短剧|精编|Plus|独家版|特别版|短片|发布会|解忧局|走心局|火锅局|巅峰时刻|坞里都知道|福持目标坞民|.{3,}篇|(?!.*(入局|破冰局|做局)).{2,}局|观察室|上班那点事儿|周top|赛段|直拍|REACTION|VLOG|全纪录|开播|先导|总宣|展演|集锦|旅行日记|精彩分享|剧情揭秘
|
||||
(特别|惊喜|纳凉)?企划(?!(书|案|部))|合伙人手记|超前(营业|vlog)?|速览|vlog|reaction|纯享|加更(版|篇)?|抢先(看|版|集|篇)?|(?<!(被|争|谁))抢[先鲜](?!(一步|手|攻|了|告|言|机|话))|预告(?!(函|信|书|犯))|花絮(独家)?|(制作|拍摄|幕后|花絮|未播|独家|演员|导演|主创|杀青|探班|收官|开播|先导|彩蛋|NG|回顾|高光|个人|主创)特辑|(?<!(行动|计划|游戏|任务|危机|神秘|黄金))彩蛋|(?<!(嫌疑人|证人|家属|律师|警方|凶手|死者))专访|(正式|角色|先导|概念|首曝|定档|剧情|动画|宣传|主题曲|印象)PV|(?<!(退居|回归|走向|转战|隐身|藏身))幕后(?!(主谋|主使|黑手|真凶|玩家|老板|金主|英雄|功臣|推手|大佬|操纵|交易|策划|博弈|BOSS|真相))(故事|花絮|独家)?|直播(陪看|回顾)?|直播(?!(.*(事件|杀人|自杀|谋杀|犯罪|现场|游戏|挑战)))|未播(片段)?|衍生(?!(品|物|兽))|番外(?!(地|人))|会员(专享|加长|尊享|专属|版)?|片花|(?<!(提取|吸收|生命|魔法|修护|美白))精华|看点|速看|解读(?!.*(密文|密码|密电|电报|档案|书信|遗书|碑文|代码|信号|暗号|讯息|谜题|人心|唇语|真相|谜团|梦境))|影评|解说|吐槽|(?<!(年终|季度|库存|资产|物资|财务|收获|战利))盘点|拍摄花絮|制作花絮|幕后花絮|未播花絮|独家花絮|花絮特辑|先导预告|终极预告|正式预告|官方预告|彩蛋片段|删减片段|未播片段|番外彩蛋|精彩片段|精彩看点|精彩回顾|精彩集锦|看点解析|看点预告|NG镜头|NG花絮|番外篇|番外特辑|制作特辑|拍摄特辑|幕后特辑|导演特辑|演员特辑|片尾曲|(?<!(生命|生活|情感|爱情|一段|小|意外))插曲|高光回顾|背景音乐|OST|音乐MV|歌曲MV|前季回顾|剧情回顾|往期回顾|内容总结|剧情盘点|精选合集|剪辑合集|混剪视频|独家专访|演员访谈|导演访谈|主创访谈|媒体采访|发布会采访|采访|陪看(记)?|试看版|短剧|精编|Plus|独家版|特别版(?!(图|画))|短片|发布会|解忧局|走心局|火锅局|巅峰时刻|坞里都知道|福持目标坞民|福利(?!(院|会|主义|课))篇|(福利|加更|番外|彩蛋|衍生|特别|收官|游戏|整蛊|日常)篇|独家(?!(记忆|试爱|报道|秘方|占有|宠爱|恩宠))|(?<!(市|分|警|总|省|卫|药|政|监|结|大|开|破|布|僵|困|骗|赌|胜|败|定|乱|危|迷|入|搅|设|中|残|平|和|终|变|对)).{2,}局(?!(长|座|势|面|部|内|外|中|限|促|气))|(?<!(重症|隔离|实验|心理|审讯|单向|术后))观察室|上班那点事儿|周top|赛段|直拍|REACTION|VLOG|(?<!(大案|要案|刑侦|侦查|破案|档案|风云|历史|战争|探案))全纪录|开播|先导|总宣|展演|集锦|旅行日记|精彩分享|剧情揭秘(?!(者|人))
|
||||
|
||||
# 如果你想自定义过滤词,请新增EPISODE_TITLE_FILTER环境变量,示例如下,每个词用'|'隔开,也可参照默认值填写
|
||||
测试|test
|
||||
|
||||
@@ -182,13 +182,34 @@ export class Envs {
|
||||
* @returns {RegExp} 过滤正则表达式
|
||||
*/
|
||||
static resolveEpisodeTitleFilter() {
|
||||
const defaultFilter = '(特别|惊喜|纳凉)?企划|合伙人手记|超前(营业|vlog)?|速览|vlog|reaction|纯享|加更(版|篇)?|抢先(看|版|集|篇)?|抢鲜|预告|花絮(独家)?|' +
|
||||
'特辑|彩蛋|专访|幕后(故事|花絮|独家)?|直播(陪看|回顾)?|未播(片段)?|衍生|番外|会员(专享|加长|尊享|专属|版)?|片花|精华|看点|速看|解读|影评|解说|吐槽|盘点|拍摄花絮|制作花絮|幕后花絮|未播花絮|独家花絮|' +
|
||||
'花絮特辑|先导预告|终极预告|正式预告|官方预告|彩蛋片段|删减片段|未播片段|番外彩蛋|精彩片段|精彩看点|精彩回顾|精彩集锦|看点解析|看点预告|' +
|
||||
'NG镜头|NG花絮|番外篇|番外特辑|制作特辑|拍摄特辑|幕后特辑|导演特辑|演员特辑|片尾曲|插曲|高光回顾|背景音乐|OST|音乐MV|歌曲MV|前季回顾|' +
|
||||
'剧情回顾|往期回顾|内容总结|剧情盘点|精选合集|剪辑合集|混剪视频|独家专访|演员访谈|导演访谈|主创访谈|媒体采访|发布会采访|采访|陪看(记)?|' +
|
||||
'试看版|短剧|精编|Plus|独家版|特别版|短片|发布会|解忧局|走心局|火锅局|巅峰时刻|坞里都知道|福持目标坞民|观察室|上班那点事儿|' +
|
||||
'周top|赛段|直拍|REACTION|VLOG|全纪录|开播|先导|总宣|展演|集锦|旅行日记|精彩分享|剧情揭秘';
|
||||
const defaultFilter =
|
||||
// 企划/Vlog/预告/抢先 (防误杀: 企划书, 预告犯, 被抢先了, 抢先一步, 抢得先机)
|
||||
'(特别|惊喜|纳凉)?企划(?!(书|案|部))|合伙人手记|超前(营业|vlog)?|速览|vlog|reaction|纯享|加更(版|篇)?|抢先(看|版|集|篇)?|' +
|
||||
'(?<!(被|争|谁))抢[先鲜](?!(一步|手|攻|了|告|言|机|话))|抢鲜|预告(?!(函|信|书|犯))|花絮(独家)?|' +
|
||||
// PV/特辑/彩蛋/专访 (防误杀: 行动彩蛋, 专访嫌疑人)
|
||||
'(制作|拍摄|幕后|花絮|未播|独家|演员|导演|主创|杀青|探班|收官|开播|先导|彩蛋|NG|回顾|高光|个人|主创)特辑|' +
|
||||
'(?<!(行动|计划|游戏|任务|危机|神秘|黄金))彩蛋|(?<!(嫌疑人|证人|家属|律师|警方|凶手|死者))专访|' +
|
||||
'(正式|角色|先导|概念|首曝|定档|剧情|动画|宣传|主题曲|印象)PV|' +
|
||||
// 幕后/番外/衍生 (防误杀: 幕后主谋, 番外地, 衍生品)
|
||||
'(?<!(退居|回归|走向|转战|隐身|藏身))幕后(?!(主谋|主使|黑手|真凶|玩家|老板|金主|英雄|功臣|推手|大佬|操纵|交易|策划|博弈|BOSS|真相))(故事|花絮|独家)?|' +
|
||||
'衍生(?!(品|物|兽))|番外(?!(地|人))|' +
|
||||
// 直播/解读/精华 (防误杀: 直播杀人, 解读梦境/人心, 生命精华)
|
||||
'直播(陪看|回顾)?|直播(?!(.*(事件|杀人|自杀|谋杀|犯罪|现场|游戏|挑战)))|未播(片段)?|会员(专享|加长|尊享|专属|版)?|片花|' +
|
||||
'(?<!(提取|吸收|生命|魔法|修护|美白))精华|看点|速看|解读(?!.*(密文|密码|密电|电报|档案|书信|遗书|碑文|代码|信号|暗号|讯息|谜题|人心|唇语|真相|谜团|梦境))|' +
|
||||
// 盘点/影评/花絮组合 (防误杀: 财务盘点, 盘点收获)
|
||||
'影评|解说|吐槽|(?<!(年终|季度|库存|资产|物资|财务|收获|战利))盘点|拍摄花絮|制作花絮|幕后花絮|未播花絮|独家花絮|花絮特辑|' +
|
||||
'先导预告|终极预告|正式预告|官方预告|彩蛋片段|删减片段|未播片段|番外彩蛋|精彩片段|精彩看点|精彩回顾|精彩集锦|看点解析|看点预告|NG镜头|NG花絮|' +
|
||||
// 音乐/回顾/插曲 (防误杀: 生活小插曲)
|
||||
'番外篇|番外特辑|制作特辑|拍摄特辑|幕后特辑|导演特辑|演员特辑|片尾曲|(?<!(生命|生活|情感|爱情|一段|小|意外))插曲|高光回顾|背景音乐|OST|音乐MV|歌曲MV|' +
|
||||
'前季回顾|剧情回顾|往期回顾|内容总结|剧情盘点|精选合集|剪辑合集|混剪视频|独家专访|演员访谈|导演访谈|主创访谈|媒体采访|发布会采访|采访|陪看(记)?|' +
|
||||
// 独家/福利/特定篇 (防误杀: 独家宠爱, 福利课)
|
||||
'试看版|短剧|精编|Plus|独家版|特别版(?!(图|画))|短片|发布会|解忧局|走心局|火锅局|巅峰时刻|坞里都知道|福持目标坞民|福利(?!(院|会|主义|课))篇|' +
|
||||
'(福利|加更|番外|彩蛋|衍生|特别|收官|游戏|整蛊|日常)篇|独家(?!(记忆|试爱|报道|秘方|占有|宠爱|恩宠))|' +
|
||||
// 局字大防线
|
||||
'(?<!(市|分|警|总|省|卫|药|政|监|结|大|开|破|布|僵|困|骗|赌|胜|败|定|乱|危|迷|入|搅|设|中|残|平|和|终|变|对)).{2,}局(?!(长|座|势|面|部|内|外|中|限|促|气))|' +
|
||||
// 观察室/全纪录/揭秘 (防误杀: ICU, 刑侦全纪录, 揭秘者)
|
||||
'(?<!(重症|隔离|实验|心理|审讯|单向|术后))观察室|上班那点事儿|周top|赛段|直拍|REACTION|VLOG|(?<!(大案|要案|刑侦|侦查|破案|档案|风云|历史|战争|探案))全纪录|' +
|
||||
'开播|先导|总宣|展演|集锦|旅行日记|精彩分享|剧情揭秘(?!(者|人))';
|
||||
|
||||
// 读取环境变量,如果设置了则完全覆盖默认值
|
||||
const customFilter = this.get('EPISODE_TITLE_FILTER', '', 'string', false).trim();
|
||||
|
||||
+129
-34
@@ -35,12 +35,15 @@ export default class AnimekoSource extends BaseSource {
|
||||
*/
|
||||
async search(keyword) {
|
||||
try {
|
||||
log("info", `[Animeko] 开始搜索 (V0): ${keyword}`);
|
||||
// 标准化函数
|
||||
const searchKeyword = keyword.replace(/[._]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
|
||||
log("info", `[Animeko] 开始搜索 (V0): ${searchKeyword}`);
|
||||
|
||||
const searchUrl = `https://api.bgm.tv/v0/search/subjects?limit=5`;
|
||||
|
||||
const payload = {
|
||||
keyword: keyword,
|
||||
keyword: searchKeyword,
|
||||
filter: {
|
||||
type: [2] // 2 代表动画类型
|
||||
}
|
||||
@@ -121,8 +124,15 @@ export default class AnimekoSource extends BaseSource {
|
||||
* @returns {Array} 过滤后的结果列表
|
||||
*/
|
||||
filterSearchResults(list, keyword) {
|
||||
const threshold = 0.6; // 相似度阈值
|
||||
const normalizedKeyword = simplized(keyword).toLowerCase().trim();
|
||||
const threshold = 0.8; // 相似度阈值
|
||||
|
||||
// 标准化函数
|
||||
const normalize = (str) => {
|
||||
if (!str) return "";
|
||||
return simplized(str).toLowerCase().replace(/[\p{P}\p{S}\s]/gu, "");
|
||||
};
|
||||
|
||||
const normalizedKeyword = normalize(keyword);
|
||||
|
||||
// 1. 基础相似度过滤 (获取所有潜在相关结果)
|
||||
const candidates = list.filter(item => {
|
||||
@@ -145,7 +155,8 @@ export default class AnimekoSource extends BaseSource {
|
||||
// 计算最高相似度得分
|
||||
let maxScore = 0;
|
||||
for (const t of titles) {
|
||||
const normalizedTitle = simplized(t).toLowerCase().trim();
|
||||
// 使用去符号后的文本进行比对
|
||||
const normalizedTitle = normalize(t);
|
||||
const score = this.calculateSimilarity(normalizedKeyword, normalizedTitle);
|
||||
if (score > maxScore) maxScore = score;
|
||||
}
|
||||
@@ -353,28 +364,72 @@ export default class AnimekoSource extends BaseSource {
|
||||
|
||||
/**
|
||||
* 获取剧集列表
|
||||
* Bangumi API 限制单次 limit=200,需循环获取完整列表
|
||||
* @param {number} subjectId 条目 ID
|
||||
* @returns {Promise<Array>} 剧集数组
|
||||
*/
|
||||
async getEpisodes(subjectId) {
|
||||
try {
|
||||
const resp = await httpGet(`https://api.bgm.tv/v0/episodes?subject_id=${subjectId}`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
let allEpisodes = [];
|
||||
let offset = 0;
|
||||
const limit = 200;
|
||||
|
||||
if (!resp || !resp.data) {
|
||||
log("info", `[Animeko] Subject ${subjectId} 无剧集数据`);
|
||||
return [];
|
||||
try {
|
||||
while (true) {
|
||||
// 构造分页 URL
|
||||
const url = `https://api.bgm.tv/v0/episodes?subject_id=${subjectId}&limit=${limit}&offset=${offset}`;
|
||||
|
||||
const resp = await httpGet(url, {
|
||||
headers: this.headers,
|
||||
});
|
||||
|
||||
// 1. 结构校验:确保 resp.data.data 存在且为数组
|
||||
// 对应您的 JSON: resp.data 存在,resp.data.data 是 [] (数组),校验通过
|
||||
if (!resp || !resp.data || !Array.isArray(resp.data.data)) {
|
||||
if (offset === 0) {
|
||||
log("info", `[Animeko] Subject ${subjectId} 无剧集数据或响应异常`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const currentBatch = resp.data.data;
|
||||
|
||||
// 2. 空数据校验:如果没有数据,停止
|
||||
// 对应您的 JSON: data 为 [],length 为 0,在此处 break 退出
|
||||
if (currentBatch.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
// 3. 合并数据
|
||||
allEpisodes = allEpisodes.concat(currentBatch);
|
||||
|
||||
// 打印进度日志
|
||||
if (currentBatch.length === limit) {
|
||||
log("info", `[Animeko] ID:${subjectId} 正加载更多剧集 (当前已获: ${allEpisodes.length})`);
|
||||
}
|
||||
|
||||
// 4. 判断是否还有下一页
|
||||
// 如果当前获取的数量少于限制数量 (例如获取了 2 个,而 limit 是 200),说明是最后一页
|
||||
if (currentBatch.length < limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
// 5. 准备下一页
|
||||
offset += limit;
|
||||
|
||||
// 6. 安全熔断:防止API异常导致死循环
|
||||
if (offset > 1600) {
|
||||
log("warn", `[Animeko] ID:${subjectId} 剧集数量超过安全限制(1600),停止翻页`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const body = resp.data;
|
||||
if (Array.isArray(body.data)) return body.data;
|
||||
|
||||
return [];
|
||||
return allEpisodes;
|
||||
|
||||
} catch (error) {
|
||||
log("error", "[Animeko] GetEpisodes error:", {
|
||||
message: error.message,
|
||||
id: subjectId
|
||||
id: subjectId,
|
||||
offset: offset
|
||||
});
|
||||
return [];
|
||||
}
|
||||
@@ -457,28 +512,68 @@ export default class AnimekoSource extends BaseSource {
|
||||
|
||||
/**
|
||||
* 获取完整弹幕列表
|
||||
* 支持传入纯数字ID或完整URL
|
||||
* 支持自动降级:Global -> CN
|
||||
* @param {string} episodeId 剧集 ID 或 完整 API URL
|
||||
* @returns {Promise<Array>} 弹幕数组
|
||||
*/
|
||||
async getEpisodeDanmu(episodeId) {
|
||||
try {
|
||||
// 兼容分片请求传递过来的完整 URL
|
||||
const url = episodeId.startsWith('http')
|
||||
? episodeId
|
||||
: `https://danmaku-global.myani.org/v1/danmaku/${episodeId}`;
|
||||
// 目前使用的服务器是全球区域的,备用大陆区域:https://danmaku-cn.myani.org
|
||||
|
||||
const resp = await httpGet(url, { headers: this.headers });
|
||||
|
||||
if (!resp || !resp.data) return [];
|
||||
const body = resp.data;
|
||||
if (body.danmakuList) return body.danmakuList;
|
||||
return [];
|
||||
} catch (error) {
|
||||
log("error", "[Animeko] GetDanmu error:", { message: error.message, url: episodeId });
|
||||
// 1. 提取真实 ID
|
||||
// 兼容分片请求传递过来的完整 URL 或 纯 ID
|
||||
let realId = String(episodeId).trim();
|
||||
|
||||
// 如果是完整 URL (包含 /),尝试提取最后一部分
|
||||
if (realId.includes('/')) {
|
||||
const parts = realId.split('/');
|
||||
realId = parts[parts.length - 1];
|
||||
}
|
||||
|
||||
// 去除可能存在的 URL 参数干扰 (例如 ?v=1)
|
||||
if (realId.includes('?')) {
|
||||
realId = realId.split('?')[0];
|
||||
}
|
||||
|
||||
if (!realId) {
|
||||
log("error", "[Animeko] 无效的 episodeId");
|
||||
return [];
|
||||
}
|
||||
|
||||
const HOST_GLOBAL = "https://danmaku-global.myani.org";
|
||||
const HOST_CN = "https://danmaku-cn.myani.org";
|
||||
|
||||
// 定义内部通用请求函数
|
||||
const fetchDanmu = async (hostUrl) => {
|
||||
const targetUrl = `${hostUrl}/v1/danmaku/${realId}`;
|
||||
try {
|
||||
const resp = await httpGet(targetUrl, { headers: this.headers });
|
||||
|
||||
if (!resp || !resp.data) return null;
|
||||
|
||||
const body = resp.data;
|
||||
if (body.danmakuList) return body.danmakuList;
|
||||
return null;
|
||||
} catch (error) {
|
||||
log("warn", `[Animeko] 请求节点失败: ${hostUrl} - ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 2. 优先尝试 Global 节点
|
||||
let danmuList = await fetchDanmu(HOST_GLOBAL);
|
||||
|
||||
// 3. 如果失败,降级尝试 CN 节点
|
||||
if (!danmuList) {
|
||||
log("info", `[Animeko] Global 节点获取失败/无数据,降级尝试 CN 节点... ID:${realId}`);
|
||||
danmuList = await fetchDanmu(HOST_CN);
|
||||
}
|
||||
|
||||
// 4. 返回结果或空数组
|
||||
if (danmuList) {
|
||||
log("info", `[Animeko] 成功获取弹幕,共 ${danmuList.length} 条`);
|
||||
return danmuList;
|
||||
}
|
||||
|
||||
log("error", "[Animeko] 所有节点尝试均失败,无法获取弹幕");
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -492,7 +587,7 @@ export default class AnimekoSource extends BaseSource {
|
||||
"type": "animeko",
|
||||
"segment_start": 0,
|
||||
"segment_end": 30000,
|
||||
"url": `https://danmaku-global.myani.org/v1/danmaku/${id}` // 使用完整 URL
|
||||
"url": String(id)
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { httpGet } from "../utils/http-util.js";
|
||||
import { generateValidStartDate } from "../utils/time-util.js";
|
||||
import { addAnime, removeEarliestAnime } from "../utils/cache-util.js";
|
||||
import { simplized, traditionalized } from "../utils/zh-util.js";
|
||||
import { getTmdbJaOriginalTitle } from "../utils/tmdb-util.js";
|
||||
import { getTmdbJaOriginalTitle, smartTitleReplace } from "../utils/tmdb-util.js";
|
||||
import { strictTitleMatch, normalizeSpaces } from "../utils/common-util.js";
|
||||
import { SegmentListResponse } from '../models/dandan-model.js';
|
||||
|
||||
@@ -80,14 +80,18 @@ export default class BahamutSource extends BaseSource {
|
||||
// 延迟100毫秒,避免与原始搜索争抢同一连接池
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// 如果类型不符合会返回null,自然跳过后续搜索
|
||||
const tmdbTitle = await getTmdbJaOriginalTitle(tmdbSearchKeyword, tmdbAbortController.signal, "Bahamut");
|
||||
// 获取 TMDB 日语原名及中文别名 (解构返回值)
|
||||
const tmdbResult = await getTmdbJaOriginalTitle(tmdbSearchKeyword, tmdbAbortController.signal, "Bahamut");
|
||||
|
||||
if (!tmdbTitle) {
|
||||
// 如果没有结果或者没有标题,则停止
|
||||
if (!tmdbResult || !tmdbResult.title) {
|
||||
log("info", "[Bahamut] TMDB转换未返回结果,取消日语原名搜索");
|
||||
return { success: false, source: 'tmdb' };
|
||||
}
|
||||
|
||||
// 解构出日语原名和中文别名
|
||||
const { title: tmdbTitle, cnAlias } = tmdbResult;
|
||||
|
||||
log("info", `[Bahamut] 使用日语原名进行搜索: ${tmdbTitle}`);
|
||||
const encodedTmdbTitle = encodeURIComponent(tmdbTitle);
|
||||
const targetUrl = `https://api.gamer.com.tw/mobile_app/anime/v1/search.php?kw=${encodedTmdbTitle}`;
|
||||
@@ -107,6 +111,7 @@ export default class BahamutSource extends BaseSource {
|
||||
try {
|
||||
a._originalQuery = keyword;
|
||||
a._searchUsedTitle = tmdbTitle;
|
||||
a._tmdbCnAlias = cnAlias;
|
||||
} catch (e) {}
|
||||
}
|
||||
log("info", `bahamutSearchresp (TMDB): ${JSON.stringify(anime)}`);
|
||||
@@ -276,6 +281,10 @@ export default class BahamutSource extends BaseSource {
|
||||
return bahamutTitleMatches(itemTitle, queryTitle, usedSearchTitle);
|
||||
});
|
||||
|
||||
// 应用tmdb智能标题替换
|
||||
const cnAlias = filtered.length > 0 ? filtered[0]._tmdbCnAlias : null;
|
||||
smartTitleReplace(filtered, cnAlias);
|
||||
|
||||
// 使用 map 和 async 时需要返回 Promise 数组,并等待所有 Promise 完成
|
||||
const processBahamutAnimes = await Promise.all(filtered.map(async (anime) => {
|
||||
try {
|
||||
@@ -304,10 +313,14 @@ export default class BahamutSource extends BaseSource {
|
||||
|
||||
if (links.length > 0) {
|
||||
let yearMatch = (anime.info || "").match(/(\d{4})/);
|
||||
|
||||
// 优先使用tmdb智能标题替换的标题,否则简转繁处理原标题
|
||||
const displayTitle = anime._displayTitle || simplized(anime.title);
|
||||
|
||||
let transformedAnime = {
|
||||
animeId: anime.video_sn,
|
||||
bangumiId: String(anime.video_sn),
|
||||
animeTitle: `${simplized(anime.title)}(${(anime.info.match(/(\d{4})/) || [null])[0]})【动漫】from bahamut`,
|
||||
animeTitle: `${displayTitle}(${(anime.info.match(/(\d{4})/) || [null])[0]})【动漫】from bahamut`,
|
||||
type: "动漫",
|
||||
typeDescription: "动漫",
|
||||
imageUrl: anime.cover,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { addAnime, removeEarliestAnime } from "../utils/cache-util.js";
|
||||
import { titleMatches } from "../utils/common-util.js";
|
||||
import { SegmentListResponse } from '../models/dandan-model.js';
|
||||
import { simplized } from "../utils/zh-util.js";
|
||||
import { getTmdbJaOriginalTitle } from "../utils/tmdb-util.js";
|
||||
import { getTmdbJaOriginalTitle, smartTitleReplace } from "../utils/tmdb-util.js";
|
||||
|
||||
// =====================
|
||||
// 获取b站弹幕
|
||||
@@ -203,12 +203,22 @@ export default class BilibiliSource extends BaseSource {
|
||||
return entities[match] || match;
|
||||
})
|
||||
.replace(/:/g, ':')
|
||||
.trim();
|
||||
|
||||
// 清洗原标题
|
||||
const cleanedOrgTitle = (item.org_title || "")
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/&[^;]+;/g, match => {
|
||||
const entities = { '<': '<', '>': '>', '&': '&', '"': '"', ''': "'" };
|
||||
return entities[match] || match;
|
||||
})
|
||||
.trim();
|
||||
|
||||
const resultItem = {
|
||||
provider: "bilibili",
|
||||
mediaId,
|
||||
title: cleanedTitle,
|
||||
org_title: cleanedOrgTitle,
|
||||
type: mediaType,
|
||||
year,
|
||||
imageUrl: item.cover || null,
|
||||
@@ -424,9 +434,13 @@ export default class BilibiliSource extends BaseSource {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 应用tmdb智能标题替换
|
||||
const cnAlias = sourceAnimes.length > 0 ? sourceAnimes[0]._tmdbCnAlias : null;
|
||||
smartTitleReplace(sourceAnimes, cnAlias);
|
||||
|
||||
const processPromises = sourceAnimes
|
||||
// 港澳台资源不做严格标题匹配,因为可能搜到的是日语/繁体标题
|
||||
.filter(anime => anime.isOversea || titleMatches(anime.title, queryTitle))
|
||||
.filter(anime => anime.isOversea || titleMatches(anime.title, queryTitle)||(anime.org_title && titleMatches(anime.org_title, queryTitle)))
|
||||
.map(async (anime) => {
|
||||
try {
|
||||
let links = [];
|
||||
@@ -493,10 +507,14 @@ export default class BilibiliSource extends BaseSource {
|
||||
if (links.length === 0) return;
|
||||
|
||||
const numericAnimeId = convertToAsciiSum(anime.mediaId);
|
||||
|
||||
// 优先使用tmdb智能标题替换的标题,否则对原标题进行繁转简处理
|
||||
const displayTitle = anime._displayTitle || simplized(anime.title);
|
||||
|
||||
const transformedAnime = {
|
||||
animeId: numericAnimeId,
|
||||
bangumiId: anime.mediaId,
|
||||
animeTitle: `${anime.title}(${anime.year || 'N/A'})【${anime.type}】from bilibili`,
|
||||
animeTitle: `${displayTitle}(${anime.year || 'N/A'})【${anime.type}】from bilibili`,
|
||||
type: anime.type,
|
||||
typeDescription: anime.type,
|
||||
imageUrl: anime.imageUrl,
|
||||
@@ -936,8 +954,37 @@ export default class BilibiliSource extends BaseSource {
|
||||
// 综合港澳台搜索入口
|
||||
async _searchOversea(keyword) {
|
||||
const tmdbAbortController = new AbortController();
|
||||
const t1 = this._searchOverseaRequest(keyword, "Original").then(r => { if(r.length) tmdbAbortController.abort(); return r; }).catch(()=>[]);
|
||||
const t2 = globals.tmdbApiKey ? (new Promise(r=>setTimeout(r,100)).then(()=>getTmdbJaOriginalTitle(keyword, tmdbAbortController.signal, "Bilibili")).then(t => (t&&t!==keyword)?this._searchOverseaRequest(t,"TMDB",tmdbAbortController.signal):[]).catch(()=>[])) : Promise.resolve([]);
|
||||
|
||||
// 1. 原始关键词搜索
|
||||
const t1 = this._searchOverseaRequest(keyword, "Original").then(r => {
|
||||
if(r.length) tmdbAbortController.abort();
|
||||
// 挂载原始 keyword
|
||||
r.forEach(i => i._originalQuery = keyword);
|
||||
return r;
|
||||
}).catch(()=>[]);
|
||||
|
||||
// 2. TMDB 辅助搜索
|
||||
const t2 = globals.tmdbApiKey ? (new Promise(r=>setTimeout(r,100)).then(async ()=>{
|
||||
// 获取 TMDB 原名及别名
|
||||
const tmdbResult = await getTmdbJaOriginalTitle(keyword, tmdbAbortController.signal, "Bilibili");
|
||||
|
||||
if (tmdbResult && tmdbResult.title && tmdbResult.title !== keyword) {
|
||||
const { title: tmdbTitle, cnAlias } = tmdbResult;
|
||||
|
||||
// 使用日语原名进行搜索
|
||||
const results = await this._searchOverseaRequest(tmdbTitle, "TMDB", tmdbAbortController.signal);
|
||||
|
||||
// 注入上下文信息,包括别名
|
||||
results.forEach(r => {
|
||||
r._originalQuery = keyword;
|
||||
r._searchUsedTitle = tmdbTitle;
|
||||
r._tmdbCnAlias = cnAlias;
|
||||
});
|
||||
return results;
|
||||
}
|
||||
return [];
|
||||
}).catch(()=>[])) : Promise.resolve([]);
|
||||
|
||||
return (await Promise.all([t1, t2])).flat();
|
||||
}
|
||||
|
||||
|
||||
+150
-23
@@ -15,7 +15,7 @@ const TMDB_PENDING = new Map();
|
||||
async function tmdbApiGet(url, options = {}) {
|
||||
const tmdbApi = "https://api.tmdb.org/3/";
|
||||
const tartgetUrl = `${tmdbApi}${url}`;
|
||||
// 修改:使用统一的代理 URL 构建方法
|
||||
// 使用统一的代理 URL 构建方法
|
||||
const nextUrl = globals.makeProxyUrl(tartgetUrl);
|
||||
|
||||
try {
|
||||
@@ -213,8 +213,14 @@ export async function getTmdbJaOriginalTitle(title, signal = null, sourceLabel =
|
||||
return null;
|
||||
}
|
||||
|
||||
// 优化搜索关键词: 剥离 "Season 2", "第二季" 等后缀
|
||||
const cleanTitle = cleanSearchQuery(title);
|
||||
if (cleanTitle !== title) {
|
||||
log("info", `[TMDB] 优化搜索关键词: "${title}" -> "${cleanTitle}"`);
|
||||
}
|
||||
|
||||
// 检查是否已有相同关键词的搜索任务正在进行
|
||||
let task = TMDB_PENDING.get(title);
|
||||
let task = TMDB_PENDING.get(cleanTitle);
|
||||
|
||||
if (!task) {
|
||||
// 创建一个新的控制器,用于控制真正的后台网络请求
|
||||
@@ -340,12 +346,12 @@ export async function getTmdbJaOriginalTitle(title, signal = null, sourceLabel =
|
||||
};
|
||||
|
||||
// 第一步:TMDB搜索
|
||||
log("info", `[TMDB] 正在搜索 (Shared Task): ${title}`);
|
||||
log("info", `[TMDB] 正在搜索 (Shared Task): ${cleanTitle}`);
|
||||
|
||||
// 检查 masterController 是否已被中断
|
||||
if (backgroundSignal.aborted) throw new DOMException('Aborted', 'AbortError');
|
||||
|
||||
const respZh = await searchTmdbTitles(title, "multi", { signal: backgroundSignal });
|
||||
const respZh = await searchTmdbTitles(cleanTitle, "multi", { signal: backgroundSignal });
|
||||
|
||||
if (!respZh || !respZh.data) {
|
||||
log("info", "[TMDB] TMDB搜索结果为空");
|
||||
@@ -382,9 +388,9 @@ export async function getTmdbJaOriginalTitle(title, signal = null, sourceLabel =
|
||||
if (!resultTitle) continue;
|
||||
|
||||
// 先计算原标题的相似度
|
||||
const directScore = similarity(title, resultTitle);
|
||||
const directScore = similarity(cleanTitle, resultTitle);
|
||||
const originalTitle = result.original_name || result.original_title || "";
|
||||
const originalScore = originalTitle ? similarity(title, originalTitle) : 0;
|
||||
const originalScore = originalTitle ? similarity(cleanTitle, originalTitle) : 0;
|
||||
const initialScore = Math.max(directScore, originalScore);
|
||||
|
||||
// 如果原标题已经100%匹配,标记跳过后续所有别名搜索
|
||||
@@ -433,7 +439,7 @@ export async function getTmdbJaOriginalTitle(title, signal = null, sourceLabel =
|
||||
log("info", `[TMDB] 已达到别名获取上限(${MAX_ALTERNATIVE_FETCHES}),使用原标题: ${resultTitle}`);
|
||||
}
|
||||
|
||||
const finalDirectScore = similarity(title, chineseTitle);
|
||||
const finalDirectScore = similarity(cleanTitle, chineseTitle);
|
||||
finalScore = Math.max(finalDirectScore, originalScore);
|
||||
|
||||
const displayInfo = chineseTitle !== resultTitle
|
||||
@@ -467,22 +473,22 @@ export async function getTmdbJaOriginalTitle(title, signal = null, sourceLabel =
|
||||
|
||||
const detailResp = await getTmdbJpDetail(mediaType, bestMatch.id, { signal: backgroundSignal });
|
||||
|
||||
let jaOriginalTitle;
|
||||
if (!detailResp || !detailResp.data) {
|
||||
const fallbackTitle = bestMatch.name || bestMatch.title;
|
||||
log("info", `[TMDB] 使用中文搜索结果标题: ${fallbackTitle}`);
|
||||
return fallbackTitle;
|
||||
jaOriginalTitle = bestMatch.name || bestMatch.title;
|
||||
log("info", `[TMDB] 使用中文搜索结果标题: ${jaOriginalTitle}`);
|
||||
} else {
|
||||
const detail = typeof detailResp.data === "string" ? JSON.parse(detailResp.data) : detailResp.data;
|
||||
jaOriginalTitle = detail.original_name || detail.original_title || detail.name || detail.title;
|
||||
log("info", `[TMDB] 找到日语原名: ${jaOriginalTitle}`);
|
||||
}
|
||||
|
||||
const detail = typeof detailResp.data === "string" ? JSON.parse(detailResp.data) : detailResp.data;
|
||||
|
||||
const jaOriginalTitle = detail.original_name || detail.original_title || detail.name || detail.title;
|
||||
log("info", `[TMDB] 找到日语原名: ${jaOriginalTitle}`);
|
||||
|
||||
return jaOriginalTitle;
|
||||
// 返回对象,包含原名和别名
|
||||
return { title: jaOriginalTitle, cnAlias: bestMatchChineseTitle };
|
||||
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
log("info", `[TMDB] 后台搜索任务已完全终止 (${title})`);
|
||||
log("info", `[TMDB] 后台搜索任务已完全终止 (${cleanTitle})`);
|
||||
throw error;
|
||||
}
|
||||
log("error", "[TMDB] Background Search error:", {
|
||||
@@ -500,14 +506,14 @@ export async function getTmdbJaOriginalTitle(title, signal = null, sourceLabel =
|
||||
refCount: 0,
|
||||
promise: executeSearch().finally(() => {
|
||||
// 无论成功失败,移除 Map 记录
|
||||
TMDB_PENDING.delete(title);
|
||||
TMDB_PENDING.delete(cleanTitle);
|
||||
})
|
||||
};
|
||||
|
||||
TMDB_PENDING.set(title, task);
|
||||
log("info", `[TMDB] 启动新搜索任务: ${title}`);
|
||||
TMDB_PENDING.set(cleanTitle, task);
|
||||
log("info", `[TMDB] 启动新搜索任务: ${cleanTitle}`);
|
||||
} else {
|
||||
log("info", `[TMDB] 加入正在进行的搜索: ${title} (${sourceLabel})`);
|
||||
log("info", `[TMDB] 加入正在进行的搜索: ${cleanTitle} (${sourceLabel})`);
|
||||
}
|
||||
|
||||
// 增加引用计数
|
||||
@@ -516,11 +522,11 @@ export async function getTmdbJaOriginalTitle(title, signal = null, sourceLabel =
|
||||
// 定义退出任务的逻辑
|
||||
const leaveTask = () => {
|
||||
// 再次获取任务确认其仍存在
|
||||
const currentTask = TMDB_PENDING.get(title);
|
||||
const currentTask = TMDB_PENDING.get(cleanTitle);
|
||||
if (currentTask === task) {
|
||||
task.refCount--;
|
||||
if (task.refCount <= 0) {
|
||||
log("info", `[TMDB] 所有调用者已取消,终止后台请求: ${title}`);
|
||||
log("info", `[TMDB] 所有调用者已取消,终止后台请求: ${cleanTitle}`);
|
||||
task.controller.abort();
|
||||
}
|
||||
}
|
||||
@@ -609,3 +615,124 @@ export async function getTMDBChineseTitle(title, season = null, episode = null)
|
||||
return title;
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// 智能标题替换相关函数
|
||||
// =====================
|
||||
|
||||
// 识别季度、剧场版、外传、副标题等后缀信息的正则白名单
|
||||
const SUFFIX_PATTERNS = [
|
||||
/(\s+|^)(?:第)?(\d+|[一二三四五六七八九十]+)[季期部]/,
|
||||
/(\s+|^)season\s*\d+/i,
|
||||
/(\s+|^)s\d+/i,
|
||||
/(\s+|^)part\s*\d+/i,
|
||||
/(\s+|^)the\s+final\s+season/i,
|
||||
/(\s+|^)movie(?![a-z])/i, // 负向预查,防止误匹配单词内部
|
||||
/(\s+|^)film(?![a-z])/i,
|
||||
/(\s+|^)ova(?![a-z])/i,
|
||||
/(\s+|^)oad(?![a-z])/i,
|
||||
/(\s+|^)sp(?![a-z])/i,
|
||||
/[::]\s*.+/,
|
||||
/[~~].+/,
|
||||
/\s+.*篇/,
|
||||
/.*外传/
|
||||
];
|
||||
|
||||
const SEPARATOR_REGEX = /[ ::~~]/;
|
||||
|
||||
/**
|
||||
* 寻找标题中属于后缀或季度信息的起始位置
|
||||
* @param {string} title 原标题
|
||||
* @returns {number} 后缀起始索引
|
||||
*/
|
||||
function detectSuffixStart(title) {
|
||||
let minIndex = title.length;
|
||||
for (const pattern of SUFFIX_PATTERNS) {
|
||||
const match = title.match(pattern);
|
||||
if (match && match.index !== undefined) {
|
||||
if (match.index < minIndex) {
|
||||
minIndex = match.index;
|
||||
}
|
||||
}
|
||||
}
|
||||
return minIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* 利用后缀正则清洗搜索关键词,移除季度等信息以提高 TMDB 搜索命中率
|
||||
* @param {string} title 原始标题
|
||||
* @returns {string} 清洗后的标题主体
|
||||
*/
|
||||
function cleanSearchQuery(title) {
|
||||
const limit = detectSuffixStart(title);
|
||||
if (limit < title.length) {
|
||||
return title.substring(0, limit).trim();
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 TMDB 中文别名对番剧列表进行智能标题替换
|
||||
* @param {Array} animes 待处理的 anime 对象列表
|
||||
* @param {string} cnAlias TMDB 中文别名
|
||||
*/
|
||||
export function smartTitleReplace(animes, cnAlias) {
|
||||
if (!animes || animes.length === 0 || !cnAlias) return;
|
||||
|
||||
log("info", `[TMDB] 启动智能替换,目标别名: "${cnAlias}",待处理条目: ${animes.length}`);
|
||||
|
||||
// 计算所有标题主体部分的 LCP (最长公共前缀)
|
||||
const baseTitles = animes.map(a => {
|
||||
const t = a.org_title || a.title || "";
|
||||
const limit = detectSuffixStart(t);
|
||||
return t.substring(0, limit);
|
||||
});
|
||||
|
||||
let lcp = "";
|
||||
if (baseTitles.length > 0) {
|
||||
const sorted = baseTitles.concat().sort();
|
||||
const a1 = sorted[0];
|
||||
const a2 = sorted[sorted.length - 1];
|
||||
let i = 0;
|
||||
while (i < a1.length && a1.charAt(i) === a2.charAt(i)) i++;
|
||||
lcp = a1.substring(0, i);
|
||||
}
|
||||
|
||||
if (lcp && lcp.length > 1) {
|
||||
log("info", `[TMDB] 计算出最长公共前缀 (LCP): "${lcp}"`);
|
||||
}
|
||||
|
||||
for (let i = 0; i < animes.length; i++) {
|
||||
const anime = animes[i];
|
||||
const originalTitle = anime.title || "";
|
||||
|
||||
// 策略 A: LCP 模式
|
||||
if (lcp && lcp.length > 1 && originalTitle.startsWith(lcp)) {
|
||||
const suffix = originalTitle.substring(lcp.length).trim();
|
||||
let newTitle;
|
||||
if (!suffix) {
|
||||
newTitle = cnAlias;
|
||||
} else if (suffix.match(/^[~~::]/)) {
|
||||
newTitle = cnAlias + suffix;
|
||||
} else {
|
||||
newTitle = cnAlias + " " + suffix;
|
||||
}
|
||||
anime._displayTitle = newTitle;
|
||||
log("info", `[TMDB] [LCP模式] "${originalTitle}" -> "${newTitle}"`);
|
||||
}
|
||||
// 策略 B: 分隔符模式
|
||||
else {
|
||||
const match = originalTitle.match(SEPARATOR_REGEX);
|
||||
if (match) {
|
||||
const suffix = originalTitle.substring(match.index);
|
||||
const newTitle = cnAlias + suffix;
|
||||
anime._displayTitle = newTitle;
|
||||
log("info", `[TMDB] [分隔符模式] "${originalTitle}" -> "${newTitle}"`);
|
||||
} else {
|
||||
// 策略 C: 全替模式
|
||||
anime._displayTitle = cnAlias;
|
||||
log("info", `[TMDB] [全替模式] "${originalTitle}" -> "${cnAlias}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user