feat(identity): 豆瓣 / bgm.tv 辅助信息源与发现页

- adapters/meta:bgm.tv 官方 API(条目 / 搜索 / 每日放送)与豆瓣移动版接口(条目 / 实时热门)
- 识别升级链:豆瓣条目缓存没有时在线查;AniBT 的 bgm ID、公开 BT 站标题搜 bgm → 名称 + 年份匹配 TMDB(季条目去季名与年份)
- 条目缓存 30 天,查不到 6 小时负缓存;测试进程缺省不出外网
- 发现页:bgm 每日放送、豆瓣热门电影 / 剧集,点条目匹配 TMDB 作品

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-02 21:30:16 +08:00
1 parent b8876542b8
commit 709414778e
23 files changed
+958 -31

No files matched your search

+225
View File
@@ -0,0 +1,225 @@
/**
* 辅助信息源(对照 MP modules/bangumi、modules/douban):只读公开接口,结果只用来"拿名称 + 年份去匹配 TMDB"与发现页展示,
* 身份仍以 TMDB 为准。
* - bgm.tv:官方 API(api.bgm.tv),条目 / 搜索 / 每日放送,不需要登录;要求带可识别的 User-Agent
* - 豆瓣:移动版 rexxar 接口(m.douban.com,需要 Referer),条目详情与实时热门榜;搜索要登录,不用
*/
export interface MetaSubject {
source: "bangumi" | "douban";
id: string;
/** 显示名(中文名优先) */
title: string;
/** 原名 / 别名 / 又名(不含 title) */
aliases: string[];
year: number | null;
/** 电影 / 剧集;bgm 动画条目按剧集,剧场版(platform 剧场版 / 电影)按电影 */
kind: "movie" | "tv" | null;
cover: string | null;
rating: number | null;
/** 首播 / 上映日期 YYYY-MM-DD */
date: string | null;
episodes: number | null;
url: string;
}
export interface MetaClientOptions {
fetch?: typeof globalThis.fetch;
/** 缺省 15s */
timeoutMs?: number;
baseUrl?: string;
}
export class MetaSourceError extends Error {
constructor(
readonly source: "bangumi" | "douban",
readonly kind: "not_found" | "http" | "parse",
message: string,
) {
super(message);
this.name = "MetaSourceError";
}
}
type Raw = Record<string, unknown>;
const isObj = (v: unknown): v is Raw => !!v && typeof v === "object" && !Array.isArray(v);
const str = (v: unknown) => (typeof v === "string" && v.trim() ? v.trim() : null);
const numOf = (v: unknown) => {
const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : Number.NaN;
return Number.isFinite(n) ? n : null;
};
const yearOf = (v: unknown) => {
const y = Number(/(?:19|20)\d{2}/.exec(String(v ?? ""))?.[0]);
return y >= 1900 && y <= 2100 ? y : null;
};
const https = (u: string | null) => (u ? u.replace(/^http:\/\//, "https://") : null);
const uniq = (xs: (string | null | undefined)[], not: string) => [
...new Set(xs.filter((x): x is string => !!x && x !== not && x.length <= 120)),
];
async function getJson(
source: "bangumi" | "douban",
url: string,
o: MetaClientOptions,
init: RequestInit = {},
): Promise<unknown> {
const f = o.fetch ?? globalThis.fetch;
let res: Response;
try {
res = await f(url, { ...init, signal: AbortSignal.timeout(o.timeoutMs ?? 15_000) });
} catch (e) {
throw new MetaSourceError(source, "http", `请求 ${new URL(url).host} 失败:${(e as Error).message}`);
}
if (res.status === 404) throw new MetaSourceError(source, "not_found", "条目不存在");
if (!res.ok) throw new MetaSourceError(source, "http", `${new URL(url).host} 返回 HTTP ${res.status}`);
try {
return await res.json();
} catch {
throw new MetaSourceError(source, "parse", `${new URL(url).host} 返回的不是 JSON`);
}
}
// ── bgm.tv ──
export const BANGUMI_API = "https://api.bgm.tv";
const BGM_UA = "uhd-media-tool (https://github.com/uhd-media-tool)";
/** bgm 条目(v0 subject、搜索结果、每日放送条目通用) → MetaSubject */
export function bangumiSubject(r: Raw): MetaSubject | null {
const id = numOf(r.id);
const name = str(r.name);
const cn = str(r.name_cn);
const title = cn ?? name;
if (!id || !title) return null;
const infobox = Array.isArray(r.infobox) ? (r.infobox as Raw[]) : [];
const alias = infobox
.filter((x) => x.key === "别名" || x.key === "中文名")
.flatMap((x) => (Array.isArray(x.value) ? (x.value as Raw[]).map((v) => str(v.v)) : [str(x.value)]));
const date = str(r.date) ?? str(r.air_date);
const platform = str(r.platform) ?? "";
const images = isObj(r.images) ? r.images : {};
const rating = isObj(r.rating) ? numOf(r.rating.score) : null;
return {
source: "bangumi",
id: String(id),
title,
aliases: uniq([name, ...alias], title).slice(0, 10),
year: yearOf(date),
kind: /剧场|电影|movie/i.test(platform) ? "movie" : "tv",
cover: https(str(images.large) ?? str(images.common)),
rating: rating || null,
date: date && /^\d{4}-\d{2}-\d{2}$/.test(date) && date !== "0000-00-00" ? date : null,
episodes: numOf(r.total_episodes) || numOf(r.eps) || numOf(r.eps_count) || null,
url: `https://bgm.tv/subject/${id}`,
};
}
export function createBangumiClient(o: MetaClientOptions = {}) {
const base = (o.baseUrl ?? BANGUMI_API).replace(/\/+$/, "");
const headers = { "User-Agent": BGM_UA, Accept: "application/json" };
return {
async subject(id: string): Promise<MetaSubject> {
if (!/^\d+$/.test(id)) throw new MetaSourceError("bangumi", "not_found", "bgm 条目 ID 非法");
const r = await getJson("bangumi", `${base}/v0/subjects/${id}`, o, { headers });
const s = isObj(r) ? bangumiSubject(r) : null;
if (!s) throw new MetaSourceError("bangumi", "parse", "bgm 条目格式不对");
return s;
},
/** 动画条目搜索(type 2) */
async search(keyword: string, limit = 10): Promise<MetaSubject[]> {
const r = await getJson("bangumi", `${base}/v0/search/subjects?limit=${limit}`, o, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ keyword, filter: { type: [2] } }),
});
const rows = isObj(r) && Array.isArray(r.data) ? (r.data as Raw[]) : [];
return rows.flatMap((x) => bangumiSubject(x) ?? []);
},
/** 每日放送:周一到周日(weekday 1-7) */
async calendar(): Promise<{ weekday: number; label: string; items: MetaSubject[] }[]> {
const r = await getJson("bangumi", `${base}/calendar`, o, { headers });
if (!Array.isArray(r)) throw new MetaSourceError("bangumi", "parse", "bgm 每日放送格式不对");
return (r as Raw[]).map((d) => {
const wd = isObj(d.weekday) ? d.weekday : {};
return {
weekday: numOf(wd.id) ?? 0,
label: str(wd.cn) ?? "",
items: (Array.isArray(d.items) ? (d.items as Raw[]) : []).flatMap((x) => bangumiSubject(x) ?? []),
};
});
},
};
}
export type BangumiClient = ReturnType<typeof createBangumiClient>;
// ── 豆瓣 ──
export const DOUBAN_API = "https://m.douban.com/rexxar/api/v2";
const DOUBAN_HEADERS = {
Referer: "https://m.douban.com/",
"User-Agent":
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1",
Accept: "application/json",
};
/** 豆瓣条目(详情或榜单条目) → MetaSubject */
export function doubanSubject(r: Raw): MetaSubject | null {
const id = str(r.id);
const title = str(r.title)?.replace(/‎/g, "").trim();
if (!id || !/^\d+$/.test(id) || !title) return null;
const pic = isObj(r.pic) ? r.pic : {};
const rating = isObj(r.rating) ? numOf(r.rating.value) : null;
const pub = Array.isArray(r.pubdate) ? str(r.pubdate[0]) : null;
const date = /\d{4}-\d{2}-\d{2}/.exec(pub ?? "")?.[0] ?? null;
const type = str(r.type) ?? str(r.subtype);
return {
source: "douban",
id,
title,
aliases: uniq([str(r.original_title), ...(Array.isArray(r.aka) ? r.aka.map(str) : [])], title).slice(0, 10),
year: yearOf(r.year) ?? yearOf(date),
kind: type === "tv" || r.is_tv === true ? "tv" : type === "movie" ? "movie" : null,
cover: str(pic.large) ?? str(pic.normal),
rating: rating || null,
date,
episodes: numOf(r.episodes_count) || null,
url: `https://movie.douban.com/subject/${id}/`,
};
}
export const DOUBAN_HOT = {
movie: "movie_real_time_hotest",
tv: "tv_real_time_hotest",
} as const;
export function createDoubanClient(o: MetaClientOptions = {}) {
const base = (o.baseUrl ?? DOUBAN_API).replace(/\/+$/, "");
const get = (path: string) => getJson("douban", `${base}${path}`, o, { headers: DOUBAN_HEADERS });
return {
/** 条目详情:类型未知时先按电影查,不存在再按剧集查 */
async subject(id: string, kind?: "movie" | "tv" | null): Promise<MetaSubject> {
if (!/^\d+$/.test(id)) throw new MetaSourceError("douban", "not_found", "豆瓣条目 ID 非法");
const order: ("movie" | "tv")[] = kind === "tv" ? ["tv", "movie"] : ["movie", "tv"];
let last: unknown = null;
for (const k of order) {
try {
const r = await get(`/${k}/${id}`);
const s = isObj(r) ? doubanSubject(r) : null;
if (s) return s;
} catch (e) {
last = e;
if (!(e instanceof MetaSourceError) || e.kind === "http") throw e;
}
}
throw last instanceof MetaSourceError ? last : new MetaSourceError("douban", "not_found", "豆瓣条目不存在");
},
/** 实时热门榜(电影 / 剧集) */
async hot(kind: "movie" | "tv", count = 30): Promise<MetaSubject[]> {
const r = await get(`/subject_collection/${DOUBAN_HOT[kind]}/items?start=0&count=${count}`);
const rows = isObj(r) && Array.isArray(r.subject_collection_items) ? (r.subject_collection_items as Raw[]) : null;
if (!rows) throw new MetaSourceError("douban", "parse", "豆瓣榜单格式不对");
return rows.flatMap((x) => doubanSubject(x) ?? []);
},
};
}
export type DoubanClient = ReturnType<typeof createDoubanClient>;