docs(plan): on-demand site detail page parsing (HHClub details.php)

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-29 15:13:35 +08:00
1 parent 94fd48bff2
commit ac478673bc
39 files changed
+2446 -339

No files matched your search

@@ -29,7 +29,8 @@ import { cn, formatBytes } from "~/lib/utils.ts";
import { delegateToAgent, SearchDownloadDialog } from "./search-download-dialog.tsx";
import { gbToBytes, INVENTORY_STATE, promoLabel, SITE_STATUS, SORT_OPTIONS } from "./search-labels.ts";
// 资源段:跨站结果的筛选(站点/分辨率/来源/大小/免费/H&R)、排序、游标;行操作同站点工作台。
// 资源段(SEARCH-2 第二阶段):按作品或关键词、在选中的站点上搜;筛选(站点/分辨率/来源/大小/免费/H&R)、排序、游标;
// 行操作同站点工作台(库存叠加、下载、委托 Agent)。
/** URL 里的资源筛选(字符串形态,空串 = 不筛) */
export type ReleaseFilterState = {
@@ -55,12 +56,25 @@ export const RELEASE_FILTER_KEYS = [
"order",
] as const satisfies readonly (keyof ReleaseFilterState)[];
export function toApiFilters(q: string, f: ReleaseFilterState): ReleaseSearchFilters {
/** 搜什么:关键词,或按作品(服务端生成查询词) */
export type ReleaseTarget = { q?: string; entityId?: string; tmdb?: string };
/** URL 参数 work → 搜索目标:local:<id> / tmdb:<type>:<id> / q:<关键词> */
export function parseWorkRef(ref: string): ReleaseTarget | null {
if (ref.startsWith("local:")) return { entityId: ref.slice(6) };
const m = /^tmdb:(movie|tv):(\d+)$/.exec(ref);
if (m) return { tmdb: `${m[1]}:${m[2]}` };
if (ref.startsWith("q:") && ref.slice(2).trim()) return { q: ref.slice(2).trim() };
return null;
}
export function toApiFilters(target: ReleaseTarget, f: ReleaseFilterState, siteIds = ""): ReleaseSearchFilters {
const bool = (v: string) => (v === "true" || v === "false" ? v : undefined);
const sort = SORT_OPTIONS.some((o) => o.value === f.sort) ? (f.sort as ReleaseSearchFilters["sort"]) : undefined;
return {
q,
sites: f.sites || undefined,
...target,
sites: siteIds || undefined,
siteFilter: f.sites || undefined,
resolution: f.resolution || undefined,
source: f.source || undefined,
minSize: gbToBytes(f.minGb),
@@ -75,17 +89,22 @@ export function toApiFilters(q: string, f: ReleaseFilterState): ReleaseSearchFil
const csvList = (s: string) => (s ? s.split(",").filter(Boolean) : []);
export function ReleaseResults({
q,
target,
siteIds,
filters,
setFilter,
resetFilters,
onChangeSites,
}: {
q: string;
target: ReleaseTarget;
/** 选中的站点(逗号分隔);空 = 服务端按默认站点设置 */
siteIds: string;
onChangeSites?: () => void;
filters: ReleaseFilterState;
setFilter: (k: keyof ReleaseFilterState, v: string) => void;
resetFilters: () => void;
}) {
const api = useMemo(() => toApiFilters(q, filters), [q, filters]);
const api = useMemo(() => toApiFilters(target, filters, siteIds), [target, filters, siteIds]);
const res = useReleaseSearch(api);
const refresh = useRefreshReleaseSearch();
const pages = res.data?.pages ?? [];
@@ -109,20 +128,29 @@ export function ReleaseResults({
) : null}
</h2>
{first ? (
<p className="mt-0.5 text-meta text-muted-foreground tabular-nums">
{first.cached ? "缓存结果" : "站点实时结果"} · 抓取于 {relTime(first.fetchedAt)} · {first.tookMs} ms
<p className="mt-0.5 text-meta break-words text-muted-foreground tabular-nums">
{first.sites.length} 个站点 · {first.cached ? "缓存结果" : "站点实时结果"} · 抓取于{" "}
{relTime(first.fetchedAt)} · {first.tookMs} ms
{first.queries?.length ? ` · 查询词 ${first.queries.map((x) => `「${x}」`).join("")}` : ""}
</p>
) : null}
</div>
<Button
variant="outline"
size="sm"
disabled={refresh.isPending || res.isFetching}
onClick={() => refresh.mutate(api, { onError: (e) => toast.error(errorMessage(e, "刷新失败")) })}
>
<RefreshCw className={cn(refresh.isPending && "animate-spin")} />
{refresh.isPending ? "查询中…" : "刷新"}
</Button>
<div className="flex flex-wrap gap-1">
{onChangeSites ? (
<Button variant="ghost" size="sm" onClick={onChangeSites}>
换站点
</Button>
) : null}
<Button
variant="outline"
size="sm"
disabled={refresh.isPending || res.isFetching}
onClick={() => refresh.mutate(api, { onError: (e) => toast.error(errorMessage(e, "刷新失败")) })}
>
<RefreshCw className={cn(refresh.isPending && "animate-spin")} />
{refresh.isPending ? "查询中…" : "刷新"}
</Button>
</div>
</div>
{first ? <CoverageNotice page={first} /> : null}
@@ -143,8 +171,8 @@ export function ReleaseResults({
<EmptyState
plain
icon={PackageSearch}
title={filtered ? "没有符合筛选条件的资源" : "各站点都没有找到资源"}
description={filtered ? "放宽筛选条件再看。" : "换个关键词,或到站点工作台检查站点状态。"}
title={filtered ? "没有符合筛选条件的资源" : "所选站点都没有找到资源"}
description={filtered ? "放宽筛选条件再看。" : "换站点或换关键词再搜,或到站点页检查站点状态。"}
className="py-12"
/>
) : (