Files
UHD_Media_Tool/packages/adapters/src/meta/index.ts
T

504 lines
21 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 辅助信息源(对照 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<T>(
req: { source: "bangumi" | "douban"; key: string; kind: MetaRequestKind; empty: (v: T) => boolean },
load: () => Promise<T>,
): Promise<T>;
}
export interface MetaClientOptions {
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
/** 缺省 15s */
timeoutMs?: number;
/** API 地址(bgm api.bgm.tv / 豆瓣 rexxar) */
baseUrl?: string;
/** 网页地址(bgm.tv / movie.douban.com) */
webBaseUrl?: string;
/** 请求缓存;不给就不缓存 */
cache?: MetaRequestCache;
/** 限流(每源最小间隔);只包住真正发出的网络请求,缓存命中不占额度 */
limiter?: { run<T>(fn: () => Promise<T>): Promise<T> };
}
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 fetchOk(
source: "bangumi" | "douban",
url: string,
o: MetaClientOptions,
init: RequestInit,
): Promise<Response> {
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<unknown> {
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<T>(
o: MetaClientOptions,
source: "bangumi" | "douban",
kind: MetaRequestKind,
key: string,
empty: (v: T) => boolean,
load: () => Promise<T>,
): Promise<T> {
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<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[]> {
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<string> {
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<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);
// 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<string, string>) =>
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<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", "豆瓣条目不存在");
},
/** 榜单(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<MetaSubject[]> {
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<MetaSubject[]> {
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<MetaSubject[]> {
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<typeof createDoubanClient>;