增加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:
Elegy233 authored and GitHub committed 2026-01-29 00:19:50 +08:00
1 parent 6ca1398563
commit 82c32c7e8a
6 files changed
+378 -75

No files matched your search

+1 -1
View File
@@ -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
+28 -7
View File
@@ -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
View File
@@ -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)
}]
});
}
+18 -5
View File
@@ -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,
+52 -5
View File
@@ -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 = { '&lt;': '<', '&gt;': '>', '&amp;': '&', '&quot;': '"', '&#39;': "'" };
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
View File
@@ -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}"`);
}
}
}
}