fix(sites): preserve remote throttling reasons and HTTP diagnostics
行为对照:MoviePilot v3.2.4,6ca7b294/f4a68f36;复用现有站点共享桶,明确频控按远端秒数冷却,错误标题脱敏;修复有界响应读取对单个大数据块的限制。
This commit is contained in:
1 parent
32e1d446b5
commit
b44ebc2ba1
3 files changed
+233
-13
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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 & offline passkey=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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user