feat: 适配 FongMi影视 原生弹幕接口 (#296)
* feat: add FongMi native danmaku adapter * feat: improve FongMi title and episode matching
This commit is contained in:
1 parent
e851d7495d
commit
71075b554b
3 files changed
+395
-5
No files matched your search
@@ -0,0 +1,370 @@
|
||||
import { globals } from "../../configs/globals.js";
|
||||
import { jsonResponse } from "../../utils/http-util.js";
|
||||
import { log } from "../../utils/log-util.js";
|
||||
import { simplized } from "../../utils/zh-util.js";
|
||||
import { convertChineseNumber, extractEpisodeTitle, extractEpisodeNumberFromTitle, normalizeSpaces } from "../../utils/common-util.js";
|
||||
import { filterSameEpisodeTitle, getBangumiDataForMatch, searchAnime } from "../dandan-api.js";
|
||||
|
||||
// =====================
|
||||
// FongMi 弹幕接口适配
|
||||
// =====================
|
||||
|
||||
const FONGMI_TITLE_CLEAN_RULES = [
|
||||
[/[\(\[(【]\s*(19|20)\d{2}\s*[\)\])】]/g, " "],
|
||||
[/\b(19|20)\d{2}\b/g, " "],
|
||||
[/\b(?:2160p|1080p|720p|4k|web-?dl|web-?rip|blu-?ray|hdr|dv|x265|x264|h\.?265|h\.?264|60fps)\b/gi, " "],
|
||||
[/[_.-]+/g, " "]
|
||||
];
|
||||
|
||||
const FONGMI_EPISODE_CLEAN_RULES = [
|
||||
[/\[[^\]]*\]/g, " "],
|
||||
[/[【((][^】))]*[】))]/g, " "],
|
||||
[/\.(mp4|mkv|avi|rmvb|ts|flv|mov|m4v)$/gi, " "],
|
||||
[/\b(?:2160p|1080p|720p|4k|web-?dl|web-?rip|blu-?ray|hdr|dv|x265|x264|h\.?265|h\.?264|60fps|aac|flac|dts)\b/gi, " "],
|
||||
[/[_~.-]+/g, " "]
|
||||
];
|
||||
|
||||
/**
|
||||
* 规范化 FongMi 传入文本,统一大小写、空格和简繁体。
|
||||
* @param {string} value 原始文本
|
||||
* @returns {string} 规范化后的文本
|
||||
*/
|
||||
function normalizeFongmiText(value) {
|
||||
let text = String(value || "").trim();
|
||||
if (!text) return "";
|
||||
if (globals.animeTitleSimplified) {
|
||||
try {
|
||||
text = simplized(text);
|
||||
} catch (e) {
|
||||
// 保底忽略简繁转换异常,继续使用原始文本
|
||||
}
|
||||
}
|
||||
return normalizeSpaces(text.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取文本中的 8 位日期数字,兼容综艺类日期匹配。
|
||||
* @param {string} value 原始文本
|
||||
* @returns {string} 8 位日期数字,未提取到则返回空串
|
||||
*/
|
||||
function extractDateDigits(value) {
|
||||
const digits = String(value || "").replace(/\D/g, "");
|
||||
return digits.length >= 8 ? digits.slice(0, 8) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 标题清洗正则预处理。
|
||||
* 这里预留独立函数,方便后续继续补充标题噪音规则。
|
||||
* @param {string} name 原始标题
|
||||
* @returns {string} 预处理后的标题
|
||||
*/
|
||||
function normalizeFongmiTitleByRegex(name) {
|
||||
let text = String(name || "");
|
||||
FONGMI_TITLE_CLEAN_RULES.forEach(([pattern, replacement]) => {
|
||||
text = text.replace(pattern, replacement);
|
||||
});
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 集数文本正则预处理。
|
||||
* 这里先保留为空实现,后续补 PR 时可以继续扩充网盘命名兼容。
|
||||
* @param {string} episode 原始集数文本
|
||||
* @returns {string} 预处理后的集数文本
|
||||
*/
|
||||
function normalizeFongmiEpisodeByRegex(episode) {
|
||||
let text = String(episode || "");
|
||||
FONGMI_EPISODE_CLEAN_RULES.forEach(([pattern, replacement]) => {
|
||||
text = text.replace(pattern, replacement);
|
||||
});
|
||||
return normalizeSpaces(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 FongMi 集数文本中提取更稳健的集数。
|
||||
* 除了复用公共提取逻辑外,再补充网盘命名常见格式。
|
||||
* @param {string} episode 原始集数文本
|
||||
* @returns {number|null} 集数,未提取到则返回 null
|
||||
*/
|
||||
function extractFongmiEpisodeNumber(episode) {
|
||||
const normalizedEpisode = normalizeFongmiEpisodeByRegex(episode);
|
||||
const commonNum = extractEpisodeNumberFromTitle(normalizedEpisode);
|
||||
if (commonNum !== null) return commonNum;
|
||||
|
||||
const seasonEpisodeMatch = normalizedEpisode.match(/S\d{1,2}\s*E(\d{1,4})/i);
|
||||
if (seasonEpisodeMatch) {
|
||||
return parseInt(seasonEpisodeMatch[1], 10);
|
||||
}
|
||||
|
||||
const chineseEpisodeMatch = normalizedEpisode.match(/第\s*([一二三四五六七八九十百零〇两\d]+)\s*(?:集|话|期|回|章)/);
|
||||
if (chineseEpisodeMatch) {
|
||||
return convertChineseNumber(chineseEpisodeMatch[1]);
|
||||
}
|
||||
|
||||
const xEpisodeMatch = normalizedEpisode.match(/(?:^|\s)(\d{1,4})x(?:\s|$)/i);
|
||||
if (xEpisodeMatch) {
|
||||
return parseInt(xEpisodeMatch[1], 10);
|
||||
}
|
||||
|
||||
const standaloneEpisodeMatch = normalizedEpisode.match(/(?:^|\s)(\d{1,4})(?:\s|$)/);
|
||||
if (standaloneEpisodeMatch) {
|
||||
return parseInt(standaloneEpisodeMatch[1], 10);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为 FongMi 标题生成搜索关键词列表。
|
||||
* 先保留原始标题,再追加正则清洗后的标题作为回退搜索词。
|
||||
* @param {string} name 原始标题
|
||||
* @returns {string[]} 搜索关键词数组
|
||||
*/
|
||||
function buildFongmiSearchKeywords(name) {
|
||||
const rawName = String(name || "").trim();
|
||||
if (!rawName) return [];
|
||||
|
||||
const keywords = [];
|
||||
const pushKeyword = (value) => {
|
||||
const keyword = normalizeSpaces(String(value || "").trim());
|
||||
if (!keyword || keywords.includes(keyword)) return;
|
||||
keywords.push(keyword);
|
||||
};
|
||||
|
||||
pushKeyword(rawName);
|
||||
|
||||
const cleanedName = normalizeSpaces(normalizeFongmiTitleByRegex(rawName)).trim();
|
||||
if (cleanedName && cleanedName !== rawName) {
|
||||
pushKeyword(cleanedName);
|
||||
}
|
||||
|
||||
const plainBracketName = normalizeSpaces(rawName.replace(/[\(\[(【].*$/, "")).trim();
|
||||
if (plainBracketName && plainBracketName !== rawName) {
|
||||
pushKeyword(plainBracketName);
|
||||
}
|
||||
|
||||
return keywords.sort((a, b) => a.length - b.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 FongMi 返回的弹幕基础地址。
|
||||
* 这里会尽量保留 token 前缀,避免 comment 接口丢失部署路径。
|
||||
* @param {Request} req 请求对象
|
||||
* @returns {string} 弹幕基础地址
|
||||
*/
|
||||
function buildFongmiApiBase(req) {
|
||||
const reqUrl = new URL(req.url);
|
||||
const path = reqUrl.pathname.replace(/\/+$/, "");
|
||||
const apiMarker = "/api/v2/";
|
||||
const danmakuMarker = "/danmaku";
|
||||
|
||||
let prefix = "";
|
||||
const apiMarkerIndex = path.indexOf(apiMarker);
|
||||
if (apiMarkerIndex >= 0) {
|
||||
prefix = path.slice(0, apiMarkerIndex);
|
||||
} else {
|
||||
const danmakuIndex = path.indexOf(danmakuMarker);
|
||||
if (danmakuIndex >= 0) {
|
||||
prefix = path.slice(0, danmakuIndex);
|
||||
} else {
|
||||
prefix = path;
|
||||
}
|
||||
}
|
||||
|
||||
return `${reqUrl.origin}${prefix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 FongMi 的请求参数,兼容 GET、JSON POST、表单 POST。
|
||||
* @param {URL} url 请求 URL
|
||||
* @param {Request} req 请求对象
|
||||
* @returns {Promise<{name: string, episode: string}>} 标题和集数文本
|
||||
*/
|
||||
async function parseFongmiRequestParams(url, req) {
|
||||
if (req.method === "GET") {
|
||||
return {
|
||||
name: url.searchParams.get("name") || "",
|
||||
episode: url.searchParams.get("episode") || ""
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const clonedReq = req.clone();
|
||||
const contentType = (clonedReq.headers.get("content-type") || "").toLowerCase();
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
const body = await clonedReq.json();
|
||||
return {
|
||||
name: body?.name || "",
|
||||
episode: body?.episode || ""
|
||||
};
|
||||
}
|
||||
|
||||
if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
|
||||
const form = await clonedReq.formData();
|
||||
return {
|
||||
name: form.get("name") || "",
|
||||
episode: form.get("episode") || ""
|
||||
};
|
||||
}
|
||||
|
||||
const text = await clonedReq.text();
|
||||
if (text) {
|
||||
try {
|
||||
const body = JSON.parse(text);
|
||||
return {
|
||||
name: body?.name || "",
|
||||
episode: body?.episode || ""
|
||||
};
|
||||
} catch (e) {
|
||||
const params = new URLSearchParams(text);
|
||||
return {
|
||||
name: params.get("name") || "",
|
||||
episode: params.get("episode") || ""
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log("warn", `[Fongmi] 解析请求参数失败: ${e.message}`);
|
||||
}
|
||||
|
||||
return { name: "", episode: "" };
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算单个候选分集与目标分集的匹配得分。
|
||||
* 当前综合标题、集数、日期做排序,后续可以继续扩展更多维度。
|
||||
* @param {Object} anime 候选剧集对象
|
||||
* @param {Object} episode 候选分集对象
|
||||
* @param {string} targetEpisode FongMi 传入的分集文本
|
||||
* @param {number} index 分集索引
|
||||
* @returns {number} 匹配得分
|
||||
*/
|
||||
function scoreFongmiEpisodeMatch(anime, episode, targetEpisode, index) {
|
||||
let score = Math.max(0, 200 - index);
|
||||
|
||||
const normalizedTargetEpisode = normalizeFongmiEpisodeByRegex(targetEpisode);
|
||||
const targetText = normalizeFongmiText(normalizedTargetEpisode);
|
||||
if (!targetText) return score;
|
||||
|
||||
const episodeTitle = episode?.episodeTitle || "";
|
||||
const episodeText = normalizeFongmiText(episodeTitle);
|
||||
const titleWithoutPlatform = normalizeFongmiText(extractEpisodeTitle(episodeTitle));
|
||||
const animeText = normalizeFongmiText(anime?.animeTitle || "");
|
||||
|
||||
if (episodeText === targetText || titleWithoutPlatform === targetText) score += 10000;
|
||||
if (episodeText.includes(targetText) || targetText.includes(episodeText)) score += 4500;
|
||||
if (titleWithoutPlatform && (titleWithoutPlatform.includes(targetText) || targetText.includes(titleWithoutPlatform))) score += 2500;
|
||||
|
||||
const targetNum = extractFongmiEpisodeNumber(normalizedTargetEpisode);
|
||||
const episodeNum = extractFongmiEpisodeNumber(episodeTitle);
|
||||
const episodeIndexNum = parseInt(episode?.episodeNumber || `${index + 1}`, 10);
|
||||
if (targetNum !== null && episodeNum !== null && targetNum === episodeNum) score += 7000;
|
||||
if (targetNum !== null && Number.isFinite(episodeIndexNum) && targetNum === episodeIndexNum) score += 4000;
|
||||
|
||||
const targetDate = extractDateDigits(normalizedTargetEpisode);
|
||||
const episodeDate = extractDateDigits(episodeTitle);
|
||||
if (targetDate && episodeDate && targetDate === episodeDate) score += 9000;
|
||||
|
||||
if (targetDate && targetText.includes(targetDate)) score += 800;
|
||||
if (animeText.includes("综艺") && targetDate && episodeDate) score += 1200;
|
||||
|
||||
if (animeText.includes("综艺") && targetDate && !episodeDate && targetNum === null) score -= 1500;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将搜索结果展开成 FongMi 可排序的候选分集列表。
|
||||
* @param {Array} animes 搜索到的剧集列表
|
||||
* @param {Map} detailStore 详情缓存
|
||||
* @param {string} apiBase 基础地址
|
||||
* @returns {Array} 候选分集数组
|
||||
*/
|
||||
function buildFongmiDanmakuItems(animes, detailStore, apiBase) {
|
||||
const candidates = [];
|
||||
|
||||
for (const anime of animes) {
|
||||
const bangumiData = getBangumiDataForMatch(anime, detailStore);
|
||||
if (!bangumiData?.success || !bangumiData?.bangumi?.episodes?.length) continue;
|
||||
|
||||
let episodes = bangumiData.bangumi.episodes;
|
||||
if (globals.enableAnimeEpisodeFilter) {
|
||||
episodes = episodes.filter(item => !globals.episodeTitleFilter.test(item.episodeTitle));
|
||||
}
|
||||
episodes = filterSameEpisodeTitle(episodes);
|
||||
|
||||
episodes.forEach((episode, index) => {
|
||||
const commentUrl = `${apiBase}/api/v2/comment/${encodeURIComponent(episode.episodeId)}?format=xml`;
|
||||
candidates.push({
|
||||
anime,
|
||||
episode,
|
||||
index,
|
||||
commentUrl
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* FongMi 自定义弹幕接口。
|
||||
* 返回 FongMi 需要的 JSON 数组格式:[{ name, url }]
|
||||
* @param {URL} url 请求 URL
|
||||
* @param {Request} req 请求对象
|
||||
* @returns {Promise<Response>} 弹幕候选响应
|
||||
*/
|
||||
export async function getFongmiDanmaku(url, req) {
|
||||
const { name, episode } = await parseFongmiRequestParams(url, req);
|
||||
|
||||
if (!name) {
|
||||
return jsonResponse([], 200);
|
||||
}
|
||||
|
||||
const searchUrl = new URL(url.toString());
|
||||
const detailStore = new Map();
|
||||
const keywords = buildFongmiSearchKeywords(name);
|
||||
let animes = [];
|
||||
|
||||
for (const keyword of keywords) {
|
||||
searchUrl.searchParams.set("keyword", keyword);
|
||||
const searchRes = await searchAnime(searchUrl, null, null, detailStore);
|
||||
const searchData = await searchRes.json();
|
||||
animes = Array.isArray(searchData?.animes) ? searchData.animes : [];
|
||||
if (animes.length) {
|
||||
if (keyword !== name) {
|
||||
log("info", `[Fongmi] Search fallback hit: raw=${name}, keyword=${keyword}, episode=${episode}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!animes.length) {
|
||||
log("info", `[Fongmi] No danmaku candidates for name=${name}, episode=${episode}`);
|
||||
return jsonResponse([], 200);
|
||||
}
|
||||
|
||||
const apiBase = buildFongmiApiBase(req);
|
||||
const candidates = buildFongmiDanmakuItems(animes, detailStore, apiBase)
|
||||
.map(item => ({
|
||||
...item,
|
||||
score: scoreFongmiEpisodeMatch(item.anime, item.episode, episode, item.index)
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
const seen = new Set();
|
||||
const items = [];
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate?.commentUrl || seen.has(candidate.commentUrl)) continue;
|
||||
seen.add(candidate.commentUrl);
|
||||
items.push({
|
||||
name: `${candidate.anime.animeTitle} - ${candidate.episode.episodeTitle}`,
|
||||
url: candidate.commentUrl
|
||||
});
|
||||
if (items.length >= 12) break;
|
||||
}
|
||||
|
||||
log("info", `[Fongmi] name=${name}, episode=${episode}, candidates=${items.length}`);
|
||||
return jsonResponse(items, 200);
|
||||
}
|
||||
@@ -523,7 +523,7 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null
|
||||
|
||||
}
|
||||
|
||||
function filterSameEpisodeTitle(filteredTmpEpisodes) {
|
||||
export function filterSameEpisodeTitle(filteredTmpEpisodes) {
|
||||
const filteredEpisodes = filteredTmpEpisodes.filter((episode, index, episodes) => {
|
||||
// 查找当前 episode 标题是否在之前的 episodes 中出现过
|
||||
return !episodes.slice(0, index).some(prevEpisode => {
|
||||
@@ -726,7 +726,7 @@ async function matchAniAndEpByAi(season, episode, year, searchData, title, req,
|
||||
}
|
||||
}
|
||||
|
||||
function getBangumiDataForMatch(anime, detailStore = null) {
|
||||
export function getBangumiDataForMatch(anime, detailStore = null) {
|
||||
const detailAnime =
|
||||
resolveAnimeByIdFromDetailStore(anime?.bangumiId, detailStore, anime?.source) ||
|
||||
resolveAnimeByIdFromDetailStore(anime?.animeId, detailStore, anime?.source);
|
||||
|
||||
+23
-3
@@ -7,6 +7,7 @@ import { formatDanmuResponse } from "./utils/danmu-util.js";
|
||||
import AIClient from './utils/ai-util.js';
|
||||
import { initBangumiData } from "./utils/bangumi-data-util.js";
|
||||
import { getBangumi, getComment, getCommentByUrl, getSegmentComment, matchAnime, searchAnime, searchEpisodes } from "./apis/dandan-api.js";
|
||||
import { getFongmiDanmaku } from "./apis/clients/fongmi-api.js";
|
||||
import { handleConfig, handleUI, handleLogs, handleClearLogs, handleDeploy, handleClearCache, handleReqRecords } from "./apis/system-api.js";
|
||||
import { handleSetEnv, handleAddEnv, handleDelEnv, handleAiVerify } from "./apis/env-api.js";
|
||||
import { Segment } from "./models/dandan-model.js"
|
||||
@@ -29,7 +30,7 @@ async function handleRequest(req, env, deployPlatform, clientIp, ctx) {
|
||||
const method = req.method;
|
||||
|
||||
// Bangumi Data 辅助函数,用于判断数据更新
|
||||
const isDataDependentRequest = path.includes('/search') || path.includes('/match');
|
||||
const isDataDependentRequest = path.includes('/search') || path.includes('/match') || path.includes('/danmaku');
|
||||
|
||||
if (globals.useBangumiData) {
|
||||
await initBangumiData(deployPlatform, isDataDependentRequest, ctx);
|
||||
@@ -75,7 +76,7 @@ async function handleRequest(req, env, deployPlatform, clientIp, ctx) {
|
||||
// --- 校验 token ---
|
||||
const parts = path.split("/").filter(Boolean); // 去掉空段
|
||||
|
||||
const knownApiPaths = ["api", "v1", "v2", "search", "match", "bangumi", "comment"];
|
||||
const knownApiPaths = ["api", "v1", "v2", "search", "match", "bangumi", "comment", "danmaku"];
|
||||
|
||||
const firstPart = parts[0] || "";
|
||||
const isDefaultToken = globals.token === "87654321";
|
||||
@@ -103,6 +104,8 @@ async function handleRequest(req, env, deployPlatform, clientIp, ctx) {
|
||||
'/api/v2/search/anime',
|
||||
'/api/v2/match',
|
||||
'/api/v2/search/episodes',
|
||||
'/api/v2/fongmi/danmaku',
|
||||
'/danmaku',
|
||||
'/api/v2/bangumi',
|
||||
'/api/v2/comment',
|
||||
'/api/v2/segmentcomment'
|
||||
@@ -231,6 +234,13 @@ async function handleRequest(req, env, deployPlatform, clientIp, ctx) {
|
||||
path = "/" + parts.slice(1).join("/");
|
||||
}
|
||||
|
||||
// 兼容部分客户端将自定义弹幕短地址再次拼接官方完整路径的情况
|
||||
// 例如: /danmaku/api/v2/fongmi/danmaku?name=...&episode=...
|
||||
if (path.endsWith("/danmaku/api/v2/fongmi/danmaku")) {
|
||||
log("info", `[Path Fix] Collapsed nested danmaku path: "${path}" -> "/danmaku"`);
|
||||
path = "/danmaku";
|
||||
}
|
||||
|
||||
// GET /api/config - 获取配置信息 (需要 token)
|
||||
if (path === "/api/config" && method === "GET") {
|
||||
return handleConfig(true); // 有权限
|
||||
@@ -244,7 +254,7 @@ async function handleRequest(req, env, deployPlatform, clientIp, ctx) {
|
||||
log("info", path);
|
||||
|
||||
// 智能处理API路径前缀,确保最终有一个正确的 /api/v2
|
||||
if (path !== "/" && path !== "/api/logs" && !path.startsWith('/api/env')
|
||||
if (path !== "/" && path !== "/danmaku" && path !== "/api/logs" && !path.startsWith('/api/env')
|
||||
&& !path.startsWith('/api/deploy') && !path.startsWith('/api/cache')
|
||||
&& !path.startsWith('/api/cookie') && !path.startsWith('/api/config')
|
||||
&& !path.startsWith('/api/ai')) {
|
||||
@@ -307,6 +317,16 @@ async function handleRequest(req, env, deployPlatform, clientIp, ctx) {
|
||||
return searchEpisodes(url);
|
||||
}
|
||||
|
||||
// GET|POST /api/v2/fongmi/danmaku
|
||||
if (path === "/api/v2/fongmi/danmaku" && (method === "GET" || method === "POST")) {
|
||||
return getFongmiDanmaku(url, req);
|
||||
}
|
||||
|
||||
// GET|POST /danmaku
|
||||
if (path === "/danmaku" && (method === "GET" || method === "POST")) {
|
||||
return getFongmiDanmaku(url, req);
|
||||
}
|
||||
|
||||
// GET /api/v2/match
|
||||
if (path === "/api/v2/match" && method === "POST") {
|
||||
return matchAnime(url, req, clientIp);
|
||||
|
||||
Reference in new issue
Block a user