import { createHash } from "node:crypto"; import { type StartedServer, startHttpServer } from "./server.ts"; export interface MockFeedItem { guid: string; title: string; link?: string; enclosureUrl?: string; size?: number; pubDate?: Date; categories?: string[]; description?: string; } export interface MockFailure { status: number; retryAfter?: string; body?: string; } export interface MockRssOptions { format?: "rss" | "atom"; items?: MockFeedItem[]; /** 访问需带的 passkey(query ?passkey=);缺失或错误返回 403 */ passkey?: string; /** Feed 路径,默认 /rss.xml */ path?: string; title?: string; } export interface MockRssServer extends StartedServer { /** 带 passkey(若配置)的完整 Feed URL */ feedUrl: string; items: MockFeedItem[]; setItems(items: MockFeedItem[]): void; /** 新条目放到最前(RSS 惯例新→旧) */ addItem(item: MockFeedItem): void; /** 同 GUID 修改内容(标题修正/促销等) */ updateItem(guid: string, patch: Partial): void; /** 置顶:把条目挪到最前 */ pinItem(guid: string): void; /** 打乱顺序(确定性:按 seed 洗牌) */ shuffle(seed?: number): void; /** 接下来 n 次请求返回失败(如 503 + Retry-After) */ failNext(n: number, failure: MockFailure): void; /** 直接返回原始 XML(用于 DTD/XXE/超大响应等恶意样本);传 null 恢复正常 */ setRawBody(xml: string | null): void; /** 是否支持 ETag/Last-Modified 条件请求(默认 true) */ setConditional(enabled: boolean): void; /** 统计:返回 304 的次数 */ readonly notModifiedCount: number; } function esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } export function renderRss(title: string, items: MockFeedItem[]): string { const body = items .map((it) => { const parts = [ `${esc(it.title)}`, `${esc(it.guid)}`, it.link ? `${esc(it.link)}` : "", it.enclosureUrl ? `` : "", it.pubDate ? `${it.pubDate.toUTCString()}` : "", ...(it.categories ?? []).map((c) => `${esc(c)}`), it.description ? `` : "", ]; return `${parts.join("")}`; }) .join("\n"); return `\n${esc(title)}\n${body}\n`; } export function renderAtom(title: string, items: MockFeedItem[]): string { const body = items .map((it) => { const parts = [ `${esc(it.guid)}`, `${esc(it.title)}`, it.link ? `` : "", it.enclosureUrl ? `` : "", it.pubDate ? `${it.pubDate.toISOString()}` : "", ...(it.categories ?? []).map((c) => ``), it.description ? `${esc(it.description)}` : "", ]; return `${parts.join("")}`; }) .join("\n"); return `\n${esc(title)}\n${body}\n`; } /** 可编程 mock RSS/Atom 服务器:ETag/Last-Modified/304、乱序/置顶/更新、5xx + Retry-After。 */ export async function createMockRssServer(opts: MockRssOptions = {}): Promise { const path = opts.path ?? "/rss.xml"; const title = opts.title ?? "Mock Feed"; let items = [...(opts.items ?? [])]; let version = 1; let lastModified = new Date(Date.UTC(2026, 8, 1)); const failures: MockFailure[] = []; let raw: string | null = null; let conditional = true; let notModified = 0; const bump = () => { version++; lastModified = new Date(lastModified.getTime() + 60_000); }; const server = await startHttpServer((req, res) => { const url = new URL(req.url ?? "/", "http://mock"); if (url.pathname !== path) { res.writeHead(404).end("not found"); return; } if (opts.passkey && url.searchParams.get("passkey") !== opts.passkey) { res.writeHead(403, { "content-type": "text/plain" }).end("invalid passkey"); return; } const failure = failures.shift(); if (failure) { const headers: Record = { "content-type": "text/plain" }; if (failure.retryAfter !== undefined) headers["retry-after"] = failure.retryAfter; res.writeHead(failure.status, headers).end(failure.body ?? `mock failure ${failure.status}`); return; } const body = raw ?? (opts.format === "atom" ? renderAtom(title, items) : renderRss(title, items)); const etag = `"${createHash("sha1").update(body).digest("hex").slice(0, 16)}-${version}"`; const lm = lastModified.toUTCString(); if (conditional) { const inm = req.headers["if-none-match"]; const ims = req.headers["if-modified-since"]; if ((inm && inm === etag) || (!inm && ims && Date.parse(ims) >= lastModified.getTime())) { notModified++; res.writeHead(304, { etag, "last-modified": lm }).end(); return; } } const headers: Record = { "content-type": opts.format === "atom" ? "application/atom+xml; charset=utf-8" : "application/rss+xml; charset=utf-8", }; if (conditional) { headers.etag = etag; headers["last-modified"] = lm; } res.writeHead(200, headers).end(body); }); const feedUrl = `${server.baseUrl}${path}${opts.passkey ? `?passkey=${encodeURIComponent(opts.passkey)}` : ""}`; return { ...server, feedUrl, get items() { return items; }, setItems(next) { items = [...next]; bump(); }, addItem(item) { items = [item, ...items]; bump(); }, updateItem(guid, patch) { items = items.map((it) => (it.guid === guid ? { ...it, ...patch, guid } : it)); bump(); }, pinItem(guid) { const it = items.find((i) => i.guid === guid); if (!it) return; items = [it, ...items.filter((i) => i.guid !== guid)]; bump(); }, shuffle(seed = 42) { let s = seed; const rnd = () => { s = (s * 1103515245 + 12345) % 2147483648; return s / 2147483648; }; const arr = [...items]; for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [arr[i], arr[j]] = [arr[j]!, arr[i]!]; } items = arr; bump(); }, failNext(n, failure) { for (let i = 0; i < n; i++) failures.push(failure); }, setRawBody(xml) { raw = xml; bump(); }, setConditional(enabled) { conditional = enabled; }, get notModifiedCount() { return notModified; }, }; }