fix(sites): preserve remote throttling reasons and HTTP diagnostics

行为对照:MoviePilot v3.2.4,6ca7b294/f4a68f36;复用现有站点共享桶,明确频控按远端秒数冷却,错误标题脱敏;修复有界响应读取对单个大数据块的限制。
This commit is contained in:
root committed 2026-11-06 00:02:10 +08:00
1 parent 32e1d446b5
commit b44ebc2ba1
3 files changed
+233 -13

No files matched your search

+7 -3
View File
@@ -126,7 +126,7 @@ function concatBytes(chunks: Uint8Array[], total: number): Uint8Array<ArrayBuffe
}
/** 先读最多 max 字节;返回读到的部分、是否读完,以及一个内容不变的新响应(没读完的部分接着原流) */
async function peekBody(
export async function peekBody(
res: Response,
max: number,
): Promise<{ head: Uint8Array<ArrayBuffer>; complete: boolean; res: Response }> {
@@ -134,14 +134,17 @@ async function peekBody(
const chunks: Uint8Array[] = [];
let total = 0;
let complete = false;
let remainder: Uint8Array | undefined;
while (total < max) {
const r = await reader.read();
if (r.done) {
complete = true;
break;
}
chunks.push(r.value);
total += r.value.byteLength;
const length = Math.min(r.value.byteLength, max - total);
chunks.push(r.value.subarray(0, length));
total += length;
if (length < r.value.byteLength) remainder = r.value.subarray(length);
}
const head = concatBytes(chunks, total);
const init = { status: res.status, statusText: res.statusText, headers: res.headers };
@@ -149,6 +152,7 @@ async function peekBody(
const stream = new ReadableStream<Uint8Array>({
start(c) {
c.enqueue(head);
if (remainder) c.enqueue(remainder);
},
async pull(c) {
const r = await reader.read();
+136
View File
@@ -0,0 +1,136 @@
import { describe, expect, it, vi } from "vitest";
import { peekBody } from "./challenge.ts";
import { type SiteHttpContext, siteFetch } from "./http.ts";
import { createSharedSiteRateLimiter, createSiteRateLimiter } from "./rate-limit.ts";
import { SiteError } from "./types.ts";
const url = "https://tracker.example/download?id=1&passkey=private-key";
const frequency = "Request frequency limit, please try in 23 seconds.";
function context(response: Response) {
const limiter = createSiteRateLimiter({ minIntervalMs: 0 });
const penalize = vi.spyOn(limiter, "penalize");
const fetch = vi.fn(async () => response);
const ctx: SiteHttpContext = { siteId: "tracker", fetch, limiter, timeoutMs: 1000, now: () => new Date(0) };
return { ctx, penalize, fetch };
}
describe("站点远端频控", () => {
it.each([200, 403, 429])("HTTP %i 的HTML频控保留原因并按明确秒数顺延", async (status) => {
const { ctx, penalize, fetch } = context(
new Response(`<html><h1>Blocked</h1><p>${frequency}</p></html>`, {
status,
headers: { "content-type": "text/html" },
}),
);
const error = await siteFetch(ctx, url).catch((e: unknown) => e);
expect(error).toBeInstanceOf(SiteError);
expect(error).toMatchObject({ kind: "rate_limited", httpStatus: status, retryAfterMs: 23_000, isAuthError: false });
expect((error as Error).message).toContain(frequency);
expect((error as Error).message).not.toContain("private-key");
expect(penalize).toHaveBeenCalledExactlyOnceWith(23_000);
expect(fetch).toHaveBeenCalledTimes(1);
});
it.each([
["相同種子當天最多下載12次", 86_400_000],
["Too many requests, retry after 90 minutes", 5_400_000],
[frequency, 23_000],
])("JSON限流提示保留站点原因:%s", async (message, waitMs) => {
const { ctx, penalize } = context(Response.json({ code: "1", msg: message }));
const error = await siteFetch(ctx, url).catch((e: unknown) => e);
expect(error).toMatchObject({ kind: "rate_limited", retryAfterMs: waitMs });
expect((error as Error).message).toContain(message);
expect(penalize).toHaveBeenCalledExactlyOnceWith(waitMs);
});
it("Retry-After比正文要求更长时不缩短冷却", async () => {
const { ctx, penalize } = context(
new Response(`<p>${frequency}</p>`, { status: 429, headers: { "retry-after": "60" } }),
);
await expect(siteFetch(ctx, url)).rejects.toMatchObject({ retryAfterMs: 60_000 });
expect(penalize).toHaveBeenCalledExactlyOnceWith(60_000);
});
it("频控顺延已有站点共享桶,API与下载主机仍用原站点ID", async () => {
const bucket = {
acquire: vi.fn(async () => ({ ok: true, waitedMs: 0 })),
penalize: vi.fn(async () => {}),
};
const { ctx } = context(Response.json({ message: frequency }));
ctx.limiter = createSharedSiteRateLimiter({ key: "site:original-id", bucket, minIntervalMs: 1000 });
await expect(siteFetch(ctx, "https://temporary-download.example/file")).rejects.toMatchObject({
kind: "rate_limited",
});
expect(bucket.penalize).toHaveBeenCalledExactlyOnceWith("site:original-id", 23_000, {
capacity: 1,
refillPerSec: 1,
});
});
it("挑战页优先于频控文字,不将Cloudflare错误当作凭据错误", async () => {
const { ctx, penalize } = context(
new Response(`<title>Just a moment...</title><p>${frequency}</p>`, { status: 403 }),
);
await expect(siteFetch(ctx, url)).rejects.toMatchObject({ kind: "challenge", isAuthError: false });
expect(penalize).not.toHaveBeenCalled();
});
it.each([
'{"message":"too many requests"',
'{"message":{"error":"too many requests"}}',
'["too many requests"]',
'{"message":"success"}',
"<html><table><tr><td>站点限流规则说明</td></tr></table></html>",
])("不把非错误或无效结构猜成限流:%s", async (body) => {
const { ctx, penalize } = context(new Response(body));
expect(await (await siteFetch(ctx, url)).text()).toBe(body);
expect(penalize).not.toHaveBeenCalled();
});
it("有效种子的文件名即使包含频控句子也原样返回", async () => {
const bytes = new TextEncoder().encode(`d4:infod4:name${frequency.length}:${frequency}ee`);
const { ctx, penalize } = context(new Response(bytes, { headers: { "content-type": "application/x-bittorrent" } }));
expect(new Uint8Array(await (await siteFetch(ctx, url)).arrayBuffer())).toEqual(bytes);
expect(penalize).not.toHaveBeenCalled();
});
});
describe("站点HTTP失败诊断", () => {
it.each([429, 500, 503])("HTTP %i附状态和规范化页面标题,正文敏感信息不进入错误", async (status) => {
const { ctx } = context(
new Response(
`<title>\n Service &amp; offline passkey&#61;private-key session=session-secret token: "token-secret" [email protected]\n</title><body>cookie=body-private</body>`,
{ status, headers: { "content-type": "text/html" } },
),
);
const error = await siteFetch(ctx, url).catch((e: unknown) => e);
expect(error).toMatchObject({ httpStatus: status, detail: { reason: "http", httpStatus: status } });
expect((error as Error).message).toContain(`HTTP ${status}`);
expect((error as Error).message).toContain("页面标题:Service & offline");
expect((error as Error).message).not.toMatch(
/private-key|session-secret|token-secret|[email protected]|body-private/,
);
expect((error as SiteError).detail?.title).not.toMatch(/\n| {2}/);
expect((error as SiteError).detail?.title?.length).toBeLessThanOrEqual(160);
});
it("未识别的403保持原响应给适配器判定", async () => {
const body = "<title>Forbidden</title><p>No permission</p>";
const { ctx } = context(new Response(body, { status: 403 }));
const res = await siteFetch(ctx, url);
expect(res.status).toBe(403);
expect(await res.text()).toBe(body);
});
it("单次stream块超过读取预算时只检查前缀,返回正文保持完整", async () => {
const body = `<html>${"x".repeat(70_000)}<p>${frequency}</p></html>`;
const { ctx, penalize } = context(new Response(body, { headers: { "content-type": "text/html" } }));
expect(await (await siteFetch(ctx, url)).text()).toBe(body);
expect(penalize).not.toHaveBeenCalled();
const bytes = new TextEncoder().encode(body);
const peek = await peekBody(new Response(bytes), 123);
expect(peek.head.byteLength).toBe(123);
expect(new Uint8Array(await peek.res.arrayBuffer())).toEqual(bytes);
});
});
+90 -10
View File
@@ -1,6 +1,13 @@
import { redactText } from "../rss/redact.ts";
import { CHALLENGE_OUTCOME_HEADER, challengeOutcomeText, detectChallenge, isPageRequest } from "./challenge.ts";
import { buildFailureSample } from "./failure-sample.ts";
import {
CHALLENGE_OUTCOME_HEADER,
challengeOutcomeText,
detectChallenge,
isPageRequest,
peekBody,
} from "./challenge.ts";
import { stripMarkup } from "./detail-description.ts";
import { buildFailureSample, redactPageHtml } from "./failure-sample.ts";
import type { SiteRateLimiter } from "./rate-limit.ts";
import { SiteError } from "./types.ts";
@@ -21,10 +28,61 @@ function retryAfterMs(res: Response, now: Date): number | undefined {
return Number.isNaN(t) ? undefined : Math.max(0, t - now.getTime());
}
/** 只解释明确的错误提示,不从种子字节或正常页面中的普通“限流”字样猜测失败。 */
function rateLimitOf(body: string, status: number): { message: string; waitMs: number } | null {
let message: string | null = null;
try {
const data: unknown = JSON.parse(body);
if (data && typeof data === "object" && !Array.isArray(data)) {
const fields = data as { message?: unknown; msg?: unknown };
if (typeof fields.message === "string") message = fields.message;
else if (typeof fields.msg === "string") message = fields.msg;
}
} catch {
// 非 JSON 只认明确的频控句子,避免把正常列表或二进制文件里的词当成错误。
if (!/^\s*[[{]/.test(body) && (/^\s*</.test(body) || status === 403 || status === 429))
message =
/Request\s+frequency\s+limit,?\s*please\s+try\s+in\s+\d+\s+seconds?\.?/i.exec(stripMarkup(body))?.[0] ?? null;
}
if (!message) return null;
const frequency = /Request\s+frequency\s+limit,?\s*please\s+try\s+in\s+(\d+)\s+seconds?/i.exec(message);
let seconds: number;
if (
/[当當]天|每天|每日|今日|daily/i.test(message) &&
/下载|下載|download/i.test(message) &&
/最多|上限|限[额額]|次[数數]|limit|quota/i.test(message)
) {
seconds = 86_400;
} else if (frequency) {
seconds = Math.max(1, Number(frequency[1]));
} else {
if (
!/限流|流控|请求(?:太多|过多|频繁|过于频繁)|請求(?:太多|過多|頻繁|過於頻繁)|too many requests|rate limit/i.test(
message,
)
)
return null;
const wait = /(\d+)\s*(秒|分[钟鐘]?|小[时時]|seconds?|minutes?|hours?)/i.exec(message);
const multiplier = wait && /^(小|hour)/i.test(wait[2]!) ? 3600 : wait && /^(分|minute)/i.test(wait[2]!) ? 60 : 1;
seconds = Math.max(3600, wait ? Number(wait[1]) * multiplier : 0);
}
if (!Number.isFinite(seconds) || seconds > Number.MAX_SAFE_INTEGER / 1000) return null;
return {
message: redactPageHtml(stripMarkup(message)).replace(/\s+/g, " ").trim().slice(0, 240),
waitMs: seconds * 1000,
};
}
function pageTitle(body: string | null): string | undefined {
const title = body && /<title\b[^>]*>([\s\S]*?)<\/title>/i.exec(body.slice(0, 4096))?.[1];
const safe = title && redactPageHtml(stripMarkup(title)).replace(/\s+/g, " ").trim().slice(0, 160);
return safe || undefined;
}
/**
* 站点请求:先取站点令牌,再发请求;Cloudflare 挑战(`cf-mitigated: challenge`,或 403 / 503 / 429 的挑战页,
* 或页面请求返回 200 的挑战页 / 人机验证门页)→ `challenge`(附脱敏页面样本)
* (不是凭据错误,CF-2);其余 429/503 统一映射并顺延限流器。
* (不是凭据错误,CF-2);明确的远端频控按提示冷却,其余 429/503 按 Retry-After 顺延限流器。
* 返回的 Response 由调用方消费。错误消息只含脱敏后的 URL。
*/
export async function siteFetch(
@@ -69,21 +127,43 @@ export async function siteFetch(
}),
});
}
// 与挑战识别共用有界读取,重建响应保留调用方尚未消费的正文;种子等已知二进制不读。
let body = ch.html;
const contentType = res.headers.get("content-type") ?? "";
if (body === null && res.body && (!contentType || /text\/|json|xml/i.test(contentType))) {
const peek = await peekBody(res, 64 * 1024);
res = peek.res;
body = new TextDecoder().decode(peek.head);
}
const limited = body === null ? null : rateLimitOf(body, res.status);
if (limited) {
await res.body?.cancel().catch(() => {});
const waitMs = Math.max(limited.waitMs, retryAfterMs(res, ctx.now()) ?? 0);
ctx.limiter.penalize(waitMs);
throw new SiteError(ctx.siteId, "rate_limited", `请求 ${safeUrl} 返回 HTTP ${res.status}:${limited.message}`, {
httpStatus: res.status,
retryAfterMs: waitMs,
detail: { reason: "rate_limited", httpStatus: res.status },
});
}
const title = pageTitle(body);
const httpMessage = `请求 ${safeUrl} 返回 HTTP ${res.status}${title ? `,页面标题:${title}` : ""}`;
const detail = { reason: "http", httpStatus: res.status, ...(title ? { title } : {}) };
if (res.status === 429 || res.status === 503) {
const ra = retryAfterMs(res, ctx.now());
await res.body?.cancel().catch(() => {});
if (ra !== undefined) ctx.limiter.penalize(ra);
throw new SiteError(
ctx.siteId,
res.status === 429 ? "rate_limited" : "site_maintenance",
`请求 ${safeUrl} 返回 HTTP ${res.status}`,
{ httpStatus: res.status, ...(ra !== undefined ? { retryAfterMs: ra } : {}) },
);
throw new SiteError(ctx.siteId, res.status === 429 ? "rate_limited" : "site_maintenance", httpMessage, {
httpStatus: res.status,
detail,
...(ra !== undefined ? { retryAfterMs: ra } : {}),
});
}
if (res.status >= 500) {
await res.body?.cancel().catch(() => {});
throw new SiteError(ctx.siteId, "site_maintenance", `请求 ${safeUrl} 返回 HTTP ${res.status}`, {
throw new SiteError(ctx.siteId, "site_maintenance", httpMessage, {
httpStatus: res.status,
detail,
});
}
return res;