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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user