/** * 辅助信息源(对照 MP modules/bangumi、modules/douban):只读公开接口,结果只用来"拿名称 + 年份去匹配 TMDB"与发现页展示, * 身份仍以 TMDB 为准。 * - bgm.tv:官方 API(api.bgm.tv),条目 / 搜索 / 每日放送,不需要登录;要求带可识别的 User-Agent * - 豆瓣:移动版 rexxar 接口(m.douban.com,需要 Referer):条目详情、榜单(subject_collection)、筛选(movie|tv/recommend); * 桌面版 JSON(movie.douban.com/j/…):标签列表(search_subjects)、搜索联想(subject_suggest)。都不需要登录,HTTP 直连。 * 发现页(2026-10-06):列表 / 搜索请求经 MetaRequestCache(服务端 PG 请求缓存,对照 MP DoubanApi.__invoke / Bangumi 的 @cached) * 与限流器;条目详情不走请求缓存(服务端已有条目级缓存 douban.subject / bangumi.subject,避免同一份数据存两遍)。 */ import { load } from "cheerio"; 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; /** 风格 / 类型(豆瓣 card_subtitle 第三段;没有为空) */ genres?: string[]; } /** 请求类别:决定缓存 TTL(搜索 / 列表);条目详情不走请求缓存 */ export type MetaRequestKind = "search" | "list"; /** * 请求缓存端口(服务端用 PG kv_cache 实现,region douban.request / bangumi.request): * 命中直接返回;否则执行 load,成功的结果按类别 TTL 写入,empty(v) 为真时用空结果短 TTL;load 抛错(网络失败、403、风控页)不写。 */ export interface MetaRequestCache { cached( req: { source: "bangumi" | "douban"; key: string; kind: MetaRequestKind; empty: (v: T) => boolean }, load: () => Promise, ): Promise; } export interface MetaClientOptions { fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; /** 缺省 15s */ timeoutMs?: number; /** API 地址(bgm api.bgm.tv / 豆瓣 rexxar) */ baseUrl?: string; /** 网页地址(bgm.tv / movie.douban.com) */ webBaseUrl?: string; /** 请求缓存;不给就不缓存 */ cache?: MetaRequestCache; /** 限流(每源最小间隔);只包住真正发出的网络请求,缓存命中不占额度 */ limiter?: { run(fn: () => Promise): Promise }; } 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; 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 fetchOk( source: "bangumi" | "douban", url: string, o: MetaClientOptions, init: RequestInit, ): Promise { const f = o.fetch ?? globalThis.fetch; const run = () => f(url, { ...init, signal: AbortSignal.timeout(o.timeoutMs ?? 15_000) }); let res: Response; try { res = await (o.limiter ? o.limiter.run(run) : run()); } catch (e) { throw new MetaSourceError(source, "http", `请求 ${new URL(url).host} 失败:${(e as Error).message}`); } if (res.status === 404) { await res.body?.cancel(); throw new MetaSourceError(source, "not_found", "条目不存在"); } if (!res.ok) { await res.body?.cancel(); throw new MetaSourceError(source, "http", `${new URL(url).host} 返回 HTTP ${res.status}`); } return res; } async function getJson( source: "bangumi" | "douban", url: string, o: MetaClientOptions, init: RequestInit = {}, ): Promise { const res = await fetchOk(source, url, o, init); try { return await res.json(); } catch { // 风控 / 验证页是 HTML:按解析失败抛出,不缓存 throw new MetaSourceError(source, "parse", `${new URL(url).host} 返回的不是 JSON`); } } /** 请求缓存键:方法 + 参数排序后的地址 + 请求体 */ export function metaRequestKey(method: string, url: string, body?: string | null): string { const u = new URL(url); const qs = new URLSearchParams([...u.searchParams].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); const q = qs.toString(); return `${method.toUpperCase()} ${u.origin}${u.pathname}${q ? `?${q}` : ""}${body ? ` ${body}` : ""}`; } function viaCache( o: MetaClientOptions, source: "bangumi" | "douban", kind: MetaRequestKind, key: string, empty: (v: T) => boolean, load: () => Promise, ): Promise { return o.cache ? o.cache.cached({ source, key, kind, empty }, load) : load(); } const emptyRows = (field: string) => (v: unknown) => { const rows = isObj(v) ? v[field] : v; return Array.isArray(rows) && rows.length === 0; }; // ── 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 const BANGUMI_WEB = "https://bgm.tv"; /** bgm.tv 网页日期「2023年9月29日」/「2023-09-29」→ YYYY-MM-DD */ function bgmDate(text: string): string | null { const m = /((?:19|20)\d{2})[年-](\d{1,2})[月-](\d{1,2})/.exec(text); return m ? `${m[1]}-${m[2]!.padStart(2, "0")}-${m[3]!.padStart(2, "0")}` : null; } /** * bgm.tv 动画浏览页(/anime/browser?sort=trends,服务端渲染 HTML)→ 条目列表。外部 HTML 不可信: * 服务端经沙箱任务 bangumi.trends 调用(ADR-005),这里只是纯函数。 */ export function parseBangumiBrowser(html: string, web = BANGUMI_WEB): { items: MetaSubject[]; hasMore: boolean } { const $ = load(html); const items: MetaSubject[] = []; $("#browserItemList > li.item").each((_, el) => { const li = $(el); const id = /(\d+)/.exec(li.attr("id") ?? "")?.[1] ?? /\/subject\/(\d+)/.exec(li.find("h3 a").attr("href") ?? "")?.[1]; const title = li.find("h3 a.l").first().text().trim(); if (!id || !title) return; const original = li.find("h3 small.grey").first().text().trim(); const info = li.find("p.info").first().text().replace(/\s+/g, " ").trim(); const date = bgmDate(info); const eps = numOf(/(\d+)\s*话/.exec(info)?.[1]); const rating = numOf(li.find("p.rateInfo small.fade").first().text().trim()); let cover = str(li.find("img.cover").first().attr("src")); if (cover?.startsWith("//")) cover = `https:${cover}`; items.push({ source: "bangumi", id, title, aliases: original && original !== title ? [original] : [], year: yearOf(date ?? info), kind: /剧场|电影/.test(info) ? "movie" : "tv", cover: https(cover), rating: rating || null, date, episodes: eps || null, url: `${web}/subject/${id}`, }); }); const hasMore = $("#multipage a.p") .toArray() .some((a) => $(a).text().includes("››") || $(a).text().includes("\u203a\u203a")); return { items, hasMore }; } export function createBangumiClient(o: MetaClientOptions = {}) { const base = (o.baseUrl ?? BANGUMI_API).replace(/\/+$/, ""); const web = (o.webBaseUrl ?? BANGUMI_WEB).replace(/\/+$/, ""); const headers = { "User-Agent": BGM_UA, Accept: "application/json" }; const searchPage = async (keyword: string, limit = 10, offset = 0) => { const url = `${base}/v0/search/subjects?limit=${limit}&offset=${offset}`; const body = JSON.stringify({ keyword, filter: { type: [2] } }); const r = await viaCache(o, "bangumi", "search", metaRequestKey("POST", url, body), emptyRows("data"), () => getJson("bangumi", url, o, { method: "POST", headers: { ...headers, "Content-Type": "application/json" }, body, }), ); const rows = isObj(r) && Array.isArray(r.data) ? (r.data as Raw[]) : []; return { items: rows.flatMap((x) => bangumiSubject(x) ?? []), total: isObj(r) ? (numOf(r.total) ?? 0) : 0 }; }; return { async subject(id: string): Promise { 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 { return (await searchPage(keyword, limit)).items; }, /** 动画条目搜索,带分页与总数(发现页) */ searchPage, /** 每日放送:周一到周日(weekday 1-7) */ async calendar(): Promise<{ weekday: number; label: string; items: MetaSubject[] }[]> { const url = `${base}/calendar`; const r = await viaCache(o, "bangumi", "list", metaRequestKey("GET", url), emptyRows(""), () => getJson("bangumi", url, 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) ?? []), }; }); }, /** 动画浏览(对照 MP bangumi discover:v0/subjects?type=2&sort=rank|date&year=) */ async browse(q: { sort: "rank" | "date"; year?: string; limit: number; offset: number }) { const qs = new URLSearchParams({ type: "2", sort: q.sort, limit: String(q.limit), offset: String(q.offset) }); if (q.year) qs.set("year", q.year); const url = `${base}/v0/subjects?${qs}`; const r = await viaCache(o, "bangumi", "list", metaRequestKey("GET", url), emptyRows("data"), () => getJson("bangumi", url, o, { headers }), ); if (!isObj(r) || !Array.isArray(r.data)) throw new MetaSourceError("bangumi", "parse", "bgm 浏览结果格式不对"); return { items: (r.data as Raw[]).flatMap((x) => bangumiSubject(x) ?? []), total: numOf(r.total) ?? 0 }; }, /** 热门趋势网页(无 API):返回原始 HTML,由调用方在沙箱里用 parseBangumiBrowser 解析 */ async trendsHtml(page = 1): Promise { const url = `${web}/anime/browser?sort=trends&page=${page}`; return viaCache( o, "bangumi", "list", metaRequestKey("GET", url), () => false, async () => { const res = await fetchOk("bangumi", url, o, { headers: { "User-Agent": BGM_UA, Accept: "text/html" } }); const text = await res.text(); if (!text.includes("browserItemList")) throw new MetaSourceError("bangumi", "parse", "bgm 浏览页格式不对"); return text; }, ); }, }; } export type BangumiClient = ReturnType; // ── 豆瓣 ── 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); // card_subtitle:「2024 / 美国 / 剧情 科幻 / 导演 / 主演」 const card = (str(r.card_subtitle) ?? "").split(" / "); const genres = card.length >= 3 && /^\d{4}$/.test(card[0]!.trim()) ? card[2]!.trim().split(/\s+/).filter(Boolean) : []; const cover = str(pic.large) ?? str(pic.normal) ?? str(r.cover_url); 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: placeholderCover(cover) ? null : cover, rating: rating || null, date, episodes: numOf(r.episodes_count) || null, url: `https://movie.douban.com/subject/${id}/`, genres: genres.slice(0, 4), }; } /** 豆瓣占位海报(对照 MP _project_discover 的 invalid_posters) */ const placeholderCover = (u: string | null) => !!u && ["movie_large.jpg", "tv_normal.png", "tv_normal.jpg", "tv_large.jpg"].some((m) => u.includes(m)); /** 桌面版 search_subjects 条目({id, title, rate, cover, url, episodes_info}) → MetaSubject */ export function doubanTagSubject(r: Raw, kind: "movie" | "tv"): MetaSubject | null { const id = str(r.id); const title = str(r.title); if (!id || !/^\d+$/.test(id) || !title) return null; const cover = str(r.cover); return { source: "douban", id, title, aliases: [], year: null, kind, cover: placeholderCover(cover) ? null : cover, rating: numOf(r.rate) || null, date: null, episodes: null, url: `https://movie.douban.com/subject/${id}/`, genres: [], }; } /** 搜索联想 subject_suggest 条目({id, title, sub_title, img, year, episode, type}) → MetaSubject;人物等返回 null */ export function doubanSuggestSubject(r: Raw): MetaSubject | null { if (str(r.type) !== "movie") return null; const id = str(r.id); const title = str(r.title); if (!id || !/^\d+$/.test(id) || !title) return null; const sub = str(r.sub_title); return { source: "douban", id, title, aliases: sub && sub !== title ? [sub] : [], year: yearOf(r.year), kind: str(r.episode) ? "tv" : "movie", cover: str(r.img), rating: null, date: null, episodes: numOf(r.episode) || null, url: `https://movie.douban.com/subject/${id}/`, genres: [], }; } export const DOUBAN_HOT = { movie: "movie_real_time_hotest", tv: "tv_real_time_hotest", } as const; export const DOUBAN_WEB = "https://movie.douban.com"; const DOUBAN_WEB_HEADERS = { Referer: "https://movie.douban.com/", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36", Accept: "application/json", }; export function createDoubanClient(o: MetaClientOptions = {}) { const base = (o.baseUrl ?? DOUBAN_API).replace(/\/+$/, ""); const web = (o.webBaseUrl ?? DOUBAN_WEB).replace(/\/+$/, ""); const get = (path: string) => getJson("douban", `${base}${path}`, o, { headers: DOUBAN_HEADERS }); const cachedGet = (kind: MetaRequestKind, url: string, field: string, headers: Record) => viaCache(o, "douban", kind, metaRequestKey("GET", url), emptyRows(field), () => getJson("douban", url, o, { headers }), ); const listOf = (r: unknown, field: string, what: string) => { const rows = isObj(r) && Array.isArray(r[field]) ? (r[field] as Raw[]) : null; if (!rows) throw new MetaSourceError("douban", "parse", `豆瓣${what}格式不对`); return { rows, total: isObj(r) ? numOf(r.total) : null }; }; const client = { /** 条目详情:类型未知时先按电影查,不存在再按剧集查 */ async subject(id: string, kind?: "movie" | "tv" | null): Promise { 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", "豆瓣条目不存在"); }, /** 榜单(rexxar subject_collection):实时热门、正在热映、Top250、各类剧集榜… */ async collection(name: string, start = 0, count = 20): Promise<{ items: MetaSubject[]; total: number | null }> { if (!/^[a-z0-9_]+$/.test(name)) throw new MetaSourceError("douban", "not_found", "豆瓣榜单名非法"); const url = `${base}/subject_collection/${name}/items?start=${start}&count=${count}`; const { rows, total } = listOf( await cachedGet("list", url, "subject_collection_items", DOUBAN_HEADERS), "subject_collection_items", "榜单", ); return { items: rows.flatMap((x) => doubanSubject(x) ?? []), total }; }, /** 实时热门榜(电影 / 剧集) */ async hot(kind: "movie" | "tv", count = 30): Promise { return (await client.collection(DOUBAN_HOT[kind], 0, count)).items; }, /** 标签列表(桌面版 j/search_subjects,tag 如 热门 / 最新 / 豆瓣高分 / 美剧) */ async tagSubjects(kind: "movie" | "tv", tag: string, start = 0, count = 20): Promise { const qs = new URLSearchParams({ type: kind, tag, sort: "recommend", page_limit: String(count), page_start: String(start), }); const url = `${web}/j/search_subjects?${qs}`; const { rows } = listOf(await cachedGet("list", url, "subjects", DOUBAN_WEB_HEADERS), "subjects", "标签列表"); return rows.flatMap((x) => doubanTagSubject(x, kind) ?? []); }, /** 筛选(rexxar movie|tv/recommend,对照 MP douban_discover:sort U/R/T/S,tags 为 风格,地区,年代) */ async explore( kind: "movie" | "tv", q: { sort: string; tags: string[] }, start = 0, count = 20, ): Promise<{ items: MetaSubject[]; total: number | null }> { const qs = new URLSearchParams({ refresh: "0", start: String(start), count: String(count), sort: q.sort, tags: q.tags.join(","), }); const url = `${base}/${kind}/recommend?${qs}`; const { rows, total } = listOf(await cachedGet("list", url, "items", DOUBAN_HEADERS), "items", "筛选结果"); return { items: rows.flatMap((x) => doubanSubject(x) ?? []), total }; }, /** 搜索联想(桌面版 j/subject_suggest,不分页,最多约 10 条) */ async suggest(q: string): Promise { const url = `${web}/j/subject_suggest?${new URLSearchParams({ q: q.trim() })}`; const r = await cachedGet("search", url, "", DOUBAN_WEB_HEADERS); if (!Array.isArray(r)) throw new MetaSourceError("douban", "parse", "豆瓣搜索结果格式不对"); return (r as Raw[]).flatMap((x) => doubanSuggestSubject(x) ?? []); }, }; return client; } export type DoubanClient = ReturnType;