diff --git a/apps/web/app/api/sites.ts b/apps/web/app/api/sites.ts index 7a29b6f4..9001b477 100644 --- a/apps/web/app/api/sites.ts +++ b/apps/web/app/api/sites.ts @@ -9,6 +9,8 @@ import { type SiteCredentialsBody, SiteCredentialsResult, SiteDefinitionView, + SiteFailureSample, + SiteFailureSampleSummary, SiteHealthResult, SiteListItem, SiteRefreshLoginAccepted, @@ -35,6 +37,8 @@ export const siteKeys = { accounts: (siteId?: string) => ["sites", "accounts", siteId ?? "all"] as const, snapshots: (siteId: string) => ["sites", "snapshots", siteId] as const, requestStats: (siteId: string) => ["sites", "request-stats", siteId] as const, + failureSamples: (siteId: string) => ["sites", "failure-samples", siteId] as const, + failureSample: (siteId: string, sampleId: string) => ["sites", "failure-samples", siteId, sampleId] as const, definitions: () => ["sites", "definitions"] as const, userData: (siteId: string, accountId?: string, days = 30) => ["sites", "user-data", siteId, accountId ?? "first", days] as const, @@ -105,6 +109,33 @@ export function useSiteRequestStats(siteId: string | undefined) { }); } +/** 最近的失败留样(页面结构无法识别 / 人机验证;每站最近 5 份,不带正文) */ +export function useSiteFailureSamples(siteId: string | undefined) { + return useQuery({ + queryKey: siteKeys.failureSamples(siteId ?? ""), + enabled: !!siteId, + queryFn: ({ signal }) => + apiParsed(z.object({ items: z.array(SiteFailureSampleSummary) }), "/api/v1/sites/:id/failure-samples", { + pathParams: { id: siteId ?? "" }, + signal, + }), + }); +} + +/** 一份失败留样(带脱敏后的页面正文);展开时才取 */ +export function useSiteFailureSample(siteId: string, sampleId: string, enabled: boolean) { + return useQuery({ + queryKey: siteKeys.failureSample(siteId, sampleId), + enabled, + staleTime: Number.POSITIVE_INFINITY, + queryFn: ({ signal }) => + apiParsed(SiteFailureSample, "/api/v1/sites/:id/failure-samples/:sampleId", { + pathParams: { id: siteId, sampleId }, + signal, + }), + }); +} + /** 访问默认的一句话说明:"快照每 60 分钟 1 页 · 健康检测每 6 小时 · 详情缓存 24 小时" */ export function accessSummary(s: SiteRequestStats): string { const dur = (sec: number) => (sec % 3600 === 0 ? `${sec / 3600} 小时` : `${Math.round(sec / 60)} 分钟`); diff --git a/apps/web/app/components/sites/site-failure-samples.test.ts b/apps/web/app/components/sites/site-failure-samples.test.ts new file mode 100644 index 00000000..ff397efc --- /dev/null +++ b/apps/web/app/components/sites/site-failure-samples.test.ts @@ -0,0 +1,56 @@ +import type { SiteFailureSample } from "@umt/contracts"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { SiteFailureSamples } from "./site-failure-samples.tsx"; +import { failureSampleFileName, failureSampleItems, failureSampleText, failureSampleTitle } from "./site-fields.ts"; + +// 站点页技术信息里的失败样本:一行说清是搜索 / 列表、人机验证还是页面结构变化;展开看地址、响应头、页面,可下载文本。 + +const sample: SiteFailureSample = { + id: "0f1e2d3c-4b5a-4987-8a6b-5c4d3e2f1a0b", + siteId: "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + kind: "challenge", + requestKind: "search", + url: "https://audiences.me/torrents.php?search=Dune&passkey=[REDACTED]", + httpStatus: 200, + headers: { server: "cloudflare", "cf-ray": "8c00-HKG" }, + message: "搜索:搜索页需要人机验证(Cloudflare 等),需要你在原站视图里过一次验证", + rule: "gate*=cf-turnstile", + body: '
', + truncated: false, + createdAt: "2026-10-06T07:30:05.000Z", +}; +const { body: _b, ...summary } = sample; +const row = { ...summary, bodyBytes: 44 }; + +describe("站点失败样本展示", () => { + it("标题区分请求类型与失败类型", () => { + expect(failureSampleTitle(sample)).toBe("搜索 · 人机验证 · HTTP 200"); + expect(failureSampleTitle({ kind: "parse_error", requestKind: "list", httpStatus: null })).toBe( + "列表 · 页面结构无法识别", + ); + }); + + it("技术信息条目:地址、错误、规则、关键响应头、页面大小", () => { + const items = failureSampleItems(row); + expect(items.map((i) => i.label)).toEqual(["地址", "错误", "命中规则", "server", "cf-ray", "页面"]); + expect(items[0]?.value).toContain("passkey=[REDACTED]"); + }); + + it("下载文本带请求信息头;文件名带请求类型、类型与时间", () => { + const text = failureSampleText(sample); + expect(text).toContain("# 地址:https://audiences.me/torrents.php"); + expect(text).toContain("# cf-ray: 8c00-HKG"); + expect(text.endsWith(sample.body)).toBe(true); + expect(failureSampleFileName(sample)).toBe("site-sample-search-challenge-20261006-073005.html.txt"); + }); + + it("列表渲染:每份一行(收起),没有样本不渲染", () => { + const html = renderToStaticMarkup(createElement(SiteFailureSamples, { siteId: sample.siteId, items: [row] })); + expect(html).toContain("最近的失败页面"); + expect(html).toContain("搜索 · 人机验证 · HTTP 200"); + expect(html).toContain('aria-expanded="false"'); + expect(renderToStaticMarkup(createElement(SiteFailureSamples, { siteId: sample.siteId, items: [] }))).toBe(""); + }); +}); diff --git a/apps/web/app/components/sites/site-failure-samples.tsx b/apps/web/app/components/sites/site-failure-samples.tsx new file mode 100644 index 00000000..eec8592f --- /dev/null +++ b/apps/web/app/components/sites/site-failure-samples.tsx @@ -0,0 +1,81 @@ +import type { SiteFailureSample, SiteFailureSampleSummary } from "@umt/contracts"; +import { Download } from "lucide-react"; +import { useState } from "react"; +import { useSiteFailureSample } from "~/api/sites.ts"; +import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; +import { KvList } from "~/components/console/kv-list.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { relTime } from "~/components/workbench/release-fields.ts"; +import { failureSampleFileName, failureSampleItems, failureSampleText, failureSampleTitle } from "./site-fields.ts"; + +// 站点请求失败留样(技术信息里):最近几次「页面结构无法识别」/「人机验证」时站点返回的页面(已脱敏), +// 用来分清是站点改版还是被人机验证拦了。列表不带正文,展开时再取。 + +function download(s: SiteFailureSample) { + const url = URL.createObjectURL(new Blob([failureSampleText(s)], { type: "text/plain;charset=utf-8" })); + const a = document.createElement("a"); + a.href = url; + a.download = failureSampleFileName(s); + a.click(); + URL.revokeObjectURL(url); +} + +function SampleBody({ siteId, sampleId }: { siteId: string; sampleId: string }) { + const q = useSiteFailureSample(siteId, sampleId, true); + if (q.isPending) return
+ {q.data.body || "(页面是空的)"}
+
+