feat(adapters): RSS and PT site adapters ported from MoviePilot v3 + mocks (W1-3b)
- rss: port of MP RssHelper._parse_impl/get_rss_link semantics with safe XML (entities off, DTD/ENTITY rejected, size/item caps), ETag/Last-Modified, backoff + Retry-After, circuit breaker with manual reset, passkey redaction, overlap-window GUID+digest dedupe - sites/spider: TS port of MP SiteSpider (indexer schema, search URL builder, field DSL, filters, Jinja templates via nunjucks, login-page detection); audiences definition hand-written as JSON; raw category id preserved; shared per-site token bucket - sites/mteam: port of MP MTorrentSpider (params, result mapping, promo factors, genDlToken flow) without embedding the API key in enclosures; business code/message checks - testkit: mock RSS server, mock NexusPHP site, mock M-Team API - docs: W1-3b migration mapping appended to porting ledger Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
ef2e504a94
commit
a6d049db81
47 files changed
+5414
No files matched your search
@@ -3,4 +3,6 @@ export const PACKAGE_NAME = "@umt/testkit";
|
||||
export * from "./emby";
|
||||
export * from "./fs";
|
||||
export * from "./qbittorrent";
|
||||
export * from "./rss";
|
||||
export * from "./sites";
|
||||
export * from "./tmdb";
|
||||
@@ -0,0 +1,18 @@
|
||||
# testkit/rss
|
||||
|
||||
mock RSS/Atom 服务器(127.0.0.1 随机端口,node:http,无外部依赖)。
|
||||
|
||||
```ts
|
||||
const feed = await createMockRssServer({ items, passkey: "pk", format: "rss" | "atom" });
|
||||
feed.feedUrl; // 带 ?passkey= 的完整 URL
|
||||
feed.addItem(item); // 新条目置前
|
||||
feed.updateItem(guid, {title}); // 同 GUID 内容变化
|
||||
feed.pinItem(guid); feed.shuffle(seed); // 置顶 / 乱序
|
||||
feed.failNext(2, { status: 503, retryAfter: "120" });
|
||||
feed.setRawBody(maliciousXml); // DTD/XXE/超大响应样本;null 恢复
|
||||
feed.setConditional(false); // 关闭 ETag/Last-Modified
|
||||
feed.requests; feed.notModifiedCount;
|
||||
await feed.close();
|
||||
```
|
||||
|
||||
ETag 随内容与版本变化;If-None-Match 命中或 If-Modified-Since 不早于最后修改时间返回 304。passkey 错误返回 403。
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
createMockRssServer,
|
||||
type MockFailure,
|
||||
type MockFeedItem,
|
||||
type MockRssOptions,
|
||||
type MockRssServer,
|
||||
renderAtom,
|
||||
renderRss,
|
||||
} from "./mock-rss";
|
||||
@@ -0,0 +1,206 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { type StartedServer, startHttpServer } from "./server";
|
||||
|
||||
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<MockFeedItem>): 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, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
export function renderRss(title: string, items: MockFeedItem[]): string {
|
||||
const body = items
|
||||
.map((it) => {
|
||||
const parts = [
|
||||
`<title>${esc(it.title)}</title>`,
|
||||
`<guid isPermaLink="false">${esc(it.guid)}</guid>`,
|
||||
it.link ? `<link>${esc(it.link)}</link>` : "",
|
||||
it.enclosureUrl
|
||||
? `<enclosure url="${esc(it.enclosureUrl)}" length="${it.size ?? 0}" type="application/x-bittorrent"/>`
|
||||
: "",
|
||||
it.pubDate ? `<pubDate>${it.pubDate.toUTCString()}</pubDate>` : "",
|
||||
...(it.categories ?? []).map((c) => `<category>${esc(c)}</category>`),
|
||||
it.description ? `<description><![CDATA[${it.description}]]></description>` : "",
|
||||
];
|
||||
return `<item>${parts.join("")}</item>`;
|
||||
})
|
||||
.join("\n");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0"><channel><title>${esc(title)}</title>\n${body}\n</channel></rss>`;
|
||||
}
|
||||
|
||||
export function renderAtom(title: string, items: MockFeedItem[]): string {
|
||||
const body = items
|
||||
.map((it) => {
|
||||
const parts = [
|
||||
`<id>${esc(it.guid)}</id>`,
|
||||
`<title>${esc(it.title)}</title>`,
|
||||
it.link ? `<link rel="alternate" href="${esc(it.link)}"/>` : "",
|
||||
it.enclosureUrl ? `<link rel="enclosure" href="${esc(it.enclosureUrl)}" length="${it.size ?? 0}"/>` : "",
|
||||
it.pubDate ? `<published>${it.pubDate.toISOString()}</published>` : "",
|
||||
...(it.categories ?? []).map((c) => `<category term="${esc(c)}"/>`),
|
||||
it.description ? `<summary>${esc(it.description)}</summary>` : "",
|
||||
];
|
||||
return `<entry>${parts.join("")}</entry>`;
|
||||
})
|
||||
.join("\n");
|
||||
return `<?xml version="1.0" encoding="utf-8"?>\n<feed xmlns="http://www.w3.org/2005/Atom"><title>${esc(title)}</title>\n${body}\n</feed>`;
|
||||
}
|
||||
|
||||
/** 可编程 mock RSS/Atom 服务器:ETag/Last-Modified/304、乱序/置顶/更新、5xx + Retry-After。 */
|
||||
export async function createMockRssServer(opts: MockRssOptions = {}): Promise<MockRssServer> {
|
||||
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<string, string> = { "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<string, string> = {
|
||||
"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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
export interface RecordedRequest {
|
||||
method: string;
|
||||
path: string;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface StartedServer {
|
||||
/** 形如 http://127.0.0.1:PORT(不带尾斜杠) */
|
||||
baseUrl: string;
|
||||
requests: RecordedRequest[];
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export type Handler = (req: IncomingMessage, res: ServerResponse, body: string) => void | Promise<void>;
|
||||
|
||||
/** 在 127.0.0.1 随机端口起 HTTP 服务;记录所有请求供断言。 */
|
||||
export async function startHttpServer(handler: Handler): Promise<StartedServer> {
|
||||
const requests: RecordedRequest[] = [];
|
||||
const server = createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (c: Buffer) => chunks.push(c));
|
||||
req.on("end", () => {
|
||||
const body = Buffer.concat(chunks).toString("utf8");
|
||||
requests.push({ method: req.method ?? "GET", path: req.url ?? "/", headers: { ...req.headers }, body });
|
||||
Promise.resolve(handler(req, res, body)).catch((e: unknown) => {
|
||||
if (!res.headersSent) res.writeHead(500, { "content-type": "text/plain" });
|
||||
res.end(`mock handler error: ${(e as Error).message}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
requests,
|
||||
close: () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
server.closeAllConnections();
|
||||
server.close((err) => (err ? reject(err) : resolve()));
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# testkit/sites
|
||||
|
||||
mock PT 站点(配合 adapters/sites 的 MoviePilot 迁移实现)。HTML/JSON fixture 为自行编写,**不代表已核对真实站点**,只用于验证适配器的选择器配置与错误分类。
|
||||
|
||||
## mock NexusPHP(观众风格)
|
||||
|
||||
```ts
|
||||
const site = await createMockNexusPhpSite({ pageSize: 50, pageBase: 0, loginMode: "page" | "redirect" });
|
||||
site.baseUrl; site.cookie; // 请求 Cookie 需包含 site.cookie
|
||||
site.setLoggedIn(false); // 全站返回登录页(或 302 login.php)
|
||||
site.failNext(1, 429, "7"); // 限流 / 5xx
|
||||
site.setBrokenLayout(true); // 改版模拟
|
||||
site.infoHashOf("812345"); // 下载 .torrent 的 infohash
|
||||
```
|
||||
|
||||
路由:`index.php`、`torrents.php`(search / catN / spstate=2 / page)、`details.php?id=`(不存在返回"没有该ID的种子")、`download.php?id=`(合法 .torrent,或 `magnet` 字段时 302 磁力)、`login.php`。
|
||||
默认 fixture `AUDIENCES_FIXTURE_RELEASES`:免费+截止(812345)、综艺 403 + H&R(812346)、50%+2x(812347)、截断标题 + TB(812348)、纪录片 2x + KB(812349)。
|
||||
|
||||
## mock M-Team API
|
||||
|
||||
```ts
|
||||
const api = await createMockMteamApi({ apiKey, torrents, failMode });
|
||||
api.setFailMode("banned_key" | "rate_limit" | "http_429" | "non_json" | "no_dl_token" | null);
|
||||
api.lastSearchBody; api.issuedTokens; api.infoHashOf("912233");
|
||||
```
|
||||
|
||||
路由:`POST /api/torrent/search`、`/api/torrent/detail`、`/api/torrent/genDlToken`(id 可走 query 或表单,签发一次性 `/api/rss/dlv2?sign=&t=&tid=`)、`/api/member/profile`;`x-api-key` 错误返回 `{code:"1", message:"key無效"}`。
|
||||
默认 fixture `MTEAM_FIXTURE_TORRENTS`:50% 限时(912233)、商城单种免费(912234)、全站促销(912235,动画 405)、2x 免费(912236,纪录 404)、体育 407(912237)。
|
||||
|
||||
`buildFakeTorrent({name, length})` 生成结构合法的 .torrent 与其 infohash。
|
||||
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
createMockMteamApi,
|
||||
type MockMteamApi,
|
||||
type MockMteamFailMode,
|
||||
type MockMteamOptions,
|
||||
} from "./mock-mteam";
|
||||
export { createMockNexusPhpSite, type MockNexusPhpOptions, type MockNexusPhpSite } from "./mock-nexusphp";
|
||||
export { type MockMteamTorrent, MTEAM_FIXTURE_TORRENTS } from "./mteam-fixtures";
|
||||
export { AUDIENCES_FIXTURE_RELEASES, type MockNexusPromo, type MockNexusRelease } from "./nexusphp-fixtures";
|
||||
export { buildFakeTorrent, type FakeTorrent } from "./torrent";
|
||||
@@ -0,0 +1,152 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { ServerResponse } from "node:http";
|
||||
import { type StartedServer, startHttpServer } from "../rss/server";
|
||||
import { type MockMteamTorrent, MTEAM_FIXTURE_TORRENTS } from "./mteam-fixtures";
|
||||
import { buildFakeTorrent } from "./torrent";
|
||||
|
||||
export type MockMteamFailMode = "banned_key" | "rate_limit" | "http_429" | "non_json" | "no_dl_token";
|
||||
|
||||
export interface MockMteamOptions {
|
||||
apiKey?: string;
|
||||
torrents?: MockMteamTorrent[];
|
||||
failMode?: MockMteamFailMode | null;
|
||||
}
|
||||
|
||||
export interface MockMteamApi extends StartedServer {
|
||||
apiKey: string;
|
||||
torrents: MockMteamTorrent[];
|
||||
setFailMode(mode: MockMteamFailMode | null): void;
|
||||
/** 已签发的下载令牌(sign)数 */
|
||||
readonly issuedTokens: number;
|
||||
infoHashOf(id: string): string;
|
||||
/** 最近一次 search 请求体 */
|
||||
readonly lastSearchBody: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
function json(res: ServerResponse, body: unknown, status = 200) {
|
||||
res.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function formOrJson(body: string, contentType: string | undefined): Record<string, unknown> {
|
||||
if (!body) return {};
|
||||
if (contentType?.includes("application/json")) return JSON.parse(body) as Record<string, unknown>;
|
||||
return Object.fromEntries(new URLSearchParams(body));
|
||||
}
|
||||
|
||||
/** mock M-Team API:search/detail/genDlToken/dlv2/member.profile;x-api-key 校验与业务错误模拟。 */
|
||||
export async function createMockMteamApi(opts: MockMteamOptions = {}): Promise<MockMteamApi> {
|
||||
const apiKey = opts.apiKey ?? "mock-mteam-api-key-0123456789";
|
||||
const torrents = [...(opts.torrents ?? MTEAM_FIXTURE_TORRENTS)];
|
||||
let failMode: MockMteamFailMode | null = opts.failMode ?? null;
|
||||
const tokens = new Map<string, string>(); // sign → torrent id(一次性)
|
||||
let issued = 0;
|
||||
let lastSearchBody: Record<string, unknown> | null = null;
|
||||
const files = new Map(torrents.map((t) => [t.id, buildFakeTorrent({ name: t.name, length: 8 * 1024 * 1024 })]));
|
||||
|
||||
const server = await startHttpServer((req, res, raw) => {
|
||||
const url = new URL(req.url ?? "/", "http://mock");
|
||||
if (url.pathname === "/api/rss/dlv2" && req.method === "GET") {
|
||||
const sign = url.searchParams.get("sign") ?? "";
|
||||
const id = tokens.get(sign);
|
||||
if (!id || id !== url.searchParams.get("tid")) return json(res, { code: "1", message: "下載鏈接無效或已過期" });
|
||||
tokens.delete(sign);
|
||||
res.writeHead(200, { "content-type": "application/x-bittorrent" }).end(Buffer.from(files.get(id)!.bytes));
|
||||
return;
|
||||
}
|
||||
if (req.method !== "POST") return json(res, { code: "1", message: "method not allowed" }, 405);
|
||||
if (failMode === "non_json") {
|
||||
res
|
||||
.writeHead(200, { "content-type": "text/html" })
|
||||
.end("<html><body>系統維護中 / Under maintenance</body></html>");
|
||||
return;
|
||||
}
|
||||
if (failMode === "http_429") {
|
||||
res.writeHead(429, { "retry-after": "5" }).end("too many");
|
||||
return;
|
||||
}
|
||||
if (req.headers["x-api-key"] !== apiKey) return json(res, { code: "1", message: "key無效" });
|
||||
if (failMode === "banned_key") return json(res, { code: "1", message: "帳號已停用" });
|
||||
if (failMode === "rate_limit") return json(res, { code: "1", message: "請求過於頻繁" });
|
||||
const body = formOrJson(raw, req.headers["content-type"]);
|
||||
switch (url.pathname) {
|
||||
case "/api/torrent/search": {
|
||||
lastSearchBody = body;
|
||||
const kw = String(body.keyword ?? "").toLowerCase();
|
||||
const cats = (body.categories as string[] | undefined) ?? [];
|
||||
const pageNumber = Number(body.pageNumber ?? 1);
|
||||
const pageSize = Number(body.pageSize ?? 100);
|
||||
let list = torrents.filter((t) => {
|
||||
if (cats.length && !cats.map(String).includes(t.category)) return false;
|
||||
if (body.discount === "FREE" && !["FREE", "_2X_FREE"].includes(t.status.discount)) return false;
|
||||
if (!kw) return true;
|
||||
if (kw.startsWith("https://www.imdb.com/title/")) return (t.imdb ?? "").toLowerCase().startsWith(kw);
|
||||
return t.name.toLowerCase().includes(kw) || (t.smallDescr ?? "").toLowerCase().includes(kw);
|
||||
});
|
||||
const total = list.length;
|
||||
list = list.slice((pageNumber - 1) * pageSize, pageNumber * pageSize);
|
||||
return json(res, {
|
||||
code: "0",
|
||||
message: "SUCCESS",
|
||||
data: {
|
||||
pageNumber: String(pageNumber),
|
||||
pageSize: String(pageSize),
|
||||
total: String(total),
|
||||
totalPages: String(Math.max(1, Math.ceil(total / pageSize))),
|
||||
data: list,
|
||||
},
|
||||
});
|
||||
}
|
||||
case "/api/torrent/detail": {
|
||||
const t = torrents.find((x) => x.id === String(body.id ?? url.searchParams.get("id")));
|
||||
if (!t) return json(res, { code: "1", message: "種子不存在" });
|
||||
return json(res, { code: "0", message: "SUCCESS", data: t });
|
||||
}
|
||||
case "/api/torrent/genDlToken": {
|
||||
const id = String(body.id ?? url.searchParams.get("id"));
|
||||
if (!torrents.some((x) => x.id === id)) return json(res, { code: "1", message: "種子不存在" });
|
||||
if (failMode === "no_dl_token") return json(res, { code: "0", message: "SUCCESS", data: null });
|
||||
const sign = randomBytes(12).toString("hex");
|
||||
tokens.set(sign, id);
|
||||
issued++;
|
||||
const host = req.headers.host ?? "127.0.0.1";
|
||||
return json(res, {
|
||||
code: "0",
|
||||
message: "SUCCESS",
|
||||
data: `http://${host}/api/rss/dlv2?sign=${sign}&t=${Date.now()}&tid=${id}`,
|
||||
});
|
||||
}
|
||||
case "/api/member/profile":
|
||||
return json(res, {
|
||||
code: "0",
|
||||
message: "SUCCESS",
|
||||
data: {
|
||||
id: "10001",
|
||||
username: "mockuser",
|
||||
memberCount: { uploaded: "1000", downloaded: "10", shareRate: "100" },
|
||||
},
|
||||
});
|
||||
default:
|
||||
return json(res, { code: "1", message: "not found" }, 404);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...server,
|
||||
apiKey,
|
||||
torrents,
|
||||
setFailMode(mode) {
|
||||
failMode = mode;
|
||||
},
|
||||
get issuedTokens() {
|
||||
return issued;
|
||||
},
|
||||
get lastSearchBody() {
|
||||
return lastSearchBody;
|
||||
},
|
||||
infoHashOf(id) {
|
||||
const f = files.get(id);
|
||||
if (!f) throw new Error(`no torrent ${id}`);
|
||||
return f.infoHash;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { type StartedServer, startHttpServer } from "../rss/server";
|
||||
import { AUDIENCES_FIXTURE_RELEASES, type MockNexusRelease } from "./nexusphp-fixtures";
|
||||
import { buildFakeTorrent } from "./torrent";
|
||||
|
||||
export interface MockNexusPhpOptions {
|
||||
/** 登录态 Cookie;请求 Cookie 头需包含它 */
|
||||
cookie?: string;
|
||||
releases?: MockNexusRelease[];
|
||||
/** 每页条数,默认 50 */
|
||||
pageSize?: number;
|
||||
/** 站点页码起点(观众 0 起),默认 0 */
|
||||
pageBase?: 0 | 1;
|
||||
/** 登录失效时的表现:200 登录页(默认)或 302 跳转 login.php */
|
||||
loginMode?: "page" | "redirect";
|
||||
siteName?: string;
|
||||
}
|
||||
|
||||
export interface MockNexusPhpSite extends StartedServer {
|
||||
cookie: string;
|
||||
releases: MockNexusRelease[];
|
||||
/** 让站点认为会话失效(所有页面返回登录页) */
|
||||
setLoggedIn(v: boolean): void;
|
||||
/** 接下来 n 次请求返回指定状态(如 429 + Retry-After / 502) */
|
||||
failNext(n: number, status: number, retryAfter?: string): void;
|
||||
/** 改版模拟:列表页返回无法识别的结构 */
|
||||
setBrokenLayout(v: boolean): void;
|
||||
/** 各 .torrent 的 infohash,供断言 */
|
||||
infoHashOf(id: string): string;
|
||||
}
|
||||
|
||||
const PROMO_ALT: Record<string, string> = {
|
||||
free: "Free",
|
||||
free2up: "2X Free",
|
||||
"50pctdown": "50%",
|
||||
"50pctdown2up": "2X 50%",
|
||||
"30pctdown": "30%",
|
||||
"2up": "2X",
|
||||
};
|
||||
|
||||
function esc(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function shell(siteName: string, title: string, body: string, loggedIn: boolean): string {
|
||||
const userbar = loggedIn
|
||||
? `<div id="info_block"><a href="userdetails.php?id=10001">mockuser</a>
|
||||
<span class="site-userbar__compact-metric--uploaded">12.5 TB</span>
|
||||
<span class="site-userbar__compact-metric--downloaded">3.2 TB</span>
|
||||
<a href="logout.php">退出</a></div>`
|
||||
: "";
|
||||
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${esc(siteName)} :: ${esc(title)}</title></head>
|
||||
<body>${userbar}<div id="outer">${body}</div></body></html>`;
|
||||
}
|
||||
|
||||
function loginPage(siteName: string): string {
|
||||
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${esc(siteName)} :: 登录</title></head>
|
||||
<body><form method="post" action="takelogin.php"><p>你需要启用cookies才能登录</p>
|
||||
<input type="text" name="username"><input type="password" name="password"><input type="submit" value="登录"></form></body></html>`;
|
||||
}
|
||||
|
||||
function promoImg(r: MockNexusRelease): string {
|
||||
if (!r.promo) return "";
|
||||
const title = r.promoUntil ?? "";
|
||||
return ` <img class="pro_${r.promo}" src="pic/trans.gif" alt="${PROMO_ALT[r.promo]}" title="${esc(title)}">`;
|
||||
}
|
||||
|
||||
function row(r: MockNexusRelease): string {
|
||||
const titleLink = r.truncatedText
|
||||
? `<a href="details.php?id=${r.id}&hit=1"><b>${esc(r.truncatedText)}</b></a>`
|
||||
: `<a title="${esc(r.title)}" href="details.php?id=${r.id}&hit=1"><b>${esc(r.title)}</b></a>`;
|
||||
const hr = r.hr ? ` <img class="hitandrun" src="pic/trans.gif" alt="H&R" title="H&R">` : "";
|
||||
const sub = r.subtitle ? `<br><span class="torrent-subtitle-line">${esc(r.subtitle)}</span>` : "";
|
||||
return `<tr>
|
||||
<td class="rowfollow nowrap" valign="middle"><a href="?cat=${r.catId}"><img class="c_${r.catId}" src="pic/cattrans.gif" alt="${esc(r.catName)}" title="${esc(r.catName)}"></a></td>
|
||||
<td class="rowfollow" width="100%" align="left"><table class="torrentname" width="100%"><tr>
|
||||
<td class="embedded">${titleLink}${promoImg(r)}${hr}${sub}</td>
|
||||
<td class="embedded" width="20"><a href="download.php?id=${r.id}"><img class="download" src="pic/trans.gif" alt="download"></a></td>
|
||||
</tr></table></td>
|
||||
<td class="rowfollow"><a href="comment.php?id=${r.id}">0</a></td>
|
||||
<td class="rowfollow nowrap"><span title="${r.uploadedAt}">${esc(r.relativeTime ?? r.uploadedAt)}</span></td>
|
||||
<td class="rowfollow">${esc(r.sizeText)}</td>
|
||||
<td class="rowfollow" align="center"><b><a href="details.php?id=${r.id}&dllist=1#seeders">${r.seeders}</a></b></td>
|
||||
<td class="rowfollow" align="center"><b><a href="details.php?id=${r.id}&dllist=1#leechers">${r.leechers}</a></b></td>
|
||||
<td class="rowfollow"><a href="viewsnatches.php?id=${r.id}"><b>${r.snatched}</b></a></td>
|
||||
<td class="rowfollow"><a href="userdetails.php?id=2">${esc(r.uploader ?? "anonymous")}</a></td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function listBody(rows: MockNexusRelease[], pageCount: number, pageBase: number, query: URLSearchParams): string {
|
||||
const header = `<tr><td class="colhead">类型</td><td class="colhead">标题</td><td class="colhead">评论</td><td class="colhead">存活时间</td><td class="colhead">大小</td><td class="colhead">种子</td><td class="colhead">下载</td><td class="colhead">完成</td><td class="colhead">发布者</td></tr>`;
|
||||
const links: string[] = [];
|
||||
for (let i = 0; i < pageCount; i++) {
|
||||
const q = new URLSearchParams(query);
|
||||
q.set("page", String(i + pageBase));
|
||||
links.push(`<a href="?${esc(q.toString())}"><b>${i + 1}</b></a>`);
|
||||
}
|
||||
if (rows.length === 0) return `<p>没有种子。请用准确的关键字重试。</p>`;
|
||||
return `<p align="center">${links.join(" | ")}</p>
|
||||
<table class="torrents torrents-table" cellspacing="0" cellpadding="5" width="100%"><tbody>${header}${rows.map(row).join("\n")}</tbody></table>`;
|
||||
}
|
||||
|
||||
function detailBody(r: MockNexusRelease): string {
|
||||
const hr = r.hr ? ` <img class="hitandrun" src="pic/trans.gif" alt="H&R">` : "";
|
||||
return `<h1 align="center" id="top">${esc(r.title)}${promoImg(r)}${hr}</h1>
|
||||
<table width="97%" cellspacing="0" cellpadding="5">
|
||||
<tr><td class="rowhead" width="13%">下载</td><td class="rowfollow" align="left"><a class="index" href="download.php?id=${r.id}">[Mock].${esc(r.title)}.torrent</a></td></tr>
|
||||
<tr><td class="rowhead">副标题</td><td class="rowfollow" align="left">${esc(r.subtitle ?? "")}</td></tr>
|
||||
<tr><td class="rowhead">基本信息</td><td class="rowfollow" align="left"><b>大小:</b>${esc(r.sizeText)} <b>类型:</b> ${esc(r.catName)} 发布于 <span title="${r.uploadedAt}">${esc(r.relativeTime ?? r.uploadedAt)}</span></td></tr>
|
||||
<tr><td class="rowhead">同伴</td><td class="rowfollow" align="left">${r.seeders.replace(/,/g, "")}个做种者 | ${r.leechers.replace(/,/g, "")}个下载者</td></tr>
|
||||
<tr><td class="rowhead">简介</td><td class="rowfollow" align="left"><div id="kdescr">${esc(r.description ?? "暂无简介")}</div></td></tr>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function hasCookie(req: IncomingMessage, cookie: string): boolean {
|
||||
const header = req.headers.cookie ?? "";
|
||||
return cookie.split(";").every((part) => header.includes(part.trim()));
|
||||
}
|
||||
|
||||
/** mock NexusPHP 站点:列表/搜索/详情/下载/首页,支持登录失效、限流与改版模拟。 */
|
||||
export async function createMockNexusPhpSite(opts: MockNexusPhpOptions = {}): Promise<MockNexusPhpSite> {
|
||||
const cookie = opts.cookie ?? "c_secure_uid=10001; c_secure_pass=mock-session-secret";
|
||||
const releases = [...(opts.releases ?? AUDIENCES_FIXTURE_RELEASES)];
|
||||
const pageSize = opts.pageSize ?? 50;
|
||||
const pageBase = opts.pageBase ?? 0;
|
||||
const siteName = opts.siteName ?? "观众";
|
||||
let loggedIn = true;
|
||||
let broken = false;
|
||||
const failures: Array<{ status: number; retryAfter?: string }> = [];
|
||||
const torrents = new Map(releases.map((r) => [r.id, buildFakeTorrent({ name: r.title, length: 4 * 1024 * 1024 })]));
|
||||
|
||||
const send = (res: ServerResponse, status: number, html: string) => {
|
||||
res.writeHead(status, { "content-type": "text/html; charset=utf-8" }).end(html);
|
||||
};
|
||||
|
||||
const server = await startHttpServer((req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://mock");
|
||||
const failure = failures.shift();
|
||||
if (failure) {
|
||||
res.writeHead(failure.status, failure.retryAfter ? { "retry-after": failure.retryAfter } : {}).end("busy");
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/login.php") return send(res, 200, loginPage(siteName));
|
||||
if (!loggedIn || !hasCookie(req, cookie)) {
|
||||
if (opts.loginMode === "redirect") {
|
||||
res.writeHead(302, { location: `login.php?returnto=${encodeURIComponent(url.pathname.slice(1))}` }).end();
|
||||
return;
|
||||
}
|
||||
return send(res, 200, loginPage(siteName));
|
||||
}
|
||||
const q = url.searchParams;
|
||||
switch (url.pathname) {
|
||||
case "/index.php":
|
||||
case "/":
|
||||
return send(res, 200, shell(siteName, "首页", "<h2>最新公告</h2>", true));
|
||||
case "/torrents.php": {
|
||||
if (broken) return send(res, 200, shell(siteName, "种子", `<div class="new-grid">改版了</div>`, true));
|
||||
const kw = (q.get("search") ?? "").toLowerCase().trim();
|
||||
const cats = [...q.keys()].filter((k) => /^cat\d+$/.test(k)).map((k) => k.slice(3));
|
||||
let rows = releases.filter((r) => {
|
||||
if (cats.length && !cats.includes(r.catId)) return false;
|
||||
if (q.get("spstate") === "2" && r.promo !== "free" && r.promo !== "free2up") return false;
|
||||
if (!kw) return true;
|
||||
return kw
|
||||
.split(/\s+/)
|
||||
.every((w) => r.title.toLowerCase().includes(w) || (r.subtitle ?? "").toLowerCase().includes(w));
|
||||
});
|
||||
const pageCount = Math.max(1, Math.ceil(rows.length / pageSize));
|
||||
const idx = Number(q.get("page") ?? pageBase) - pageBase;
|
||||
rows = rows.slice(idx * pageSize, idx * pageSize + pageSize);
|
||||
const base = new URLSearchParams(q);
|
||||
base.delete("page");
|
||||
return send(res, 200, shell(siteName, "种子", listBody(rows, pageCount, pageBase, base), true));
|
||||
}
|
||||
case "/details.php": {
|
||||
const r = releases.find((x) => x.id === q.get("id"));
|
||||
if (!r) return send(res, 200, shell(siteName, "错误", "<h2>错误!</h2><p>没有该ID的种子</p>", true));
|
||||
return send(res, 200, shell(siteName, `种子详情 "${r.title}"`, detailBody(r), true));
|
||||
}
|
||||
case "/download.php": {
|
||||
const r = releases.find((x) => x.id === q.get("id"));
|
||||
if (!r) return send(res, 200, shell(siteName, "错误", "<p>没有该ID的种子</p>", true));
|
||||
if (r.magnet) {
|
||||
res.writeHead(302, { location: r.magnet }).end();
|
||||
return;
|
||||
}
|
||||
const t = torrents.get(r.id)!;
|
||||
res
|
||||
.writeHead(200, {
|
||||
"content-type": "application/x-bittorrent",
|
||||
"content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(`[Mock].${r.title}.torrent`)}`,
|
||||
})
|
||||
.end(Buffer.from(t.bytes));
|
||||
return;
|
||||
}
|
||||
default:
|
||||
res.writeHead(404).end("not found");
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...server,
|
||||
cookie,
|
||||
releases,
|
||||
setLoggedIn(v) {
|
||||
loggedIn = v;
|
||||
},
|
||||
failNext(n, status, retryAfter) {
|
||||
for (let i = 0; i < n; i++) failures.push(retryAfter === undefined ? { status } : { status, retryAfter });
|
||||
},
|
||||
setBrokenLayout(v) {
|
||||
broken = v;
|
||||
},
|
||||
infoHashOf(id) {
|
||||
const t = torrents.get(id);
|
||||
if (!t) throw new Error(`no torrent ${id}`);
|
||||
return t.infoHash;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* mock M-Team API fixture。字段名按公开 API 与迁移账本 §7.2 整理,待真实账号核验。
|
||||
* 数值字段在真实 API 中多为字符串,这里保持同样形态。
|
||||
*/
|
||||
|
||||
export interface MockMteamTorrent {
|
||||
id: string;
|
||||
name: string;
|
||||
smallDescr?: string;
|
||||
category: string;
|
||||
size: string;
|
||||
createdDate: string;
|
||||
imdb?: string;
|
||||
douban?: string;
|
||||
labels?: string;
|
||||
labelsNew?: string[];
|
||||
descr?: string;
|
||||
status: {
|
||||
seeders: string;
|
||||
leechers: string;
|
||||
timesCompleted: string;
|
||||
discount: string;
|
||||
discountEndTime?: string | null;
|
||||
promotionRule?: { discount: string; endTime: string } | null;
|
||||
mallSingleFree?: { status: string; endDate: string } | null;
|
||||
};
|
||||
}
|
||||
|
||||
export const MTEAM_FIXTURE_TORRENTS: MockMteamTorrent[] = [
|
||||
{
|
||||
id: "912233",
|
||||
name: "Shogun.2024.S01.2160p.DSNP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX",
|
||||
smallDescr: "幕府将军 第一季 全10集 杜比视界",
|
||||
category: "402",
|
||||
size: "61234567890",
|
||||
createdDate: "2026-09-20 08:15:00",
|
||||
imdb: "https://www.imdb.com/title/tt2788316/",
|
||||
labelsNew: ["中字", "DV"],
|
||||
descr: "[img]poster[/img] 幕府将军",
|
||||
status: {
|
||||
seeders: "532",
|
||||
leechers: "4",
|
||||
timesCompleted: "2011",
|
||||
discount: "PERCENT_50",
|
||||
discountEndTime: "2026-09-28 08:15:00",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "912234",
|
||||
name: "Oppenheimer.2023.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.DTS-HD.MA.5.1-FGT",
|
||||
smallDescr: "奥本海默",
|
||||
category: "439",
|
||||
size: "91234567890",
|
||||
createdDate: "2026-09-21 10:00:00",
|
||||
imdb: "https://www.imdb.com/title/tt15398776/",
|
||||
labels: "6",
|
||||
status: {
|
||||
seeders: "120",
|
||||
leechers: "3",
|
||||
timesCompleted: "800",
|
||||
discount: "PERCENT_50",
|
||||
mallSingleFree: { status: "ONGOING", endDate: "2026-09-27 20:00:00" },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "912235",
|
||||
name: "Frieren.S01.2023.1080p.BluRay.x265.10bit.FLAC-VCB",
|
||||
smallDescr: "葬送的芙莉莲 第一季",
|
||||
category: "405",
|
||||
size: "45000000000",
|
||||
createdDate: "2026-09-22 12:00:00",
|
||||
status: {
|
||||
seeders: "300",
|
||||
leechers: "10",
|
||||
timesCompleted: "1500",
|
||||
discount: "NORMAL",
|
||||
promotionRule: { discount: "FREE", endTime: "2026-10-07 23:59:59" },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "912236",
|
||||
name: "Planet.Earth.III.2023.2160p.iP.WEB-DL.DDP5.1.HDR.H.265-GRP",
|
||||
category: "404",
|
||||
size: "30000000000",
|
||||
createdDate: "2026-09-23 09:00:00",
|
||||
status: {
|
||||
seeders: "50",
|
||||
leechers: "1",
|
||||
timesCompleted: "90",
|
||||
discount: "_2X_FREE",
|
||||
discountEndTime: "2026-09-30 00:00:00",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "912237",
|
||||
name: "Paris.2024.Olympics.Opening.Ceremony.2160p.WEB-DL.H265-GRP",
|
||||
category: "407",
|
||||
size: "12000000000",
|
||||
createdDate: "2026-09-24 09:00:00",
|
||||
status: { seeders: "10", leechers: "0", timesCompleted: "22", discount: "NORMAL" },
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* mock NexusPHP(观众风格)资源 fixture。HTML 结构按 NexusPHP 公开页面惯例自行编写,
|
||||
* 类名(torrent-subtitle-line / pro_* / hitandrun)用于验证选择器配置,不代表已核对真实站点。
|
||||
*/
|
||||
|
||||
export type MockNexusPromo = "free" | "free2up" | "50pctdown" | "50pctdown2up" | "30pctdown" | "2up";
|
||||
|
||||
export interface MockNexusRelease {
|
||||
id: string;
|
||||
title: string;
|
||||
/** 不给 title 属性,只显示截断文字 */
|
||||
truncatedText?: string;
|
||||
subtitle?: string;
|
||||
catId: string;
|
||||
catName: string;
|
||||
sizeText: string;
|
||||
seeders: string;
|
||||
leechers: string;
|
||||
snatched: string;
|
||||
/** 站点本地时间 YYYY-MM-DD HH:mm:ss */
|
||||
uploadedAt: string;
|
||||
relativeTime?: string;
|
||||
promo?: MockNexusPromo;
|
||||
promoUntil?: string;
|
||||
hr?: boolean;
|
||||
uploader?: string;
|
||||
description?: string;
|
||||
/** 下载返回磁力而不是 .torrent */
|
||||
magnet?: string;
|
||||
}
|
||||
|
||||
export const AUDIENCES_FIXTURE_RELEASES: MockNexusRelease[] = [
|
||||
{
|
||||
id: "812345",
|
||||
title: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.Atmos.7.1-FraMeSToR",
|
||||
subtitle: "沙丘2 / 沙丘:第二部 | 杜比视界 国英双语 中字",
|
||||
catId: "401",
|
||||
catName: "电影",
|
||||
sizeText: "78.42 GB",
|
||||
seeders: "1,204",
|
||||
leechers: "17",
|
||||
snatched: "3,388",
|
||||
uploadedAt: "2026-09-26 21:14:03",
|
||||
relativeTime: "3小时前",
|
||||
promo: "free",
|
||||
promoUntil: "2026-10-01 12:00:00",
|
||||
uploader: "uploader1",
|
||||
description: "https://www.imdb.com/title/tt15239678/ https://movie.douban.com/subject/35575567/",
|
||||
},
|
||||
{
|
||||
id: "812346",
|
||||
title: "Keep.Running.S12E01.20240426.2160p.WEB-DL.H265.AAC-LelveTV",
|
||||
subtitle: "奔跑吧 第十二季 第1期",
|
||||
catId: "403",
|
||||
catName: "综艺",
|
||||
sizeText: "5.12 GB",
|
||||
seeders: "88",
|
||||
leechers: "2",
|
||||
snatched: "410",
|
||||
uploadedAt: "2026-09-26 20:00:00",
|
||||
hr: true,
|
||||
},
|
||||
{
|
||||
id: "812347",
|
||||
title: "The.Bear.S03.2160p.DSNP.WEB-DL.DDP5.1.Atmos.DV.H.265-FLUX",
|
||||
subtitle: "熊家餐馆 第三季",
|
||||
catId: "402",
|
||||
catName: "剧集",
|
||||
sizeText: "1,234.56 MiB",
|
||||
seeders: "35",
|
||||
leechers: "0",
|
||||
snatched: "120",
|
||||
uploadedAt: "2026-09-25 08:30:00",
|
||||
promo: "50pctdown2up",
|
||||
promoUntil: "2026-09-29 08:30:00",
|
||||
},
|
||||
{
|
||||
id: "812348",
|
||||
title: "The.Last.of.Us.S02.2160p.MAX.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX",
|
||||
truncatedText: "The.Last.of.Us.S02.2160p.MAX.WEB-DL.DDP5.1.Atmos.D…",
|
||||
catId: "402",
|
||||
catName: "剧集",
|
||||
sizeText: "2.1 TB",
|
||||
seeders: "12",
|
||||
leechers: "1",
|
||||
snatched: "44",
|
||||
uploadedAt: "2026-09-24 10:00:00",
|
||||
},
|
||||
{
|
||||
id: "812349",
|
||||
title: "Planet.Earth.III.2023.2160p.BluRay.REMUX.HEVC.DTS-HD.MA.5.1-GRP",
|
||||
subtitle: "地球脉动 第三季",
|
||||
catId: "406",
|
||||
catName: "纪录片",
|
||||
sizeText: "850 KB",
|
||||
seeders: "5",
|
||||
leechers: "0",
|
||||
snatched: "9",
|
||||
uploadedAt: "2026-09-23 10:00:00",
|
||||
promo: "2up",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,47 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
type BValue = number | string | Uint8Array | BValue[] | { [k: string]: BValue };
|
||||
|
||||
function concat(parts: Uint8Array[]): Uint8Array {
|
||||
const total = parts.reduce((n, p) => n + p.byteLength, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let off = 0;
|
||||
for (const p of parts) {
|
||||
out.set(p, off);
|
||||
off += p.byteLength;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const enc = new TextEncoder();
|
||||
|
||||
export function bencode(v: BValue): Uint8Array {
|
||||
if (typeof v === "number") return enc.encode(`i${Math.trunc(v)}e`);
|
||||
if (typeof v === "string") {
|
||||
const b = enc.encode(v);
|
||||
return concat([enc.encode(`${b.byteLength}:`), b]);
|
||||
}
|
||||
if (v instanceof Uint8Array) return concat([enc.encode(`${v.byteLength}:`), v]);
|
||||
if (Array.isArray(v)) return concat([enc.encode("l"), ...v.map(bencode), enc.encode("e")]);
|
||||
const keys = Object.keys(v).sort();
|
||||
return concat([enc.encode("d"), ...keys.flatMap((k) => [bencode(k), bencode(v[k]!)]), enc.encode("e")]);
|
||||
}
|
||||
|
||||
export interface FakeTorrent {
|
||||
bytes: Uint8Array;
|
||||
/** v1 infohash(小写 hex) */
|
||||
infoHash: string;
|
||||
}
|
||||
|
||||
/** 生成结构合法的单文件 .torrent(pieces 为伪数据,只用于协议/解析测试)。 */
|
||||
export function buildFakeTorrent(opts: { name: string; length?: number; announce?: string }): FakeTorrent {
|
||||
const length = opts.length ?? 1024 * 1024;
|
||||
const pieceLength = 256 * 1024;
|
||||
const pieceCount = Math.max(1, Math.ceil(length / pieceLength));
|
||||
const pieces = new Uint8Array(20 * pieceCount);
|
||||
for (let i = 0; i < pieceCount; i++) pieces.set(createHash("sha1").update(`${opts.name}:${i}`).digest(), i * 20);
|
||||
const info = { length, name: opts.name, "piece length": pieceLength, pieces, private: 1 };
|
||||
const infoBytes = bencode(info);
|
||||
const bytes = bencode({ announce: opts.announce ?? "https://tracker.invalid/announce", info });
|
||||
return { bytes, infoHash: createHash("sha1").update(infoBytes).digest("hex") };
|
||||
}
|
||||
Reference in new issue
Block a user