feat(sites): 站点请求失败留样(site_failure_sample,每站最近 5 份)
- 迁移 0077 新表 site_failure_sample:类型(parse_error / challenge)、请求类型、脱敏 URL、状态码、
关键响应头、错误文案、命中规则、脱敏页面开头(≤ 64 KB);样本几十 KB,不放进每条请求都读的 site.config
- 适配器 onFailureSample 回调接到 runtime.siteAdapter,异步写入并删到最近 5 份,写失败只记日志
- GET /sites/{id}/failure-samples(不带正文)与 /failure-samples/{sampleId}(带正文)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
1779e1a0f2
commit
0088c1c9e7
10 files changed
+355
No files matched your search
@@ -17,6 +17,8 @@ import {
|
||||
SiteCredentialsPut,
|
||||
SiteCredentialsResult,
|
||||
SiteDefinitionView,
|
||||
SiteFailureSample,
|
||||
SiteFailureSampleSummary,
|
||||
SiteHealthResult,
|
||||
SiteRefreshLoginAccepted,
|
||||
SiteRefreshLoginInput,
|
||||
@@ -47,6 +49,7 @@ import {
|
||||
userDataHistory,
|
||||
} from "../service/account.ts";
|
||||
import { audit, closeDecisions, type Exec } from "../service/common.ts";
|
||||
import { getFailureSample, listFailureSamples } from "../service/failure-sample.ts";
|
||||
import { feedDefaultsForSite } from "../service/feeds.ts";
|
||||
import { assignReleaseIdentity } from "../service/identity-admin.ts";
|
||||
import { ensureRssSite, requireCapability, siteToApi, uniqueSiteKey } from "../site-kinds.ts";
|
||||
@@ -376,6 +379,37 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
},
|
||||
);
|
||||
|
||||
route(
|
||||
app,
|
||||
{
|
||||
method: "get",
|
||||
path: "/sites/{id}/failure-samples",
|
||||
scope: "read",
|
||||
summary: "站点请求失败留样:最近几次页面结构无法识别 / 人机验证时的脱敏页面(列表不带正文)",
|
||||
tags: T_SITES,
|
||||
params: IdParam,
|
||||
response: z.object({ items: z.array(SiteFailureSampleSummary) }),
|
||||
},
|
||||
async ({ params }) => ({ items: await listFailureSamples(rt().ctx, params.id) }),
|
||||
);
|
||||
route(
|
||||
app,
|
||||
{
|
||||
method: "get",
|
||||
path: "/sites/{id}/failure-samples/{sampleId}",
|
||||
scope: "read",
|
||||
summary: "一份失败留样(含脱敏后的页面正文)",
|
||||
tags: T_SITES,
|
||||
params: z.object({ id: Id, sampleId: Id }),
|
||||
response: SiteFailureSample,
|
||||
},
|
||||
async ({ params }) => {
|
||||
const s = await getFailureSample(rt().ctx, params.id, params.sampleId);
|
||||
if (!s) throw new ApiException("not_found", "样本不存在");
|
||||
return s;
|
||||
},
|
||||
);
|
||||
|
||||
// ── W9-A 站点中心:删除、健康检测、刷新登录、用户数据、凭证 ──
|
||||
route(
|
||||
app,
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from "../../shared-cache.ts";
|
||||
import type { ServerContext } from "../types.ts";
|
||||
import { CloudflareGate, withClearance } from "./service/cloudflare.ts";
|
||||
import { failureSampleSink } from "./service/failure-sample.ts";
|
||||
import { readBrowserUserAgent, resolveUserAgent, type UserAgentDefaults } from "./service/user-agent.ts";
|
||||
|
||||
// 模块运行时依赖:站点适配器与限流器(每站一个实例,令牌在 PG 跨进程共享)、RSS 抓取器、TMDB 客户端、时钟。
|
||||
@@ -333,6 +334,8 @@ export class SourcesRuntime {
|
||||
// 每条请求都经 siteFetch:代理 + 按账号现场取 UA(BR-4)
|
||||
fetch: this.siteFetch({ id: site.id, proxy }, defaults, () => this.siteUserAgent(site.id)),
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
// parse_error / challenge 的脱敏页面样本存进 site_failure_sample(每站最近 5 份)
|
||||
onFailureSample: failureSampleSink(this.ctx, site.id),
|
||||
});
|
||||
this.adapters.set(site.id, { sig, limiter, adapter });
|
||||
return adapter;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { SiteFailureSample as AdapterSample } from "@umt/adapters";
|
||||
import type { SiteFailureSample, SiteFailureSampleSummary } from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import type { ServerContext } from "../../types.ts";
|
||||
import { toApi } from "../rows.ts";
|
||||
|
||||
// 站点请求失败留样(表 site_failure_sample,迁移 0077):适配器报 parse_error / challenge 时交出脱敏页面样本
|
||||
// (SiteAdapterDeps.onFailureSample),这里存下来,每站只留最近 SAMPLES_PER_SITE 份。站点页「技术信息」里看。
|
||||
// 样本在适配器里已脱敏(个人信息块、passkey / authkey / uid / Cookie / 邮箱),这里不再处理正文。
|
||||
|
||||
/** 每站保留的样本数 */
|
||||
export const SAMPLES_PER_SITE = 5;
|
||||
|
||||
export async function recordFailureSample(ctx: ServerContext, siteId: string, s: AdapterSample): Promise<void> {
|
||||
await ctx.db
|
||||
.insertInto("site_failure_sample")
|
||||
.values({
|
||||
site_id: siteId,
|
||||
kind: s.kind,
|
||||
request_kind: s.requestKind,
|
||||
url: s.url,
|
||||
http_status: s.httpStatus,
|
||||
headers: JSON.stringify(s.headers),
|
||||
message: s.message.slice(0, 500),
|
||||
rule: s.rule,
|
||||
body: s.body,
|
||||
truncated: s.truncated,
|
||||
created_at: new Date(s.capturedAt),
|
||||
})
|
||||
.execute();
|
||||
await sql`delete from site_failure_sample where site_id = ${siteId} and id not in (
|
||||
select id from site_failure_sample where site_id = ${siteId} order by created_at desc, id desc limit ${SAMPLES_PER_SITE})`.execute(
|
||||
ctx.db,
|
||||
);
|
||||
}
|
||||
|
||||
/** 留样回调:不等写库,失败只记日志(不影响站点请求本身的结果) */
|
||||
export function failureSampleSink(ctx: ServerContext, siteId: string): (s: AdapterSample) => void {
|
||||
return (s) => {
|
||||
void recordFailureSample(ctx, siteId, s).catch((err) =>
|
||||
ctx.logger.warn({ siteId, err: { message: (err as Error).message } }, "站点失败页面留样写入失败"),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export async function listFailureSamples(ctx: ServerContext, siteId: string): Promise<SiteFailureSampleSummary[]> {
|
||||
const rows = await ctx.db
|
||||
.selectFrom("site_failure_sample")
|
||||
.select([
|
||||
"id",
|
||||
"site_id",
|
||||
"kind",
|
||||
"request_kind",
|
||||
"url",
|
||||
"http_status",
|
||||
"headers",
|
||||
"message",
|
||||
"rule",
|
||||
"truncated",
|
||||
"created_at",
|
||||
sql<number>`octet_length(body)`.as("body_bytes"),
|
||||
])
|
||||
.where("site_id", "=", siteId)
|
||||
.orderBy("created_at", "desc")
|
||||
.orderBy("id", "desc")
|
||||
.limit(SAMPLES_PER_SITE)
|
||||
.execute();
|
||||
return rows.map((r) => toApi<SiteFailureSampleSummary>(r));
|
||||
}
|
||||
|
||||
export async function getFailureSample(
|
||||
ctx: ServerContext,
|
||||
siteId: string,
|
||||
sampleId: string,
|
||||
): Promise<SiteFailureSample | null> {
|
||||
const row = await ctx.db
|
||||
.selectFrom("site_failure_sample")
|
||||
.selectAll()
|
||||
.where("site_id", "=", siteId)
|
||||
.where("id", "=", sampleId)
|
||||
.executeTakeFirst();
|
||||
return row ? toApi<SiteFailureSample>(row) : null;
|
||||
}
|
||||
Reference in new issue
Block a user