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 ; + if (q.error) return void q.refetch()} />; + return ( +
+
+ +
+
+        {q.data.body || "(页面是空的)"}
+      
+
+ ); +} + +/** 一份样本:标题一行,展开看请求信息与页面 */ +export function FailureSampleRow({ siteId, sample }: { siteId: string; sample: SiteFailureSampleSummary }) { + const [open, setOpen] = useState(false); + return ( +
  • + + {open ? ( +
    + + +
    + ) : null} +
  • + ); +} + +/** 最近的失败样本(列表由调用方用 useSiteFailureSamples 取);没有就不渲染 */ +export function SiteFailureSamples({ siteId, items }: { siteId: string; items: SiteFailureSampleSummary[] }) { + if (!items.length) return null; + return ( +
    +

    最近的失败页面(已脱敏,留最近 5 份)

    +
      + {items.map((s) => ( + + ))} +
    +
    + ); +} diff --git a/apps/web/app/components/sites/site-fields.ts b/apps/web/app/components/sites/site-fields.ts index 1c43017f..770fcae2 100644 --- a/apps/web/app/components/sites/site-fields.ts +++ b/apps/web/app/components/sites/site-fields.ts @@ -6,6 +6,8 @@ import { type SiteAuthKind, type SiteCapability, type SiteDefinitionView, + type SiteFailureSample, + type SiteFailureSampleSummary, type SiteKind, type SiteLastHealth, type SiteListItem, @@ -240,6 +242,58 @@ export function cloudflareTechItems(config: unknown): { label: string; value: st ].filter((it) => it.value); } +const SAMPLE_KIND: Record = { parse_error: "页面结构无法识别", challenge: "人机验证" }; +const SAMPLE_REQUEST: Record = { + search: "搜索", + list: "列表", + detail: "详情", + health: "健康检测", + other: "请求", +}; + +/** 失败样本一行:"搜索 · 人机验证 · HTTP 200" */ +export function failureSampleTitle(s: Pick): string { + return [ + SAMPLE_REQUEST[s.requestKind] ?? s.requestKind, + SAMPLE_KIND[s.kind] ?? s.kind, + s.httpStatus !== null ? `HTTP ${s.httpStatus}` : null, + ] + .filter(Boolean) + .join(" · "); +} + +/** 失败样本的技术信息条目(地址、响应头、规则、文案) */ +export function failureSampleItems(s: SiteFailureSampleSummary): { label: string; value: string; mono?: boolean }[] { + return [ + { label: "地址", value: s.url, mono: true }, + { label: "错误", value: s.message }, + { label: "命中规则", value: s.rule ?? "", mono: true }, + ...Object.entries(s.headers).map(([k, v]) => ({ label: k, value: v, mono: true })), + { label: "页面", value: `${formatBytes(s.bodyBytes)}${s.truncated ? "(只留了开头)" : ""}` }, + ].filter((it) => it.value); +} + +/** 下载用的文本:头部是请求信息,后面是脱敏后的页面 */ +export function failureSampleText(s: SiteFailureSample): string { + const head = [ + `# 站点失败样本(已脱敏)`, + `# 时间:${s.createdAt}`, + `# 类型:${failureSampleTitle(s)}`, + `# 地址:${s.url}`, + ...Object.entries(s.headers).map(([k, v]) => `# ${k}: ${v}`), + ...(s.rule ? [`# 规则:${s.rule}`] : []), + `# 错误:${s.message}`, + ...(s.truncated ? ["# 页面超过 64 KB,只留了开头"] : []), + ]; + return `${head.join("\n")}\n\n${s.body}`; +} + +/** 下载文件名:site-sample-<请求类型>-<类型>-<时间>.html.txt */ +export function failureSampleFileName(s: Pick): string { + const t = s.createdAt.replace(/[-:]/g, "").replace(/\..*$/, "").replace("T", "-"); + return `site-sample-${s.requestKind}-${s.kind}-${t}.html.txt`; +} + /** 限流:"20 次/分";空对象 = 默认 */ export function rateLimitText(r: SiteRateLimit | null | undefined): string { if (!r || (!r.requestsPerMinute && !r.minIntervalMs && !r.concurrency)) return "默认"; diff --git a/apps/web/app/components/sites/site-overview.tsx b/apps/web/app/components/sites/site-overview.tsx index c25aac7a..5b1df31d 100644 --- a/apps/web/app/components/sites/site-overview.tsx +++ b/apps/web/app/components/sites/site-overview.tsx @@ -1,7 +1,7 @@ import type { SiteListItem } from "@umt/contracts"; import { Link } from "react-router"; import { useFeeds } from "~/api/feeds.ts"; -import { accessSummary, useSiteRequestStats, useSiteUserData } from "~/api/sites.ts"; +import { accessSummary, useSiteFailureSamples, useSiteRequestStats, useSiteUserData } from "~/api/sites.ts"; import { DetailSection } from "~/components/console/detail-sheet.tsx"; import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { KvList } from "~/components/console/kv-list.tsx"; @@ -10,6 +10,7 @@ import { StatTile } from "~/components/console/stat-tile.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { TechInfo } from "~/components/console/tech-info.tsx"; import { relTime } from "~/components/workbench/release-fields.ts"; +import { SiteFailureSamples } from "./site-failure-samples.tsx"; import { ACCOUNT_OVERALL, AUTH_KIND_LABEL, @@ -116,6 +117,7 @@ function AccountSiteOverview({ site }: { site: SiteListItem }) { const u = dAcc?.userData ?? null; const history = useSiteUserData(site.id, dAcc?.id, 30); const stats = useSiteRequestStats(site.id); + const samples = useSiteFailureSamples(site.id).data?.items ?? []; const st = ACCOUNT_OVERALL[overallStatus(site)]; const h = healthLine(site.lastHealth, site.lastHealthAt); @@ -215,7 +217,9 @@ function AccountSiteOverview({ site }: { site: SiteListItem }) { className="mt-2" columns={1} items={[...healthTechItems(site.lastHealth), ...cloudflareTechItems(site.config)]} - /> + > + {samples.length ? : null} +