feat(web): 站点页技术信息里显示最近的失败页面样本
站点概览「站点」一栏的技术信息里列出最近 5 份失败样本(搜索 / 列表 / 详情 · 人机验证 / 页面结构无法识别 · HTTP 状态),展开看脱敏地址、错误、命中规则、关键响应头和页面正文,可下载文本;没有样本不显示。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
bd475a4ce1
commit
ec8b224482
5 files changed
+228
-2
No files matched your search
@@ -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)} 分钟`);
|
||||
|
||||
@@ -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: '<html><div class="cf-turnstile"></div></html>',
|
||||
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("");
|
||||
});
|
||||
});
|
||||
@@ -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 <LoadingState rows={2} />;
|
||||
if (q.error) return <ErrorState plain title="读取样本失败" error={q.error} onRetry={() => void q.refetch()} />;
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<div>
|
||||
<Button size="xs" variant="outline" onClick={() => download(q.data)}>
|
||||
<Download aria-hidden />
|
||||
下载文本
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="max-h-80 overflow-auto rounded-lg bg-muted/40 p-2 font-mono text-caption break-all whitespace-pre-wrap text-foreground">
|
||||
{q.data.body || "(页面是空的)"}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一份样本:标题一行,展开看请求信息与页面 */
|
||||
export function FailureSampleRow({ siteId, sample }: { siteId: string; sample: SiteFailureSampleSummary }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<li className="min-w-0 rounded-lg border border-border/60 px-3 py-2" data-slot="failure-sample">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex w-full flex-wrap items-baseline justify-between gap-x-3 text-left text-sm text-foreground"
|
||||
>
|
||||
<span>{failureSampleTitle(sample)}</span>
|
||||
<span className="text-meta text-muted-foreground">
|
||||
{relTime(sample.createdAt)} · {open ? "收起" : "查看"}
|
||||
</span>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="mt-2 grid gap-3">
|
||||
<KvList columns={1} items={failureSampleItems(sample)} />
|
||||
<SampleBody siteId={siteId} sampleId={sample.id} />
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** 最近的失败样本(列表由调用方用 useSiteFailureSamples 取);没有就不渲染 */
|
||||
export function SiteFailureSamples({ siteId, items }: { siteId: string; items: SiteFailureSampleSummary[] }) {
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<section className="grid gap-2">
|
||||
<h3 className="text-meta text-muted-foreground">最近的失败页面(已脱敏,留最近 5 份)</h3>
|
||||
<ul className="grid gap-2">
|
||||
{items.map((s) => (
|
||||
<FailureSampleRow key={s.id} siteId={siteId} sample={s} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = { parse_error: "页面结构无法识别", challenge: "人机验证" };
|
||||
const SAMPLE_REQUEST: Record<string, string> = {
|
||||
search: "搜索",
|
||||
list: "列表",
|
||||
detail: "详情",
|
||||
health: "健康检测",
|
||||
other: "请求",
|
||||
};
|
||||
|
||||
/** 失败样本一行:"搜索 · 人机验证 · HTTP 200" */
|
||||
export function failureSampleTitle(s: Pick<SiteFailureSampleSummary, "kind" | "requestKind" | "httpStatus">): 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<SiteFailureSample, "kind" | "requestKind" | "createdAt">): 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 "默认";
|
||||
|
||||
@@ -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 ? <SiteFailureSamples siteId={site.id} items={samples} /> : null}
|
||||
</TechInfo>
|
||||
</DetailSection>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user