feat(sites): on-demand site detail page content (INT-2)

- SiteReleaseDetail.page: description, MediaInfo/BDInfo, screenshots,
  IMDb / Douban / TMDB ids, file list, tags, uploader, plus a parse probe
  (labels found) for layout-change diagnosis. Generic NexusPHP rules
  (field label -> next sibling) starting from the MovieClaw detail
  selectors; site overrides under umt.detail.page (HHClub: div labels).
  M-Team builds the same structure from descr BBCode + mediainfo.
- Fetched only on demand (drawer open without cache, "复核详情",
  release.inspect); content in kv_cache region site.detail for the
  site detail cache period (>= 24 h); within the window the site is not
  requested again. External ids persist in source_release.raw._detail,
  which list snapshots do not overwrite.
- Screenshots only as proxy paths /api/v1/img/site-detail/{rid}/{i}:
  public-address check on every hop, no site credentials, magic-byte
  image check, 8 MB cap. Poster proxy shares the same helper.
- Identity: site ids count as explicit-id evidence; when TMDB is
  queried, a TMDB id goes straight to details and an IMDb id to
  /find (no title search on a hit). Douban only for local recall.
- Release drawer "站点详情页" section; fixtures and tests for HHClub div
  layout, rowhead layout, BBCode, cache/proxy/identity end to end.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 16:21:17 +08:00
1 parent 6e9e8efec2
commit 04cffcd1f1
30 files changed
+1133 -64

No files matched your search

@@ -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<string>());
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 (
<DetailSheet
open={!!release}
@@ -216,6 +227,21 @@ export function ReleaseDetailSheet({
) : null}
</DetailSection>
<DetailSection title="站点详情页">
{detail.data?.page ? (
<ReleasePageContent page={detail.data.page} />
) : refresh.isPending || detail.isPending ? (
<LoadingState rows={2} />
) : detail.data && !detail.data.detailSupported ? (
<p className="text-caption text-muted-foreground">这个站点不支持抓详情页。</p>
) : (
<p className="text-caption text-muted-foreground">
还没有详情页内容{detail.data?.refreshError ? `(${detail.data.refreshError})` : ""}
。点「复核详情」抓取。
</p>
)}
</DetailSection>
<DetailSection title="本地库存与候选理由">
<LocalInventory release={r} entry={entry} />
</DetailSection>
@@ -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<SiteReleasePage, "imdbId" | "doubanId" | "tmdb">) {
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 (
<div className="space-y-3">
{links.length || page.uploader ? (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-caption">
{links.map((l) => (
<a
key={l.href}
href={l.href}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1 text-foreground underline underline-offset-4"
>
{l.label}
<ExternalLink className="size-3" />
</a>
))}
{page.uploader ? <span className="text-muted-foreground">上传者 {page.uploader}</span> : null}
</div>
) : null}
{page.tags.length ? (
<div className="flex flex-wrap gap-1.5">
{page.tags.map((t) => (
<StatusBadge key={t}>{t}</StatusBadge>
))}
</div>
) : null}
{page.descriptionText ? (
<p className="max-h-72 overflow-auto text-caption break-words whitespace-pre-wrap text-foreground">
{page.descriptionText}
</p>
) : null}
{page.mediaInfo ? (
<details className="rounded-lg border border-border/70 px-3 py-2">
<summary className="cursor-pointer text-caption text-foreground">MediaInfo</summary>
<pre className="mt-2 max-h-80 overflow-auto font-mono text-meta whitespace-pre text-muted-foreground">
{page.mediaInfo}
</pre>
</details>
) : null}
{page.screenshots.length ? (
<div className="grid grid-cols-2 gap-2">
{page.screenshots.map((s, i) => (
<a key={s.url} href={s.url} target="_blank" rel="noreferrer noopener" title={s.host}>
<img
src={s.url}
alt={`截图 ${i + 1}`}
loading="lazy"
className="aspect-video w-full rounded-md border border-border/70 bg-muted object-cover"
/>
</a>
))}
</div>
) : null}
{page.files.length ? (
<details className="rounded-lg border border-border/70 px-3 py-2">
<summary className="cursor-pointer text-caption text-foreground">文件清单({page.files.length})</summary>
<ul className="mt-2 max-h-60 space-y-1 overflow-auto text-meta">
{page.files.map((f, i) => (
<li key={`${i}-${f.name}`} className="flex justify-between gap-3">
<span className="min-w-0 font-mono break-all text-foreground">{f.name}</span>
<span className="shrink-0 text-muted-foreground">
{f.sizeBytes === null ? "" : formatBytes(f.sizeBytes, 1)}
</span>
</li>
))}
</ul>
</details>
) : null}
{empty ? <p className="text-caption text-muted-foreground">详情页上没有解析到简介、截图或外部链接。</p> : null}
<p className="text-meta text-muted-foreground">
抓取于 {formatDateTime(page.fetchedAt)},缓存 24 小时;截图经服务端代理加载。
</p>
</div>
);
}