diff --git a/apps/server/src/modules/sources-identity/routes/enrich.ts b/apps/server/src/modules/sources-identity/routes/enrich.ts index 73803bf2..cefdb7cf 100644 --- a/apps/server/src/modules/sources-identity/routes/enrich.ts +++ b/apps/server/src/modules/sources-identity/routes/enrich.ts @@ -1,4 +1,3 @@ -import { assertPublicUrl } from "@umt/adapters"; import { isCapabilityError } from "@umt/agent"; import { EnrichApplyInput, @@ -14,6 +13,7 @@ import type { ModuleApp } from "../../types.ts"; import { route } from "../http.ts"; import type { SourcesRuntime } from "../runtime.ts"; import { applyEnrichment, domainAllowed, hostOf } from "../service/enrich.ts"; +import { fetchPublicImage, imageResponse } from "../service/image-proxy.ts"; // META-1 元数据补全 API:先看建议(POST /identity-pool/{id}/enrich,经能力注册中心调用 metadata.enrich, // 授权 / 审计与 Agent 同一路径),再应用(POST /identity-pool/{id}/enrich/apply,只接受控制台管理员会话); @@ -22,19 +22,6 @@ import { applyEnrichment, domainAllowed, hostOf } from "../service/enrich.ts"; const T_ID = ["identity"]; const POSTER_MAX_BYTES = 5 * 1024 * 1024; -function imageMime(b: Uint8Array): string | null { - if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg"; - if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return "image/png"; - if ( - b.length >= 12 && - String.fromCharCode(...b.slice(0, 4)) === "RIFF" && - String.fromCharCode(...b.slice(8, 12)) === "WEBP" - ) - return "image/webp"; - if (b.length >= 6 && String.fromCharCode(...b.slice(0, 4)) === "GIF8") return "image/gif"; - return null; -} - export function enrichRoutes(app: ModuleApp, rt: () => SourcesRuntime): void { route( app, @@ -119,44 +106,8 @@ export function enrichRoutes(app: ModuleApp, rt: () => SourcesRuntime): void { const srcHost = hostOf(s.data.fields.posterUrl?.sourceUrl ?? ""); if (!srcHost || !domainAllowed(srcHost, identity.enrichDomains ?? [])) return c.json(errorBody("forbidden", "海报出处不在补全白名单里"), 403); - try { - const doFetch = r.overrides.fetch ?? fetch; - let target = await assertPublicUrl(url); - let res: Response | null = null; - for (let hop = 0; hop <= 3; hop++) { - res = await doFetch(target.href, { - redirect: "manual", - signal: AbortSignal.timeout(15_000), - headers: { accept: "image/avif,image/webp,image/*;q=0.8" }, - }); - const loc = res.status >= 300 && res.status < 400 ? res.headers.get("location") : null; - if (!loc) break; - await res.body?.cancel().catch(() => {}); - if (hop === 3) throw new Error("重定向次数过多"); - target = await assertPublicUrl(new URL(loc, target).href); - } - if (res?.status !== 200) { - await res?.body?.cancel().catch(() => {}); - return c.json(errorBody("upstream_unavailable", `海报返回 HTTP ${res?.status ?? "?"}`), 502); - } - const len = Number(res.headers.get("content-length") ?? 0); - if (len > POSTER_MAX_BYTES) { - await res.body?.cancel().catch(() => {}); - return c.json(errorBody("upstream_unavailable", "海报超过 5 MB"), 502); - } - const buf = new Uint8Array(await res.arrayBuffer()); - const mime = buf.byteLength <= POSTER_MAX_BYTES ? imageMime(buf) : null; - if (!mime) return c.json(errorBody("upstream_unavailable", "海报地址返回的不是图片"), 502); - return new Response(buf as BodyInit, { - status: 200, - headers: { - "content-type": mime, - "cache-control": `public, max-age=${86400 * 7}`, - "x-content-type-options": "nosniff", - }, - }); - } catch (e) { - return c.json(errorBody("upstream_unavailable", `拉取海报失败:${(e as Error).message}`), 502); - } + const got = await fetchPublicImage(r.overrides.fetch ?? fetch, url, { maxBytes: POSTER_MAX_BYTES, label: "海报" }); + if (!got.ok) return c.json(errorBody("upstream_unavailable", got.message), 502); + return imageResponse(got, 86400 * 7); }); } diff --git a/apps/server/src/modules/sources-identity/routes/workbench.ts b/apps/server/src/modules/sources-identity/routes/workbench.ts index ac471058..d9156514 100644 --- a/apps/server/src/modules/sources-identity/routes/workbench.ts +++ b/apps/server/src/modules/sources-identity/routes/workbench.ts @@ -1,14 +1,19 @@ import { Id, WorkbenchReleaseDetail, WorkbenchReleasePage, WorkbenchReleaseQuery } from "@umt/contracts"; import { z } from "zod"; +import { requireScope } from "../../../http/auth.ts"; +import { errorBody } from "../../../http/errors.ts"; import type { ModuleApp } from "../../types.ts"; import { route } from "../http.ts"; import type { SourcesRuntime } from "../runtime.ts"; +import { detailScreenshotUrl } from "../service/detail-page.ts"; +import { fetchPublicImage, imageResponse } from "../service/image-proxy.ts"; import { listSiteReleases, releaseDetail } from "../service/workbench.ts"; // 站点工作台(整理视图)数据 API。库存叠加(已有资产/Slot/下载)不在这里:条目带 entity.id, // 前端按作品 ID 二次查询 W2-C 的媒体库端点。 const T = ["site-workbench"]; +const SHOT_MAX_BYTES = 8 * 1024 * 1024; export function workbenchRoutes(app: ModuleApp, rt: () => SourcesRuntime) { route( @@ -39,4 +44,19 @@ export function workbenchRoutes(app: ModuleApp, rt: () => SourcesRuntime) { }, async ({ params, query }) => releaseDetail(rt(), params.id, params.rid, query.refresh === "true"), ); + + // 详情页截图代理:不进 OpenAPI(二进制响应);只取该资源详情缓存里的第 index 张图,不接受任意 URL,不带站点凭据 + app.get("/img/site-detail/:rid/:index", requireScope("read"), async (c) => { + const rid = c.req.param("rid"); + const index = Number(c.req.param("index")); + if (!/^[0-9a-f-]{36}$/i.test(rid) || !Number.isInteger(index) || index < 0 || index > 99) { + return c.json(errorBody("bad_request", "截图地址无效"), 400); + } + const r = rt(); + const url = await detailScreenshotUrl(r, rid, index); + if (!url) return c.json(errorBody("not_found", "截图不存在或详情缓存已过期"), 404); + const got = await fetchPublicImage(r.overrides.fetch ?? fetch, url, { maxBytes: SHOT_MAX_BYTES, label: "截图" }); + if (!got.ok) return c.json(errorBody("upstream_unavailable", got.message), 502); + return imageResponse(got, 86_400); + }); } diff --git a/apps/server/src/modules/sources-identity/service/detail-page.ts b/apps/server/src/modules/sources-identity/service/detail-page.ts new file mode 100644 index 00000000..dadeb58f --- /dev/null +++ b/apps/server/src/modules/sources-identity/service/detail-page.ts @@ -0,0 +1,107 @@ +import type { SiteDetailPage } from "@umt/adapters"; +import type { SiteReleasePage } from "@umt/contracts"; +import { sql } from "@umt/db"; +import { sharedCache } from "../../../shared-cache.ts"; +import type { SourcesRuntime } from "../runtime.ts"; +import type { Exec } from "./common.ts"; + +// 站点详情页扩展内容(简介 / MediaInfo / 截图 / 外部 ID / 文件 / 标签 / 上传者): +// - 只在按需抓详情时得到(工作台打开详情、release.inspect 复核),放 PG kv_cache region site.detail, +// key = source_release.id,TTL = 站点详情缓存期(至少 24 小时);不进 source_release 列,过期即丢。 +// - 外部 ID(IMDb / 豆瓣 / TMDB)另写 source_release.raw._detail(列表快照 upsert 不覆盖它),供身份解析用。 +// - 截图对外只给代理路径 /api/v1/img/site-detail/{releaseId}/{index},原始地址留在服务端。 + +export const DETAIL_PAGE_REGION = "site.detail"; +const MIN_TTL_SEC = 86_400; +const MAX_ENTRIES = 5_000; + +interface StoredPage extends SiteDetailPage { + fetchedAt: string; +} + +export interface DetailIds { + imdbId?: string; + doubanId?: string; + tmdb?: { type: "movie" | "tv"; id: string }; +} + +export async function saveDetailPage( + rt: SourcesRuntime, + exec: Exec, + srId: string, + page: SiteDetailPage, + cacheSec: number, +): Promise { + const stored: StoredPage = { ...page, fetchedAt: rt.now().toISOString() }; + await sharedCache(rt.ctx) + .set(DETAIL_PAGE_REGION, srId, stored, { ttl: Math.max(cacheSec, MIN_TTL_SEC), maxsize: MAX_ENTRIES }) + .catch((e) => rt.ctx.logger.warn({ err: { message: (e as Error).message } }, "详情页缓存写入失败")); + const ids: DetailIds = { + ...(page.imdbId ? { imdbId: page.imdbId } : {}), + ...(page.doubanId ? { doubanId: page.doubanId } : {}), + ...(page.tmdb ? { tmdb: page.tmdb } : {}), + }; + await sql`update source_release set raw = raw || jsonb_build_object('_detail', ${JSON.stringify(ids)}::jsonb) + where id = ${srId}`.execute(exec); +} + +async function loadStored(rt: SourcesRuntime, srId: string): Promise { + try { + const v = (await sharedCache(rt.ctx).get(DETAIL_PAGE_REGION, srId)) as StoredPage | null; + return v && typeof v === "object" && typeof v.fetchedAt === "string" ? v : null; + } catch { + return null; + } +} + +export async function hasDetailPage(rt: SourcesRuntime, srId: string): Promise { + return (await loadStored(rt, srId)) !== null; +} + +/** 缓存里的截图原始地址(图片代理用) */ +export async function detailScreenshotUrl(rt: SourcesRuntime, srId: string, index: number): Promise { + return (await loadStored(rt, srId))?.screenshots[index] ?? null; +} + +export async function loadDetailPage(rt: SourcesRuntime, srId: string): Promise { + const p = await loadStored(rt, srId); + if (!p) return null; + return { + fetchedAt: p.fetchedAt, + descriptionText: p.descriptionText, + mediaInfo: p.mediaInfo, + screenshots: p.screenshots.map((u, i) => { + let host = ""; + try { + host = new URL(u).host; + } catch { + /* 已在解析时校验 */ + } + return { url: `/api/v1/img/site-detail/${srId}/${i}`, host }; + }), + imdbId: p.imdbId, + doubanId: p.doubanId, + tmdb: p.tmdb, + files: p.files, + tags: p.tags, + uploader: p.uploader, + }; +} + +/** source_release.raw 里站点给出的外部 ID(列表 _site + 详情 _detail) */ +export function siteExplicitIds( + raw: unknown, +): { provider: "imdb" | "douban" | "tmdb"; id: string; type?: "movie" | "tv" }[] { + const r = (raw ?? {}) as { _site?: { imdbId?: unknown; doubanId?: unknown }; _detail?: DetailIds }; + const out: { provider: "imdb" | "douban" | "tmdb"; id: string; type?: "movie" | "tv" }[] = []; + const add = (provider: "imdb" | "douban" | "tmdb", id: unknown, type?: "movie" | "tv") => { + if (typeof id !== "string" || !id || out.some((x) => x.provider === provider && x.id === id)) return; + out.push({ provider, id, ...(type ? { type } : {}) }); + }; + add("imdb", r._detail?.imdbId); + add("imdb", r._site?.imdbId); + add("douban", r._detail?.doubanId); + add("douban", r._site?.doubanId); + if (r._detail?.tmdb) add("tmdb", r._detail.tmdb.id, r._detail.tmdb.type); + return out; +} diff --git a/apps/server/src/modules/sources-identity/service/image-proxy.ts b/apps/server/src/modules/sources-identity/service/image-proxy.ts new file mode 100644 index 00000000..9866a466 --- /dev/null +++ b/apps/server/src/modules/sources-identity/service/image-proxy.ts @@ -0,0 +1,75 @@ +import { assertPublicUrl } from "@umt/adapters"; + +// 外站图片代理(补全海报、站点详情截图):只取公网地址(每跳重定向都复核),不带任何凭据, +// 按文件头认图片类型(不信任上游 content-type,不放行 SVG),限制大小。 + +export type ImageFetchResult = + | { ok: true; bytes: Uint8Array; mime: string } + | { ok: false; status: 404 | 502; message: string }; + +export function imageMime(b: Uint8Array): string | null { + if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg"; + if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return "image/png"; + if ( + b.length >= 12 && + String.fromCharCode(...b.slice(0, 4)) === "RIFF" && + String.fromCharCode(...b.slice(8, 12)) === "WEBP" + ) + return "image/webp"; + if (b.length >= 6 && String.fromCharCode(...b.slice(0, 4)) === "GIF8") return "image/gif"; + return null; +} + +export async function fetchPublicImage( + doFetch: typeof fetch, + url: string, + opts: { maxBytes: number; label: string; referer?: string }, +): Promise { + const { maxBytes, label } = opts; + try { + let target = await assertPublicUrl(url); + let res: Response | null = null; + for (let hop = 0; hop <= 3; hop++) { + res = await doFetch(target.href, { + redirect: "manual", + signal: AbortSignal.timeout(15_000), + headers: { + accept: "image/avif,image/webp,image/*;q=0.8", + ...(opts.referer ? { referer: opts.referer } : {}), + }, + }); + const loc = res.status >= 300 && res.status < 400 ? res.headers.get("location") : null; + if (!loc) break; + await res.body?.cancel().catch(() => {}); + if (hop === 3) throw new Error("重定向次数过多"); + target = await assertPublicUrl(new URL(loc, target).href); + } + if (res?.status !== 200) { + await res?.body?.cancel().catch(() => {}); + return { ok: false, status: 502, message: `${label}返回 HTTP ${res?.status ?? "?"}` }; + } + const len = Number(res.headers.get("content-length") ?? 0); + const mb = Math.round(maxBytes / 1024 / 1024); + if (len > maxBytes) { + await res.body?.cancel().catch(() => {}); + return { ok: false, status: 502, message: `${label}超过 ${mb} MB` }; + } + const buf = new Uint8Array(await res.arrayBuffer()); + const mime = buf.byteLength <= maxBytes ? imageMime(buf) : null; + if (!mime) return { ok: false, status: 502, message: `${label}地址返回的不是图片` }; + return { ok: true, bytes: buf, mime }; + } catch (e) { + return { ok: false, status: 502, message: `拉取${label}失败:${(e as Error).message}` }; + } +} + +export function imageResponse(r: { bytes: Uint8Array; mime: string }, maxAgeSec: number): Response { + return new Response(r.bytes as BodyInit, { + status: 200, + headers: { + "content-type": r.mime, + "cache-control": `private, max-age=${maxAgeSec}`, + "x-content-type-options": "nosniff", + }, + }); +} diff --git a/apps/server/src/modules/sources-identity/service/resolve.ts b/apps/server/src/modules/sources-identity/service/resolve.ts index ed5db6c7..64c1dc4f 100644 --- a/apps/server/src/modules/sources-identity/service/resolve.ts +++ b/apps/server/src/modules/sources-identity/service/resolve.ts @@ -23,6 +23,7 @@ import { import { jsonb } from "../rows.ts"; import type { SourcesRuntime } from "../runtime.ts"; import { type Actor, closeDecisions, type Exec, SYSTEM } from "./common.ts"; +import { siteExplicitIds } from "./detail-page.ts"; import { maybeAutoEnrich } from "./enrich.ts"; import { addAliasIfAbsent, @@ -113,6 +114,7 @@ export function tmdbToCandidate(m: MetadataCandidate): DomainCandidate { ...(m.year !== null ? { year: m.year } : {}), aliases, ...(seasonYears?.length ? { seasonYears } : {}), + ...(m.externalIds?.imdb ? { linkedExternalIds: [{ provider: "imdb", id: m.externalIds.imdb }] } : {}), }; } @@ -137,8 +139,7 @@ async function localCandidates( release: SourceReleaseRow, ): Promise { const ids = new Set(); - const site = ((release.raw as Record)?._site ?? {}) as { imdbId?: string }; - const explicit = [...parsed.explicitIds, ...(site.imdbId ? [{ provider: "imdb" as const, id: site.imdbId }] : [])]; + const explicit = [...parsed.explicitIds, ...siteExplicitIds(release.raw)]; for (const x of explicit) { const row = await exec .selectFrom("external_identity") @@ -190,10 +191,14 @@ async function tmdbCandidates( rt: SourcesRuntime, parsed: ParsedRelease, useCache: boolean, + siteIds: ReturnType = [], ): Promise<{ remote: MetadataCandidate[]; cacheHit: boolean; meta: RecognizeMeta | null }> { const tmdb = await rt.tmdb(); if (!tmdb) return { remote: [], cacheHit: false, meta: null }; const meta = recognizeMetaOf(parsed); + // 站点详情页给出的 TMDB / IMDb ID:按 ID 直取(TMDB 详情 / find),命中就不再按标题搜索 + const byId = await tmdbByExplicitIds(tmdb, siteIds); + if (byId.length) return { remote: byId, cacheHit: false, meta }; if (meta && useCache) { const hit = await loadRecognizeCache(rt, meta); if (hit) { @@ -214,6 +219,25 @@ async function tmdbCandidates( return { remote: [...seen.values()].slice(0, 20), cacheHit: false, meta }; } +async function tmdbByExplicitIds( + tmdb: NonNullable>>, + ids: ReturnType, +): Promise { + const tmdbId = ids.find((x) => x.provider === "tmdb" && x.type); + if (tmdbId?.type) { + try { + return [await tmdb.details(tmdbId.type, tmdbId.id)]; + } catch (e) { + if (!(e instanceof TmdbError) || e.kind !== "notfound") throw e; + } + } + const imdb = ids.find((x) => x.provider === "imdb"); + if (!imdb) return []; + const found = await tmdb.findByImdb(imdb.id); + for (const m of found) m.externalIds = { ...m.externalIds, imdb: imdb.id }; + return found; +} + async function loadRelease(rt: SourcesRuntime, input: ResolveInput) { const { db } = rt.ctx; const observationId = input.observationId ?? null; @@ -277,6 +301,12 @@ export async function resolveRelease(rt: SourcesRuntime, input: ResolveInput): P } const parsed = parseRelease(release.title, release.subtitle ? { subtitle: release.subtitle } : {}); const cols = toParsedColumns(parsed); + // 站点给出的外部 ID(列表 / 详情页的 IMDb、豆瓣、TMDB 链接)作为显式 ID 证据参与打分 + const siteIds = siteExplicitIds(release.raw); + for (const x of siteIds) { + if (!parsed.explicitIds.some((e) => e.provider === x.provider)) + parsed.explicitIds.push({ provider: x.provider, id: x.id }); + } const trigger: ResolveTrigger = input.trigger ?? (input.force ? "review" : "observed"); if (!parsed.title || parsed.auxiliaryOnly) { return toPool(rt, release, observationId, parsed, null, [], "unmatched", "unparseable_title", null, { @@ -297,7 +327,7 @@ export async function resolveRelease(rt: SourcesRuntime, input: ResolveInput): P let lookupError: string | null = null; let recognize: { cacheHit: boolean; meta: RecognizeMeta | null } = { cacheHit: false, meta: null }; try { - const r = await tmdbCandidates(rt, parsed, !input.force); + const r = await tmdbCandidates(rt, parsed, !input.force, siteIds); remote = r.remote; recognize = r; } catch (e) { diff --git a/apps/server/src/modules/sources-identity/service/snapshot.ts b/apps/server/src/modules/sources-identity/service/snapshot.ts index ab3c5046..9c434bf9 100644 --- a/apps/server/src/modules/sources-identity/service/snapshot.ts +++ b/apps/server/src/modules/sources-identity/service/snapshot.ts @@ -1,4 +1,4 @@ -import { SiteError, type SitePage, type SiteRelease } from "@umt/adapters"; +import { type SiteDetailPage, SiteError, type SitePage, type SiteRelease } from "@umt/adapters"; import type { SiteSnapshotKind } from "@umt/contracts"; import { type SiteRow, sql } from "@umt/db"; import { parseRelease } from "@umt/domain"; @@ -7,6 +7,7 @@ import { jsonb } from "../rows.ts"; import type { SourcesRuntime } from "../runtime.ts"; import { enqueueRelogin } from "./account.ts"; import { type Exec, openDecision } from "./common.ts"; +import { hasDetailPage, saveDetailPage } from "./detail-page.ts"; // 站点快照(周期列表)、补漏(Feed 疑似缺口,有页数预算)、实时补查(工作台搜索)。 // 所有请求经站点共享限流器;结果按 site+站点资源 ID upsert 到 source_release,并记录 site_snapshot 覆盖范围。 @@ -215,14 +216,22 @@ export async function refreshReleaseDetail(rt: SourcesRuntime, srId: string): Pr if (!rel) return "not_found"; const site = await db.selectFrom("site").selectAll().where("id", "=", rel.site_id).executeTakeFirst(); if (!site?.enabled) return "site_disabled"; - // 详情缓存:站点访问默认的缓存期内抓过就不再请求站点(列表快照会持续更新做种 / 促销) + // 详情缓存:站点访问默认的缓存期内抓过、且详情页扩展内容还在缓存里,就不再请求站点(列表快照会持续更新做种 / 促销) const { detailCacheSec } = await rt.siteAccess(site.key); - if (rel.detail_fetched_at && rt.now().getTime() - rel.detail_fetched_at.getTime() < detailCacheSec * 1000) { + if ( + rel.detail_fetched_at && + rt.now().getTime() - rel.detail_fetched_at.getTime() < detailCacheSec * 1000 && + (await hasDetailPage(rt, srId)) + ) { return null; } let detail: SiteRelease; + let page: SiteDetailPage | undefined; try { - detail = mergeDetailOverList(await rt.siteAdapter(site).detail(rel.site_release_id), rel); + const d = await rt.siteAdapter(site).detail(rel.site_release_id); + page = d.page; + const { page: _p, descriptionText: _d, ...plain } = d; + detail = mergeDetailOverList(plain, rel); } catch (e) { if (e instanceof SiteError) { if (e.isAuthError) await db.transaction().execute((trx) => onAuthError(rt, trx, site, e)); @@ -247,6 +256,7 @@ export async function refreshReleaseDetail(rt: SourcesRuntime, srId: string): Pr }) .where("id", "=", srId) .execute(); + if (page) await saveDetailPage(rt, trx, srId, page, detailCacheSec); }); return null; } diff --git a/apps/server/src/modules/sources-identity/service/workbench.ts b/apps/server/src/modules/sources-identity/service/workbench.ts index c26a7c88..0169ffbf 100644 --- a/apps/server/src/modules/sources-identity/service/workbench.ts +++ b/apps/server/src/modules/sources-identity/service/workbench.ts @@ -10,6 +10,7 @@ import { normalizeTitle } from "@umt/domain"; import { ApiException } from "../../../http/errors.ts"; import { decodeCursor, page, toApi } from "../rows.ts"; import { type SourcesRuntime, siteSearchConcurrency } from "../runtime.ts"; +import { loadDetailPage } from "./detail-page.ts"; import { refreshReleaseDetail, snapshotSite } from "./snapshot.ts"; // 站点工作台(整理视图):本地快照优先;本地无结果 / 快照过期 / 用户要求时对已开启站点实时补查, @@ -224,5 +225,17 @@ export async function releaseDetail( recheckBeforeDownload: true, }, refreshError, + page: await loadDetailPage(rt, releaseId), + detailSupported: await detailSupported(rt, siteId), }; } + +async function detailSupported(rt: SourcesRuntime, siteId: string): Promise { + const site = await rt.ctx.db.selectFrom("site").selectAll().where("id", "=", siteId).executeTakeFirst(); + if (!site?.enabled) return false; + try { + return rt.siteAdapter(site).capabilities.detail; + } catch { + return false; + } +} diff --git a/apps/server/test/sources-identity/detail-page.test.ts b/apps/server/test/sources-identity/detail-page.test.ts new file mode 100644 index 00000000..4e7e8715 --- /dev/null +++ b/apps/server/test/sources-identity/detail-page.test.ts @@ -0,0 +1,152 @@ +import { + AUDIENCES_FIXTURE_RELEASES, + createMockNexusPhpSite, + createMockTmdb, + type MockNexusPhpSite, + type MockTmdb, +} from "@umt/testkit"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { DETAIL_PAGE_REGION } from "../../src/modules/sources-identity/service/detail-page.ts"; +import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts"; +import { snapshotSite } from "../../src/modules/sources-identity/service/snapshot.ts"; +import { sharedCache } from "../../src/shared-cache.ts"; +import { createHarness, type Harness, json, pgAvailable, seedSite } from "./harness.ts"; + +const up = await pgAvailable(); + +const SHOT = "http://93.184.216.34/shots/1.png"; +const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]); + +describe.skipIf(!up)("站点详情页扩展内容:按需抓 + 24 小时缓存 + 截图代理 + 外部 ID 进身份解析", () => { + let h: Harness; + let mock: MockNexusPhpSite; + let tmdb: MockTmdb; + let siteId: string; + let relId: string; + const imageFetches: string[] = []; + + beforeAll(async () => { + const releases = AUDIENCES_FIXTURE_RELEASES.map((r) => + r.id === "812345" + ? { + ...r, + descriptionHtml: `◎片名 沙丘2
◎IMDb tt15239678
+ +
General
Complete name : Dune.mkv
Format : Matroska
File size : 78 GiB
Duration : 2 h 46 min
Video
Format : HEVC
`, + } + : r, + ); + mock = await createMockNexusPhpSite({ releases }); + tmdb = await createMockTmdb(); + h = await createHarness("detailpage", { + tmdbUrl: tmdb.url, + fetch: (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.startsWith("http://93.184.216.34/")) { + imageFetches.push(url); + return Promise.resolve(new Response(PNG, { status: 200, headers: { "content-type": "image/png" } })); + } + return fetch(input, init); + }, + }); + siteId = (await seedSite(h, { baseUrl: `${mock.baseUrl}/`, cookie: mock.cookie })).site.id; + await snapshotSite(h.rt, { siteId, kind: "manual" }); + relId = ( + await h.db + .selectFrom("source_release") + .select(["id"]) + .where("site_release_id", "=", "812345") + .executeTakeFirstOrThrow() + ).id; + }); + afterAll(async () => { + await h?.close(); + await mock?.close(); + await tmdb?.close(); + }); + + const detailRequests = () => mock.requests.filter((r) => r.path.startsWith("/details.php")).length; + + it("未抓过:page 为 null,标明站点支持详情页;不刷新就不请求站点", async () => { + const before = detailRequests(); + const d = await json(await h.req(`/sites/${siteId}/releases/${relId}`)); + expect(d.status).toBe(200); + expect(d.body.data).toMatchObject({ page: null, detailSupported: true }); + expect(detailRequests()).toBe(before); + }); + + it("refresh 抓一次:简介 / MediaInfo / 截图(代理路径)/ IMDb;外部 ID 写 raw._detail;缓存期内不再请求", async () => { + const before = detailRequests(); + const d = await json(await h.req(`/sites/${siteId}/releases/${relId}?refresh=true`)); + expect(d.body.data.refreshError).toBeNull(); + expect(detailRequests()).toBe(before + 1); + const page = d.body.data.page; + expect(page).toMatchObject({ + imdbId: "tt15239678", + screenshots: [{ url: `/api/v1/img/site-detail/${relId}/0`, host: "93.184.216.34" }], + }); + expect(page.mediaInfo).toMatch(/^General\nComplete name : Dune.mkv/); + expect(page.descriptionText).toContain("◎片名 沙丘2"); + expect(page.descriptionText).not.toContain("Complete name"); + const row = await h.db + .selectFrom("source_release") + .select(["raw"]) + .where("id", "=", relId) + .executeTakeFirstOrThrow(); + expect((row.raw as { _detail?: unknown })._detail).toEqual({ imdbId: "tt15239678" }); + + const again = await json(await h.req(`/sites/${siteId}/releases/${relId}?refresh=true`)); + expect(again.body.data.page.imdbId).toBe("tt15239678"); + expect(detailRequests()).toBe(before + 1); + + // 列表快照覆盖 raw._site 后,详情给出的外部 ID 仍在 + await snapshotSite(h.rt, { siteId, kind: "manual" }); + const row2 = await h.db + .selectFrom("source_release") + .select(["raw"]) + .where("id", "=", relId) + .executeTakeFirstOrThrow(); + expect((row2.raw as { _detail?: unknown })._detail).toEqual({ imdbId: "tt15239678" }); + }); + + it("截图代理:只取缓存里的第 index 张,按文件头认图片;越界 404", async () => { + const res = await h.req(`/img/site-detail/${relId}/0`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("image/png"); + expect(new Uint8Array(await res.arrayBuffer())).toEqual(PNG); + expect(imageFetches).toEqual([SHOT]); + expect((await h.req(`/img/site-detail/${relId}/5`)).status).toBe(404); + expect((await h.req(`/img/site-detail/not-an-id/0`)).status).toBe(400); + }); + + it("缓存丢了(kv_cache 过期)再刷新会重新抓一次", async () => { + await sharedCache(h.ctx).delete(DETAIL_PAGE_REGION, relId); + const before = detailRequests(); + const d = await json(await h.req(`/sites/${siteId}/releases/${relId}`)); + expect(d.body.data.page).toBeNull(); + await h.req(`/sites/${siteId}/releases/${relId}?refresh=true`); + expect(detailRequests()).toBe(before + 1); + }); + + it("身份解析:详情页的 IMDb ID 走 TMDB find,不按标题搜索,显式 ID 证据", async () => { + const searches = () => tmdb.callsTo(/\/3\/search\//).length; + const s0 = searches(); + const res = await resolveRelease(h.rt, { sourceReleaseId: relId, force: true, trigger: "manual" }); + expect(tmdb.callsTo(/\/3\/find\/tt15239678/).length).toBeGreaterThan(0); + expect(searches()).toBe(s0); + expect(res.outcome).toBe("auto"); + const ev = await h.db + .selectFrom("source_release") + .select(["resolution_evidence", "media_entity_id"]) + .where("id", "=", relId) + .executeTakeFirstOrThrow(); + expect(ev.media_entity_id).toBe(res.mediaEntityId); + const ext = await h.db + .selectFrom("external_identity") + .select(["provider", "external_id"]) + .where("media_entity_id", "=", res.mediaEntityId!) + .where("provider", "=", "tmdb") + .executeTakeFirstOrThrow(); + expect(ext.external_id).toBe("693134"); + }); +}); diff --git a/apps/web/app/components/workbench/release-detail-sheet.tsx b/apps/web/app/components/workbench/release-detail-sheet.tsx index 60da0179..4e384974 100644 --- a/apps/web/app/components/workbench/release-detail-sheet.tsx +++ b/apps/web/app/components/workbench/release-detail-sheet.tsx @@ -1,6 +1,6 @@ import type { InventoryEntry, WorkbenchRelease } from "@umt/contracts"; import { Bot, Download, RefreshCw } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Link } from "react-router"; import { toast } from "sonner"; import { useEntityDetail } from "~/api/inventory.ts"; @@ -17,6 +17,7 @@ import { formatBytes, formatDateTime } from "~/lib/utils.ts"; import { DownloadDialog } from "./download-dialog.tsx"; import { categoryLabel, hrOf, ORIGIN_LABEL, relTime } from "./release-fields.ts"; import { InventoryOverlay, ReleaseBadges } from "./release-list.tsx"; +import { ReleasePageContent } from "./release-page-section.tsx"; const DECISION_LABEL = { accept: { label: "符合,可收录", tone: "success" }, @@ -125,6 +126,16 @@ export function ReleaseDetailSheet({ const [dl, setDl] = useState(false); const r = detail.data?.release ?? release; + // 按需抓详情页:打开抽屉时没有缓存的详情页扩展内容就抓一次(服务端缓存 24 小时,同一资源本次会话只自动抓一次) + const autoTried = useRef(new Set()); + const needPage = !!detail.data && !detail.data.page && detail.data.detailSupported; + const rid = release?.id; + useEffect(() => { + if (!needPage || !rid || autoTried.current.has(rid) || refresh.isPending) return; + autoTried.current.add(rid); + refresh.mutate({ siteId, rid }); + }, [needPage, rid, siteId, refresh]); + return ( + + {detail.data?.page ? ( + + ) : refresh.isPending || detail.isPending ? ( + + ) : detail.data && !detail.data.detailSupported ? ( +

这个站点不支持抓详情页。

+ ) : ( +

+ 还没有详情页内容{detail.data?.refreshError ? `(${detail.data.refreshError})` : ""} + 。点「复核详情」抓取。 +

+ )} +
+ diff --git a/apps/web/app/components/workbench/release-page-section.tsx b/apps/web/app/components/workbench/release-page-section.tsx new file mode 100644 index 00000000..565a17fd --- /dev/null +++ b/apps/web/app/components/workbench/release-page-section.tsx @@ -0,0 +1,99 @@ +import type { SiteReleasePage } from "@umt/contracts"; +import { ExternalLink } from "lucide-react"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { formatBytes, formatDateTime } from "~/lib/utils.ts"; + +/** 详情页扩展内容里的外部站点链接 */ +export function externalLinks(p: Pick) { + const out: { label: string; href: string }[] = []; + if (p.tmdb) out.push({ label: `TMDB ${p.tmdb.id}`, href: `https://www.themoviedb.org/${p.tmdb.type}/${p.tmdb.id}` }); + if (p.imdbId) out.push({ label: `IMDb ${p.imdbId}`, href: `https://www.imdb.com/title/${p.imdbId}/` }); + if (p.doubanId) out.push({ label: `豆瓣 ${p.doubanId}`, href: `https://movie.douban.com/subject/${p.doubanId}/` }); + return out; +} + +export function ReleasePageContent({ page }: { page: SiteReleasePage }) { + const links = externalLinks(page); + const empty = + !links.length && + !page.uploader && + !page.tags.length && + !page.descriptionText && + !page.mediaInfo && + !page.screenshots.length && + !page.files.length; + return ( +
+ {links.length || page.uploader ? ( +
+ {links.map((l) => ( + + {l.label} + + + ))} + {page.uploader ? 上传者 {page.uploader} : null} +
+ ) : null} + {page.tags.length ? ( +
+ {page.tags.map((t) => ( + {t} + ))} +
+ ) : null} + {page.descriptionText ? ( +

+ {page.descriptionText} +

+ ) : null} + {page.mediaInfo ? ( +
+ MediaInfo +
+            {page.mediaInfo}
+          
+
+ ) : null} + {page.screenshots.length ? ( +
+ {page.screenshots.map((s, i) => ( + + {`截图 + + ))} +
+ ) : null} + {page.files.length ? ( +
+ 文件清单({page.files.length}) +
    + {page.files.map((f, i) => ( +
  • + {f.name} + + {f.sizeBytes === null ? "" : formatBytes(f.sizeBytes, 1)} + +
  • + ))} +
+
+ ) : null} + {empty ?

详情页上没有解析到简介、截图或外部链接。

: null} +

+ 抓取于 {formatDateTime(page.fetchedAt)},缓存 24 小时;截图经服务端代理加载。 +

+
+ ); +} diff --git a/docs/plan/00-first-version-plan.md b/docs/plan/00-first-version-plan.md index 084c1cdf..7380fcec 100644 --- a/docs/plan/00-first-version-plan.md +++ b/docs/plan/00-first-version-plan.md @@ -164,5 +164,5 @@ NFO / 图片投影、身份巡检与挂载根因聚合、HTTP 失败自动切浏 - **短剧等 TMDB 缺失作品的自助补全(META-1,用户 2026-09-29)**:能力 `metadata.enrich`(propose):Brave 搜索 + 受限抓取(平台页/百科类)→ 模型抽字段(标题/别名/年份/平台/集数/简介/海报 URL)带来源与证据等级 → 写本地临时实体与字段级元数据(source=web/agent,可锁可改,不冒充 TMDB);触发:池条目"补全"、订阅/规则命中但 TMDB 无结果、Agent 委托;按归一化标题缓存、每日预算;后续 TMDB 收录由身份巡检补映射;不改路径不下载。DEC-1 之后实施。 - **Agent 快捷面板与会话排版(AGUI-2,用户 2026-09-29)**:抽屉内「当前/历史」Tab,历史列表可搜索筛选并在抽屉内打开(可续跑),"在页面打开"次要;会话页:阅读列宽、同回合分组、工具卡默认一行折叠、步骤清单改右侧栏/顶部折叠条不再 sticky 遮挡、预算在右栏底、长汇报折叠、输入区固定底部。 - **凭据统一注入浏览器(BR-2,用户 2026-09-29)**:打开站点浏览器会话时服务端把账号 Cookie(手填或自动登录得到的)注入 camofox profile,截图/桌面模式无需再登录;交还/关闭/定时从浏览器回写 Cookie 到凭据存储,HTTP 抓取/RSS/快照共用;账号显示登录态来源与时间;Cookie 不进日志/模型。 -- **站点详情页抓取(用户 2026-09-29,憨憨 details.php)**:`SiteReleaseDetail` 扩展简介/MediaInfo/截图(经图片代理)/IMDb·豆瓣·TMDB ID/文件清单/标签/上传者;只按需抓(点开详情或评估缺字段),kv_cache 24 小时;详情抽屉展示;IMDb/豆瓣 ID 作为强证据进身份解析(TMDB find)。 +- **站点详情页抓取(用户 2026-09-29,憨憨 details.php)**【INT-2 已做,见 runbook/sites.md「详情页扩展内容」】:`SiteReleaseDetail` 扩展简介/MediaInfo/截图(经图片代理)/IMDb·豆瓣·TMDB ID/文件清单/标签/上传者;只按需抓(点开详情或评估缺字段),kv_cache 24 小时;详情抽屉展示;IMDb/豆瓣 ID 作为强证据进身份解析(TMDB find)。 - **META-1 后续**:真机 4 条短剧在白名单站点均无结果——扩大抓取白名单(短剧平台/百科类/豆瓣搜索快照/Bing 摘要模式),必要时只用搜索摘要抽字段不抓页;记录命中率。 diff --git a/docs/runbook/sites.md b/docs/runbook/sites.md index 37c0c79f..09df0ba6 100644 --- a/docs/runbook/sites.md +++ b/docs/runbook/sites.md @@ -91,6 +91,16 @@ 每从站点限流器拿到一个令牌(列表、搜索、详情、下载、登录、用户数据、原站视图导航)记一次请求,写 `site_request_count`(迁移 `0046`,按小时聚合,多进程共用,保留 7 天)。`GET /sites/{id}/request-stats`(read)返回最近 24 小时总数、按小时明细、快照实际周期与合并后的访问默认;站点页概览「近 24 小时请求」与资源页列表头显示。计数只在升级后开始累计。 +## 详情页扩展内容(INT-2) + +资源详情抽屉的「站点详情页」一节:简介、MediaInfo / BDInfo、截图、IMDb / 豆瓣 / TMDB 链接、文件清单、标签、上传者。 + +- 只按需抓:打开抽屉时如果没有缓存,前端自动调一次 `GET /sites/{id}/releases/{rid}?refresh=true`(同一资源本次会话只自动一次),或点「复核详情」;`release.inspect` 的 `refreshDetail` 同一路径。列表快照、搜索不抓详情。 +- 缓存:内容放 PG `kv_cache` region `site.detail`,key = `source_release.id`,TTL 取站点详情缓存期(至少 24 小时)。缓存期内 `detail_fetched_at` 新鲜且缓存还在就不请求站点;kv_cache 被清掉(过期 / 重启丢 UNLOGGED 表)后下次刷新会重新抓一次。 +- 外部 ID 另写 `source_release.raw._detail`(`imdbId / doubanId / tmdb{type,id}`),列表快照 upsert 不覆盖它。身份解析把它和列表给的 `raw._site.imdbId / doubanId` 一起当显式 ID:本地召回查已接受的外部身份;需要查 TMDB 时,有 TMDB ID 直接取详情,否则 IMDb ID 走 `/find/{imdb}?external_source=imdb_id`,命中就不再按标题搜索;TMDB 不支持豆瓣 ID,豆瓣只用于本地召回。是否查 TMDB 仍按惰性识别规则(DEC-1),外部 ID 本身不触发查询。 +- 截图:响应里只有代理路径 `/api/v1/img/site-detail/{releaseId}/{index}`,服务端从缓存取原始地址,公网校验(每跳重定向复核)、不带站点凭据、按文件头认图片(不放行 SVG)、≤ 8 MB,浏览器缓存 24 小时。站内附件如果需要登录才能看会显示为裂图,不会为截图消耗站点请求。 +- 解析:`packages/adapters/src/sites/spider/detail-page.ts`。简介取 MovieClaw `detail_description_css`(缺省 `#kdescr`);MediaInfo 取定义 `umt.detail.page.mediaInfo` → 「MediaInfo」字段标签 → 简介里第一个像 MediaInfo 的引用 / 代码块;文件清单取 `umt.detail.page.files` → MovieClaw `detail_file_list_css` → 「文件列表」字段 → `#filelist` 表格,不额外请求 `viewfilelist.php`。字段标签除 `td.rowhead` 外可在 `umt.detail.page.labelSelectors` 加(憨憨:`div.font-bold.leading-6`、`span.font-bold`)。`page.probe` 记录页面上找到的字段标签,只存缓存不展示,站点改版时用来排查。馒头从 API 的 `descr`(BBCode)与 `mediainfo` 取同样的结构。 + ## Agent 能力(W10-pre2) 处理器在 `apps/server/src/modules/sources-identity/capabilities-sites.ts`,调用上面同一组 service。能力 ID 只允许小写 + 下划线(`CapabilityId` 正则),所以规格里的 `site.healthCheck` / `site.refreshLogin` / `site.userData` 落为下划线形式。 diff --git a/packages/adapters/src/sites/definitions/hhanclub.json b/packages/adapters/src/sites/definitions/hhanclub.json index a135c144..303500e9 100644 --- a/packages/adapters/src/sites/definitions/hhanclub.json +++ b/packages/adapters/src/sites/definitions/hhanclub.json @@ -258,6 +258,9 @@ "imdb": "a[href*='imdb.com']::attr(href)", "douban": "a[href*='douban.com']::attr(href)", "fileList": "td.rowfollow > ul > li::text" + }, + "page": { + "labelSelectors": ["div.font-bold.leading-6", "span.font-bold"] } }, "vipLevels": ["贵宾", "VIP"], diff --git a/packages/adapters/src/sites/definitions/schema.ts b/packages/adapters/src/sites/definitions/schema.ts index 97f172f8..081fe19b 100644 --- a/packages/adapters/src/sites/definitions/schema.ts +++ b/packages/adapters/src/sites/definitions/schema.ts @@ -141,6 +141,16 @@ const UmtNexusSchema = z.strictObject({ fileList: z.string().optional(), }) .optional(), + page: z + .strictObject({ + labelSelectors: z.array(z.string()).optional(), + mediaInfo: z.string().optional(), + screenshots: z.string().optional(), + tags: z.string().optional(), + uploader: z.string().optional(), + files: z.string().optional(), + }) + .optional(), }) .optional(), downloadPath: z.string().min(1), diff --git a/packages/adapters/src/sites/detail-text.ts b/packages/adapters/src/sites/detail-text.ts new file mode 100644 index 00000000..d7318537 --- /dev/null +++ b/packages/adapters/src/sites/detail-text.ts @@ -0,0 +1,108 @@ +/** + * 详情页扩展内容的通用文本规则(NexusPHP HTML 与 M-Team BBCode 共用):MediaInfo 识别、截图过滤、TMDB 链接。 + */ +import type { SiteDetailPage } from "./types.ts"; +import { extractDoubanId, extractImdbId } from "./util.ts"; + +/** 表情 / 图标 / 旗帜 / 头像等小图,不算截图 */ +const NOT_SCREENSHOT = + /(smilies|smiley|emoji|\/icons?\/|pic\/(?:trans|cattrans|flag)|\/flags?\/|avatar|\.svg(?:$|\?))/i; + +export const MAX_SCREENSHOTS = 20; +export const MAX_FILES = 500; + +/** MediaInfo / BDInfo 文本:有概览段落 + 视频段落,且至少 5 行「键 : 值」 */ +export function looksLikeMediaInfo(text: string): boolean { + if (!/(^|\n)\s*(General|概览|常规|DISC INFO|Disc Title|Complete name)\b/i.test(text)) return false; + if (!/(^|\n)\s*(Video|视频)\b/i.test(text)) return false; + return (text.match(/^[^\n::]{2,40}[ \t]*[::][ \t]*\S/gm) ?? []).length >= 5; +} + +export function extractTmdb(text: string | null | undefined): SiteDetailPage["tmdb"] { + const m = text ? /themoviedb\.org\/(movie|tv)\/(\d+)/.exec(text) : null; + return m ? { type: m[1] as "movie" | "tv", id: m[2]! } : null; +} + +/** 图片地址转绝对 http(s) 地址并过滤非截图;无效返回 null */ +export function screenshotUrl(src: string | null | undefined, base: string): string | null { + if (!src || src.startsWith("data:")) return null; + let u: URL; + try { + u = new URL(src.trim(), base); + } catch { + return null; + } + if (u.protocol !== "http:" && u.protocol !== "https:") return null; + if (NOT_SCREENSHOT.test(u.pathname)) return null; + return u.toString(); +} + +/** 多行文本规整:去 \r、行尾空白,连续空行压成一行 */ +export function tidyText(text: string): string { + return text + .replace(/\r/g, "") + .replace(/ /g, " ") + .split("\n") + .map((l) => l.replace(/\s+$/, "")) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +export function emptyDetailPage(): SiteDetailPage { + return { + descriptionText: null, + mediaInfo: null, + screenshots: [], + imdbId: null, + doubanId: null, + tmdb: null, + files: [], + tags: [], + uploader: null, + probe: { labels: [], hasDescription: false, imageCount: 0 }, + }; +} + +/** + * M-Team 详情(BBCode 简介 + 独立 mediainfo 字段)→ 扩展内容。 + * 截图取 [img] 标签;简介去掉 BBCode 标记与 MediaInfo 引用块。 + */ +export function pageFromBbcode( + descr: string | null | undefined, + base: string, + extra: { mediaInfo?: string | null; imdbId?: string | null; doubanId?: string | null; tags?: string[] } = {}, +): SiteDetailPage { + const page = emptyDetailPage(); + const src = descr ?? ""; + const imgs: string[] = []; + for (const m of src.matchAll(/\[img(?:=([^\]]+))?\]([^[]*)\[\/img\]|\[img=([^\]]+)\]/gi)) { + const u = screenshotUrl((m[2] || m[1] || m[3] || "").trim(), base); + if (u && !imgs.includes(u)) imgs.push(u); + } + page.probe.imageCount = imgs.length; + page.screenshots = imgs.slice(0, MAX_SCREENSHOTS); + let mediaInfo = extra.mediaInfo?.trim() || null; + let body = src.replace(/\[img(?:=[^\]]+)?\][^[]*\[\/img\]|\[img=[^\]]+\]/gi, ""); + // [quote] / [code] / [mediainfo] 块里的 MediaInfo + body = body.replace( + /\[(quote|code|mediainfo|box|hide|spoiler)(=[^\]]*)?\]([\s\S]*?)\[\/\1\]/gi, + (all, _t, _a, inner) => { + const text = tidyText(String(inner).replace(/\[\/?[a-z*]+(?:=[^\]]*)?\]/gi, "")); + if (looksLikeMediaInfo(text)) { + mediaInfo ??= text; + return ""; + } + return all; + }, + ); + const text = tidyText(body.replace(/\[\/?[a-z*]+(?:=[^\]]*)?\]/gi, "")); + page.probe.hasDescription = !!src.trim(); + page.descriptionText = text ? text.slice(0, 20_000) : null; + page.mediaInfo = mediaInfo ? mediaInfo.slice(0, 50_000) : null; + page.imdbId = extra.imdbId ?? extractImdbId(src) ?? null; + page.doubanId = extra.doubanId ?? extractDoubanId(src) ?? null; + page.tmdb = extractTmdb(src); + page.tags = [...new Set(extra.tags ?? [])].slice(0, 20); + return page; +} diff --git a/packages/adapters/src/sites/index.ts b/packages/adapters/src/sites/index.ts index f6ff55a7..64e13ddc 100644 --- a/packages/adapters/src/sites/index.ts +++ b/packages/adapters/src/sites/index.ts @@ -90,6 +90,8 @@ export { type SiteCapabilities, type SiteCategory, type SiteCredentialKind, + type SiteDetailFile, + type SiteDetailPage, SiteError, type SiteErrorKind, type SiteHealth, diff --git a/packages/adapters/src/sites/mteam/adapter.ts b/packages/adapters/src/sites/mteam/adapter.ts index eab984e0..658b292c 100644 --- a/packages/adapters/src/sites/mteam/adapter.ts +++ b/packages/adapters/src/sites/mteam/adapter.ts @@ -11,6 +11,7 @@ */ import { redactUrl } from "../../rss/redact.ts"; import { buildHttpContext, type SiteAdapterDeps } from "../deps.ts"; +import { pageFromBbcode } from "../detail-text.ts"; import { readBody, siteFetch } from "../http.ts"; import { healthFromError } from "../spider/adapter.ts"; import { promotionFromFactors } from "../spider/map.ts"; @@ -154,6 +155,12 @@ export function createMteamAdapter(input: MteamDefinition, deps: SiteAdapterDeps const [row] = parseMteamResult([data], def.domain); const r: SiteReleaseDetail = mteamTorrentToRelease(row!, data, def, http.now().toISOString()); if (data.descr) r.descriptionText = String(data.descr).slice(0, 20_000); + r.page = pageFromBbcode(data.descr, def.domain, { + mediaInfo: typeof data.mediainfo === "string" ? data.mediainfo : null, + imdbId: r.imdbId ?? null, + doubanId: r.doubanId ?? null, + tags: r.tags, + }); return r; }, diff --git a/packages/adapters/src/sites/mteam/spider.ts b/packages/adapters/src/sites/mteam/spider.ts index 412b0949..9f0f7d30 100644 --- a/packages/adapters/src/sites/mteam/spider.ts +++ b/packages/adapters/src/sites/mteam/spider.ts @@ -46,6 +46,7 @@ export interface MteamTorrentJson { labels?: string | number | null; labelsNew?: string[] | null; descr?: string | null; + mediainfo?: string | null; status?: { seeders?: string | number | null; leechers?: string | number | null; diff --git a/packages/adapters/src/sites/spider/detail-page.test.ts b/packages/adapters/src/sites/spider/detail-page.test.ts new file mode 100644 index 00000000..257afa5d --- /dev/null +++ b/packages/adapters/src/sites/spider/detail-page.test.ts @@ -0,0 +1,107 @@ +import { HHANCLUB_DETAIL_PAGE_FULL } from "@umt/testkit"; +import { describe, expect, it } from "vitest"; +import { hhanclubDefinition } from "../definitions/index.ts"; +import { looksLikeMediaInfo, pageFromBbcode } from "../detail-text.ts"; +import { getSiteDefinition } from "../registry.ts"; +import { parseDetailPage } from "./detail.ts"; +import type { IndexerDefinition } from "./schema.ts"; + +const OBSERVED = "2026-09-29T00:00:00.000Z"; + +describe("详情页扩展内容(HHClub div 模板)", () => { + const { kind, detail } = parseDetailPage(HHANCLUB_DETAIL_PAGE_FULL, hhanclubDefinition, "91002", OBSERVED); + const page = detail!.page!; + + it("外部 ID、上传者、标签", () => { + expect(kind).toBe("ok"); + expect(page).toMatchObject({ + imdbId: "tt14452776", + doubanId: "36000001", + tmdb: { type: "tv", id: "136315" }, + uploader: "uploader01", + tags: ["官方", "中字", "完结"], + }); + expect(detail!.imdbId).toBe("tt14452776"); + expect(detail!.tags).toEqual(["官方", "中字", "完结"]); + }); + + it("MediaInfo 从简介的代码块取出,简介里不再重复", () => { + expect(page.mediaInfo).toMatch(/^General\nUnique ID : 1234\n/); + expect(page.mediaInfo).toContain("Format : HEVC"); + expect(page.descriptionText).toContain("◎译  名 示例剧集"); + expect(page.descriptionText).not.toContain("Unique ID"); + }); + + it("截图:相对地址补全、优先 data-src、过滤表情与占位图", () => { + expect(page.screenshots).toEqual([ + "https://hhanclub.net/attachments/202609/poster.jpg", + "https://img.example-host.com/i/shot1.png", + "https://img.example-host.com/i/shot2.jpg", + ]); + }); + + it("文件清单:「名称 (大小)」", () => { + expect(page.files).toEqual([ + { name: "Example.Show.S01E01.mkv", sizeBytes: Math.round(1.04 * 1024 ** 3) }, + { name: "Example.Show.S01E02.mkv", sizeBytes: Math.round(1.02 * 1024 ** 3) }, + ]); + }); + + it("诊断:记录页面上的字段标签", () => { + expect(page.probe.labels).toEqual(expect.arrayContaining(["标题", "副标题", "标签", "上传者", "文件列表"])); + expect(page.probe.hasDescription).toBe(true); + }); +}); + +describe("详情页扩展内容(标准 NexusPHP rowhead 表格)", () => { + const def = getSiteDefinition("audiences") as IndexerDefinition; + const html = `退出

Some.Movie.2024.1080p.BluRay.x264-GRP

+ + + + + + +
副标题某电影
基本信息大小:8.00 GB   类型: 电影
标签中字DIY
上传者匿名
简介
一段简介
第二行 +
DISC INFO:
Disc Title: X
Size: 1
Protection: AACS
Playlist: 00800.MPLS
Length: 2:00:00
Video: MPEG-4 AVC
Total Bitrate: 30 Mbps
+
+
Some.Movie.mkv8.00 GB
`; + + it("rowhead 标签、BDInfo、#filelist 表格", () => { + const { detail } = parseDetailPage(html, def, "1", OBSERVED); + const page = detail!.page!; + expect(page.tags).toEqual(["中字", "DIY"]); + expect(page.uploader).toBe("匿名"); + expect(page.mediaInfo).toContain("Disc Title: X"); + expect(page.descriptionText).toBe("一段简介\n第二行"); + expect(page.screenshots).toEqual(["https://img.example-host.com/a.png"]); + expect(page.files).toEqual([{ name: "Some.Movie.mkv", sizeBytes: 8 * 1024 ** 3 }]); + expect(page.imdbId).toBeNull(); + }); +}); + +describe("M-Team BBCode 简介", () => { + it("截图取 [img],MediaInfo 取引用块,外部 ID 从正文取", () => { + const descr = `[img]https://img.example-host.com/p.jpg[/img] +◎简介 这是简介 https://www.imdb.com/title/tt15239678/ +[quote]General +Unique ID : 1 +Complete name : a.mkv +Format : Matroska +File size : 1 GiB + +Video +Format : AVC[/quote] +[img=https://img.example-host.com/s.png]`; + const page = pageFromBbcode(descr, "https://kp.m-team.cc/", { tags: ["中字"] }); + expect(page.screenshots).toEqual(["https://img.example-host.com/p.jpg", "https://img.example-host.com/s.png"]); + expect(page.mediaInfo).toMatch(/^General\n/); + expect(page.descriptionText).toBe("◎简介 这是简介 https://www.imdb.com/title/tt15239678/"); + expect(page.imdbId).toBe("tt15239678"); + expect(page.tags).toEqual(["中字"]); + }); + + it("MediaInfo 识别不误判普通简介", () => { + expect(looksLikeMediaInfo("General\n一段文字\nVideo")).toBe(false); + }); +}); diff --git a/packages/adapters/src/sites/spider/detail-page.ts b/packages/adapters/src/sites/spider/detail-page.ts new file mode 100644 index 00000000..dfa14487 --- /dev/null +++ b/packages/adapters/src/sites/spider/detail-page.ts @@ -0,0 +1,208 @@ +/** + * 详情页扩展内容(简介 / MediaInfo / 截图 / IMDb·豆瓣·TMDB / 文件清单 / 标签 / 上传者)—— 本项目扩展。 + * 以 MovieClaw detail_*_css(description / imdb / douban / file_list)为起点,其余按 NexusPHP details.php 公开结构 + * 做通用规则:字段标签(td.rowhead 或站点配置的 div 标签)的下一个兄弟元素是值。站点特化写在定义 `umt.detail.page`。 + * 文件清单只取页面上已有的内容,不为它额外请求 viewfilelist.php(请求克制)。 + */ +import type { CheerioAPI } from "cheerio"; +import { + emptyDetailPage, + extractTmdb, + looksLikeMediaInfo, + MAX_FILES, + MAX_SCREENSHOTS, + screenshotUrl, + tidyText, +} from "../detail-text.ts"; +import type { SiteDetailFile, SiteDetailPage } from "../types.ts"; +import { extractDoubanId, extractImdbId } from "../util.ts"; +import { mcSelect } from "./mc-selector.ts"; +import type { IndexerDefinition } from "./schema.ts"; +import { parseSizeMp } from "./spider.ts"; + +type DetailConf = NonNullable; +type El = ReturnType; + +const LABELS = { + description: ["简介", "Description"], + mediaInfo: ["MediaInfo", "媒体信息", "BDInfo"], + tags: ["标签", "Tags"], + uploader: ["上传者", "发布者", "发布人", "上传人", "Uploader"], + files: ["文件列表", "文件清单", "File list", "Files"], +}; + +/** 简介里可能装 MediaInfo 的块 */ +const MI_BLOCKS = "fieldset, div.codemain, pre, code, blockquote, .spoiler-content, .hide, div.nexus-media-info-raw"; + +const normLabel = (t: string) => + t + .replace(/\s+/g, "") + .replace(/[::]+$/, "") + .toLowerCase(); + +/** 保留换行的文本:
与块级元素换行 */ +function blockText($: CheerioAPI, el: El): string { + const c = el.clone(); + c.find("script, style").remove(); + c.find("br").replaceWith("\n"); + c.find("div, p, li, tr, pre, fieldset, blockquote, h1, h2, h3, h4, h5, h6").each((_, n) => { + $(n).append("\n"); + }); + return tidyText(c.text()); +} + +const SIZE_TAIL = /^(.*?)\s*[(([]\s*([\d.,]+\s*[KMGTP]?i?B)\s*[))\]]\s*$/i; +const HAS_EXT = /\.[A-Za-z0-9]{2,5}$/; + +function fileFromText(text: string): SiteDetailFile | null { + const t = text.replace(/\s+/g, " ").trim(); + if (!t) return null; + const m = SIZE_TAIL.exec(t); + if (m?.[1]) return { name: m[1], sizeBytes: parseSizeMp(m[2]!) || null }; + return HAS_EXT.test(t) ? { name: t, sizeBytes: null } : null; +} + +function filesFrom($: CheerioAPI, rows: El): SiteDetailFile[] { + const out: SiteDetailFile[] = []; + rows.each((_, n) => { + if (out.length >= MAX_FILES) return; + const row = $(n); + const cells = row.children("td"); + if (cells.length >= 2) { + const name = cells.first().text().replace(/\s+/g, " ").trim(); + const size = cells.eq(1).text().trim(); + if (name && HAS_EXT.test(name) && /\d/.test(size)) out.push({ name, sizeBytes: parseSizeMp(size) || null }); + return; + } + const f = fileFromText(row.text()); + if (f) out.push(f); + }); + return out; +} + +export function extractDetailPage($: CheerioAPI, conf: DetailConf, base: string): SiteDetailPage { + const page = emptyDetailPage(); + const p = conf.page ?? {}; + + // 字段标签 → 值元素 + const pairs: { label: string; value: El }[] = []; + const addLabels = (sel: string, valueFor: (e: El) => El) => { + $(sel).each((_, n) => { + const e = $(n); + const label = normLabel(e.text()); + if (!label || label.length > 16) return; + const value = valueFor(e); + if (value.length) pairs.push({ label, value }); + }); + }; + addLabels(conf.rowLabel, (e) => e.nextAll(conf.rowValue).first()); + for (const sel of p.labelSelectors ?? []) addLabels(sel, (e) => e.next()); + const byLabel = (names: string[]): El | null => + pairs.find((x) => names.some((n) => x.label.startsWith(normLabel(n))))?.value ?? null; + page.probe.labels = [...new Set(pairs.map((x) => x.label))].slice(0, 40); + + // 简介容器 + const descSel = conf.fields?.description ?? conf.description; + let desc: El | null = descSel ? $(descSel).first() : null; + if (!desc?.length) desc = byLabel(LABELS.description); + page.probe.hasDescription = !!desc?.length; + + // MediaInfo:站点选择器 → 字段标签 → 简介里的引用 / 代码块(取最内层文本,整块从简介里去掉) + let mediaInfoEl: El | null = null; + let miBlockIndex = -1; + if (p.mediaInfo) { + const el = $(p.mediaInfo).first(); + if (el.length) mediaInfoEl = el; + } + mediaInfoEl ??= byLabel(LABELS.mediaInfo); + const blocks = desc?.length ? desc.find(MI_BLOCKS) : null; + if (!mediaInfoEl && blocks) { + blocks.each((i, n) => { + if (miBlockIndex >= 0) return; + if (looksLikeMediaInfo(blockText($, $(n)))) miBlockIndex = i; + }); + if (miBlockIndex >= 0) { + const outer = blocks.eq(miBlockIndex); + const inner = outer + .find(MI_BLOCKS) + .filter((_, n) => looksLikeMediaInfo(blockText($, $(n)))) + .last(); + mediaInfoEl = inner.length ? inner : outer; + } + } + if (mediaInfoEl) { + const t = blockText($, mediaInfoEl); + if (t) page.mediaInfo = t.slice(0, 50_000); + } + + // 截图 + const imgs: string[] = []; + const imgEls = p.screenshots ? $(p.screenshots) : desc?.length ? desc.find("img") : null; + imgEls?.each((_, n) => { + const e = $(n); + const u = screenshotUrl(e.attr("data-src") ?? e.attr("data-original") ?? e.attr("src"), base); + if (u && !imgs.includes(u)) imgs.push(u); + }); + page.probe.imageCount = imgs.length; + page.screenshots = imgs.slice(0, MAX_SCREENSHOTS); + + // 简介纯文本:去掉 MediaInfo 块与图片 + if (desc?.length) { + const c = desc.clone(); + if (miBlockIndex >= 0) c.find(MI_BLOCKS).eq(miBlockIndex).remove(); + c.find("img").remove(); + const t = blockText($, c); + if (t) page.descriptionText = t.slice(0, 20_000); + } + + // 外部 ID:页面上的链接优先,其次简介正文 + const descHtml = desc?.html() ?? ""; + const f = conf.fields ?? {}; + page.imdbId = + extractImdbId(mcSelect($, null, f.imdb ?? "a[href*='imdb.com']::attr(href)")) ?? extractImdbId(descHtml) ?? null; + page.doubanId = + extractDoubanId(mcSelect($, null, f.douban ?? "a[href*='douban.com']::attr(href)")) ?? + extractDoubanId(descHtml) ?? + null; + page.tmdb = extractTmdb($("a[href*='themoviedb.org']").first().attr("href")) ?? extractTmdb(descHtml); + + // 文件清单:站点选择器 → MovieClaw file_list(去伪元素)→ 字段标签 → #filelist 表格 + const fileSel = p.files ?? f.fileList?.replace(/::text$/, ""); + let files = fileSel ? filesFrom($, $(fileSel)) : []; + if (!files.length) { + const v = byLabel(LABELS.files); + if (v) files = filesFrom($, v.find("li, tr")); + } + if (!files.length) files = filesFrom($, $("#filelist tr")); + page.files = files; + + // 标签 + const tagEl = p.tags ? $(p.tags) : byLabel(LABELS.tags); + if (tagEl?.length) { + const parts = tagEl.find("span, a").length + ? tagEl + .find("span, a") + .map((_, n) => $(n).text().trim()) + .get() + : tagEl.text().split(/[\s,,|/]+/); + page.tags = [...new Set(parts.map((t) => t.trim()).filter((t) => t && t.length <= 20))].slice(0, 20); + } + + // 上传者 + const upEl = p.uploader ? $(p.uploader).first() : byLabel(LABELS.uploader); + if (upEl?.length) { + const a = upEl.is("a") ? upEl : upEl.find("a[href*='userdetails.php']").first(); + const name = (a.length ? a.text() : upEl.text()).replace(/\s+/g, " ").trim(); + if (name) page.uploader = name.slice(0, 64); + } + if (!page.uploader) { + for (const x of pairs) { + const m = /(?:上传者|发布者|发布人|Uploader)\s*[::]\s*(\S+)/i.exec(x.value.text()); + if (m?.[1]) { + page.uploader = m[1].slice(0, 64); + break; + } + } + } + return page; +} diff --git a/packages/adapters/src/sites/spider/detail.ts b/packages/adapters/src/sites/spider/detail.ts index 17965b47..4613e760 100644 --- a/packages/adapters/src/sites/spider/detail.ts +++ b/packages/adapters/src/sites/spider/detail.ts @@ -6,6 +6,7 @@ import type { CheerioAPI } from "cheerio"; import type { SiteReleaseDetail } from "../types.ts"; import { extractDoubanId, extractImdbId, parseCount, parseSiteTime } from "../util.ts"; +import { extractDetailPage } from "./detail-page.ts"; import { categoryName, promotionFromFactors } from "./map.ts"; import { mcSelect } from "./mc-selector.ts"; import type { IndexerDefinition } from "./schema.ts"; @@ -115,5 +116,10 @@ export function parseDetailPage( if (imdb) detail.imdbId = imdb; const douban = extractDoubanId(mcSelect($, null, f.douban)) ?? extractDoubanId(descHtml); if (douban) detail.doubanId = douban; + const page = extractDetailPage($, conf, def.domain); + page.imdbId ??= detail.imdbId ?? null; + page.doubanId ??= detail.doubanId ?? null; + if (page.tags.length) detail.tags = page.tags; + detail.page = page; return { kind: "ok", detail }; } diff --git a/packages/adapters/src/sites/spider/schema.ts b/packages/adapters/src/sites/spider/schema.ts index df9934d0..bbc13714 100644 --- a/packages/adapters/src/sites/spider/schema.ts +++ b/packages/adapters/src/sites/spider/schema.ts @@ -198,6 +198,20 @@ export interface UmtSiteExtension { fields?: Partial< Record<"title" | "subtitle" | "size" | "description" | "download" | "imdb" | "douban" | "fileList", string> >; + /** + * 详情页扩展内容(简介 / MediaInfo / 截图 / 文件 / 标签 / 上传者)的站点特化,均可省略(走通用规则,见 detail-page.ts)。 + * 选择器为普通 CSS。 + */ + page?: { + /** 除 rowLabel 外的字段标签元素(div 版详情页:标签元素的下一个兄弟元素是值) */ + labelSelectors?: string[]; + mediaInfo?: string; + screenshots?: string; + tags?: string; + uploader?: string; + /** 文件清单行(每行一个文件,文本形如「名称 (大小)」或两列表格) */ + files?: string; + }; }; /** 下载路径(按资源 ID 拼接) */ downloadPath: string; diff --git a/packages/adapters/src/sites/types.ts b/packages/adapters/src/sites/types.ts index 7c21b60a..77bc1c3d 100644 --- a/packages/adapters/src/sites/types.ts +++ b/packages/adapters/src/sites/types.ts @@ -60,8 +60,34 @@ export interface SiteRelease { observedAt: string; } +/** 详情页文件清单一项;大小未知为 null */ +export interface SiteDetailFile { + name: string; + sizeBytes: number | null; +} + +/** + * 详情页扩展内容(只在按需抓详情时得到,服务端放 kv_cache,不进 source_release 列)。 + * 截图是站点页面上的原始图片地址,展示时经服务端图片代理,不让浏览器直连外站。 + */ +export interface SiteDetailPage { + /** 简介纯文本(已去掉 MediaInfo 块) */ + descriptionText: string | null; + mediaInfo: string | null; + screenshots: string[]; + imdbId: string | null; + doubanId: string | null; + tmdb: { type: "movie" | "tv"; id: string } | null; + files: SiteDetailFile[]; + tags: string[]; + uploader: string | null; + /** 解析诊断:页面上找到的字段标签、是否有简介容器、图片数(用于站点改版排查,不展示) */ + probe: { labels: string[]; hasDescription: boolean; imageCount: number }; +} + export interface SiteReleaseDetail extends SiteRelease { descriptionText?: string; + page?: SiteDetailPage; } export interface SiteListFilters { diff --git a/packages/adapters/src/tmdb/client.ts b/packages/adapters/src/tmdb/client.ts index f14ac4f7..78abe02a 100644 --- a/packages/adapters/src/tmdb/client.ts +++ b/packages/adapters/src/tmdb/client.ts @@ -348,6 +348,16 @@ export class TmdbClient { return [...m, ...t].sort((a, b) => (b.popularity ?? 0) - (a.popularity ?? 0)); } + /** /find/{imdb_id}?external_source=imdb_id:IMDb ID → 电影 / 剧集候选(站点详情页给出的 IMDb 链接走这里) */ + async findByImdb(imdbId: string): Promise { + if (!/^tt\d{5,10}$/.test(imdbId)) return []; + const lang = this.langs[0]!; + const p = await this.get(`/find/${imdbId}`, { external_source: "imdb_id", language: lang }); + const movies = (Array.isArray(p.movie_results) ? p.movie_results : []) as Json[]; + const tvs = (Array.isArray(p.tv_results) ? p.tv_results : []) as Json[]; + return [...movies.map((r) => this.fromBrief("movie", r, lang)), ...tvs.map((r) => this.fromBrief("tv", r, lang))]; + } + /** 详情:多语言标题、别名(alternative_titles)、外部 ID、季列表(剧集)。不存在抛 notfound。 */ async details(type: MediaEntityType, id: string | number): Promise { const [first, ...rest] = await Promise.all( diff --git a/packages/contracts/src/sources.ts b/packages/contracts/src/sources.ts index 73e1b3dd..639b4d92 100644 --- a/packages/contracts/src/sources.ts +++ b/packages/contracts/src/sources.ts @@ -636,6 +636,24 @@ export const WorkbenchReleasePage = z.object({ }); export type WorkbenchReleasePage = z.infer; +/** + * 站点详情页扩展内容(按需抓详情时得到,服务端缓存 24 小时;过期后需再次复核详情)。 + * 截图地址是服务端图片代理路径(/api/v1/img/site-detail/{releaseId}/{index}),不直连外站。 + */ +export const SiteReleasePage = z.object({ + fetchedAt: Timestamp, + descriptionText: z.string().nullable(), + mediaInfo: z.string().nullable(), + screenshots: z.array(z.object({ url: z.string(), host: z.string() })), + imdbId: z.string().nullable(), + doubanId: z.string().nullable(), + tmdb: z.object({ type: z.enum(["movie", "tv"]), id: z.string() }).nullable(), + files: z.array(z.object({ name: z.string(), sizeBytes: z.number().int().nullable() })), + tags: z.array(z.string()), + uploader: z.string().nullable(), +}); +export type SiteReleasePage = z.infer; + export const WorkbenchReleaseDetail = z.object({ release: WorkbenchRelease, observations: z.array( @@ -657,5 +675,9 @@ export const WorkbenchReleaseDetail = z.object({ }), /** 刷新详情失败时保留既有数据并给出原因 */ refreshError: z.string().nullable(), + /** 详情页扩展内容;没抓过、缓存已过期或站点不支持详情页时为 null */ + page: SiteReleasePage.nullable(), + /** 站点能否抓详情页(false 时前端不自动抓) */ + detailSupported: z.boolean(), }); export type WorkbenchReleaseDetail = z.infer; diff --git a/packages/testkit/src/sites/fixtures/definitions/hhanclub.ts b/packages/testkit/src/sites/fixtures/definitions/hhanclub.ts index 8ebe97f5..e6f32a6e 100644 --- a/packages/testkit/src/sites/fixtures/definitions/hhanclub.ts +++ b/packages/testkit/src/sites/fixtures/definitions/hhanclub.ts @@ -80,3 +80,31 @@ export const HHANCLUB_LIST_PAGE_2026 = `HHCLUB </div> <div class="pagination"><a href="?page=0">1-100</a><a href="?page=1">101-200</a></div> </body></html>`; + +/** + * 详情页扩展内容(简介 / MediaInfo / 截图 / 外部链接 / 标签 / 上传者 / 文件清单)fixture: + * 结构按 HHClub div 模板(「标签」div + 相邻值 div)与 NexusPHP #kdescr 简介编写,数值均为示例。 + */ +export const HHANCLUB_DETAIL_PAGE_FULL = `<!DOCTYPE html><html><head><title>HHCLUB :: 种子详情 + +
+
标题
Example.Show.S01.2026.2160p.WEB-DL.H265.AAC-HHWEB
+
副标题
示例剧集 | 全12集 | 4K
+
大小:12.50 GB
+
标签
官方中字完结
+
上传者
+ +
简介
+
+ poster
+ ◎译  名 示例剧集
◎年  代 2026
+ ◎IMDb链接 https://www.imdb.com/title/tt14452776/
+ ◎豆瓣链接 https://movie.douban.com/subject/36000001/
+ ◎TMDB themoviedb
+ +
MediaInfo
General
Unique ID : 1234
Complete name : Example.Show.S01E01.mkv
Format : Matroska
File size : 1.04 GiB
Duration : 45 min

Video
ID : 1
Format : HEVC
Width : 3 840 pixels
+ +
+
文件列表
+
  • Example.Show.S01E01.mkv (1.04 GB)
  • Example.Show.S01E02.mkv (1.02 GB)
+
`; diff --git a/packages/testkit/src/sites/mock-nexusphp.ts b/packages/testkit/src/sites/mock-nexusphp.ts index fff286bf..2a1451cd 100644 --- a/packages/testkit/src/sites/mock-nexusphp.ts +++ b/packages/testkit/src/sites/mock-nexusphp.ts @@ -144,7 +144,7 @@ function detailBody(r: MockNexusRelease): string { 副标题${esc(r.subtitle ?? "")} 基本信息大小:${esc(r.sizeText)}   类型: ${esc(r.catName)}  发布于 ${esc(r.relativeTime ?? r.uploadedAt)} 同伴${r.seeders.replace(/,/g, "")}个做种者 | ${r.leechers.replace(/,/g, "")}个下载者 -简介
${esc(r.description ?? "暂无简介")}
+简介
${r.descriptionHtml ?? esc(r.description ?? "暂无简介")}
`; } diff --git a/packages/testkit/src/sites/nexusphp-fixtures.ts b/packages/testkit/src/sites/nexusphp-fixtures.ts index 908928ee..316d1b89 100644 --- a/packages/testkit/src/sites/nexusphp-fixtures.ts +++ b/packages/testkit/src/sites/nexusphp-fixtures.ts @@ -25,6 +25,8 @@ export interface MockNexusRelease { hr?: boolean; uploader?: string; description?: string; + /** 简介原样 HTML(优先于 description;用于详情页扩展内容测试) */ + descriptionHtml?: string; /** 下载返回磁力而不是 .torrent */ magnet?: string; } diff --git a/packages/testkit/src/tmdb/mock-tmdb.ts b/packages/testkit/src/tmdb/mock-tmdb.ts index 4ffbdb29..ce1fddd3 100644 --- a/packages/testkit/src/tmdb/mock-tmdb.ts +++ b/packages/testkit/src/tmdb/mock-tmdb.ts @@ -19,7 +19,7 @@ const notFound: MockReply = { json: { success: false, status_code: 34, status_message: "The resource you requested could not be found." }, }; -/** mock TMDB v3:/3/search/{movie,tv}、/3/{movie,tv}/:id(append_to_response)、/3/tv/:id/season/:n */ +/** mock TMDB v3:/3/search/{movie,tv}、/3/find/:imdbId、/3/{movie,tv}/:id(append_to_response)、/3/tv/:id/season/:n */ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise { const fx = opts.fixtures ?? TMDB_FIXTURES; const apiKey = opts.apiKey ?? "test-tmdb-key"; @@ -84,6 +84,23 @@ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise { + if (q.external_source !== "imdb_id") return { json: { movie_results: [], tv_results: [] } }; + const hit = fx.filter((f) => f.external_ids.imdb_id === m[1]); + return { + json: { + movie_results: hit + .filter((f) => f.type === "movie") + .map((f) => ({ ...brief(f, q.language), media_type: "movie" })), + tv_results: hit + .filter((f) => f.type === "tv") + .map((f) => ({ ...brief(f, q.language), media_type: "tv" })), + }, + }; + }, + ], [ /^\/3\/tv\/(\d+)\/season\/(\d+)$/, (m, q) => { diff --git a/scripts/mp-sync/movieclaw-overrides/hhanclub.json b/scripts/mp-sync/movieclaw-overrides/hhanclub.json index eb663d80..d8994c9f 100644 --- a/scripts/mp-sync/movieclaw-overrides/hhanclub.json +++ b/scripts/mp-sync/movieclaw-overrides/hhanclub.json @@ -26,6 +26,11 @@ "userDataIntervalSec": 21600, "searchConcurrency": 1, "detailCacheSec": 86400 + }, + "detail": { + "page": { + "labelSelectors": ["div.font-bold.leading-6", "span.font-bold"] + } } }, "torrents": {