504 lines
21 KiB
TypeScript
504 lines
21 KiB
TypeScript
/**
|
||
* 辅助信息源(对照 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>;
|