feat(adapters): NexusPHP takelogin login, VIP and seeding page limit for user data

loginNexusPhp (captcha / two-factor / bad credentials / blocked / Cloudflare
classification, TOTP), SiteUserInfo.vip via profile.vip_css or umt.vipLevels,
maxSeedingPages option, MTorrent parser honours apiUrl; testkit mock NexusPHP
site gains takelogin with login guards and user detail pages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 15:18:30 +08:00
1 parent 5ee7ca4fee
commit d6fca25679
10 files changed
+384 -8

No files matched your search

@@ -103,6 +103,8 @@ const UmtNexusSchema = z.strictObject({
})
.optional(),
profile: z.record(z.string(), z.string()).optional(),
/** VIP 等级名(用户数据 vip 判定;profile.vip_css 之外的补充) */
vipLevels: z.array(z.string()).optional(),
detail: z
.strictObject({
path: z.string(),
+11
View File
@@ -16,6 +16,17 @@ export {
SpiderFilterSchema,
} from "./definitions/index.ts";
export type { SiteAdapterDeps } from "./deps.ts";
export {
classifyLoginError,
loginFailureText,
loginNexusPhp,
type NexusLoginForm,
type NexusLoginInput,
type NexusLoginResult,
SiteLoginError,
type SiteLoginFailure,
totpCode,
} from "./login/nexusphp.ts";
export { createMteamAdapter, mteamTorrentToRelease } from "./mteam/adapter.ts";
export { type MteamApiPath, mteamPost } from "./mteam/client.ts";
export { type MteamDefinition, mteamApiBase, mteamDefinition } from "./mteam/definition.ts";
@@ -0,0 +1,250 @@
import { createHmac } from "node:crypto";
import { load } from "cheerio";
import { redactText } from "../../rss/redact.ts";
import { underChallenge } from "../user-parsers/common.ts";
/**
* NexusPHP 账密登录(takelogin),流程对齐 MovieClaw `CredentialAuthProvider`(docs/design/site-center.md §4.4):
* 1. 清空 Cookie GET 登录页,记下 Set-Cookie;
* 2. 页面含 `imagecode.php` → 验证码,本期不做 OCR,直接失败(captcha);
* 3. 取登录表单隐藏字段,填用户名 / 密码、selectDefaults、extraFormData;有 TOTP 密钥且表单有二次验证字段时填当前 6 位码;
* 4. POST(不跟随 3xx),再 GET index.php;`loginSuccess` 命中 → 成功,拼 Cookie;
* 5. 失败按 errorCss 文本归类。
* 凭据只在内存中使用;错误消息不含密码 / Cookie。**未经真实站点核验**(表单字段、二次验证字段名、错误文本)。
*/
export type SiteLoginFailure =
| "captcha"
| "two_factor"
| "bad_credentials"
| "account_blocked"
| "challenge"
| "auth_expired"
| "network"
| "unsupported"
| "unknown";
export class SiteLoginError extends Error {
readonly reason: SiteLoginFailure;
constructor(reason: SiteLoginFailure, message: string) {
super(message);
this.name = "SiteLoginError";
this.reason = reason;
}
}
export interface NexusLoginForm {
path: string;
usernameField: string;
passwordField: string;
errorCss: string;
extraFormData?: Array<[string, string]>;
selectDefaults?: Array<[string, string]>;
}
export interface NexusLoginInput {
/** 站点前端地址(以 / 结尾或不结尾均可) */
baseUrl: string;
login: NexusLoginForm;
/** 登录成功判定选择器(定义 umt.loginSuccess;缺省 a[href*="logout"]) */
loginSuccess?: string;
username: string;
password: string;
/** base32 TOTP 密钥 */
totpSecret?: string | null;
userAgent: string;
fetch?: typeof globalThis.fetch;
limiter?: { acquire(signal?: AbortSignal): Promise<void> };
signal?: AbortSignal;
timeoutMs?: number;
now?: () => Date;
}
export interface NexusLoginResult {
cookie: string;
userAgent: string;
}
const TWO_FACTOR_FIELDS = ["two_step_code", "otp", "2fa_code", "twostep", "totp", "securitycode"];
/** RFC 6238 TOTP(SHA1、30 s、6 位) */
export function totpCode(secretBase32: string, at: Date = new Date()): string {
const clean = secretBase32.replace(/[\s=-]/g, "").toUpperCase();
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let bits = "";
for (const ch of clean) {
const v = alphabet.indexOf(ch);
if (v < 0) throw new SiteLoginError("unknown", "TOTP 密钥不是合法的 base32");
bits += v.toString(2).padStart(5, "0");
}
const bytes = new Uint8Array(Math.floor(bits.length / 8));
for (let i = 0; i < bytes.length; i++) bytes[i] = Number.parseInt(bits.slice(i * 8, i * 8 + 8), 2);
const counter = Math.floor(at.getTime() / 1000 / 30);
const msg = Buffer.alloc(8);
msg.writeBigUInt64BE(BigInt(counter));
const h = createHmac("sha1", Buffer.from(bytes)).update(msg).digest();
const off = h[h.length - 1]! & 0x0f;
const code = ((h[off]! & 0x7f) << 24) | (h[off + 1]! << 16) | (h[off + 2]! << 8) | h[off + 3]!;
return String(code % 1_000_000).padStart(6, "0");
}
/** 失败文本归类(errorCss 取到的站点原文) */
export function classifyLoginError(text: string): SiteLoginFailure {
const t = text.toLowerCase();
if (/两步|二步|二次验证|两次验证|动态码|two.?step|two.?factor|2fa|otp|authenticator/.test(t)) return "two_factor";
if (/验证码|imagecode|captcha/.test(t)) return "captcha";
if (/封禁|禁用|被禁|未确认|未激活|待审核|banned|disabled|not confirmed|pending|parked/.test(t))
return "account_blocked";
if (/密码|用户名|账号|帳號|password|username|incorrect|invalid|错误|錯誤/.test(t)) return "bad_credentials";
return "unknown";
}
const REASON_TEXT: Record<SiteLoginFailure, string> = {
captcha: "站点要求验证码",
two_factor: "站点要求二次验证",
bad_credentials: "用户名或密码错误",
account_blocked: "账号被封禁或未确认",
challenge: "站点返回 Cloudflare 挑战页",
auth_expired: "登录后仍未检测到登录态",
network: "无法连接站点",
unsupported: "站点不支持账密登录",
unknown: "登录失败",
};
function pairs(v: readonly unknown[] | undefined): Array<[string, string]> {
const out: Array<[string, string]> = [];
for (const p of v ?? []) {
if (Array.isArray(p) && p.length >= 2) out.push([String(p[0]), String(p[1])]);
else if (p && typeof p === "object" && "name" in p) {
const o = p as { name: unknown; value?: unknown };
out.push([String(o.name), String(o.value ?? "")]);
}
}
return out;
}
class Jar {
private readonly map = new Map<string, string>();
take(res: Response) {
const list =
typeof (res.headers as Headers & { getSetCookie?: () => string[] }).getSetCookie === "function"
? (res.headers as Headers & { getSetCookie: () => string[] }).getSetCookie()
: (res.headers.get("set-cookie") ?? "").split(/,(?=\s*[^;,=\s]+=)/).filter(Boolean);
for (const line of list) {
const first = line.split(";")[0] ?? "";
const eq = first.indexOf("=");
if (eq <= 0) continue;
const name = first.slice(0, eq).trim();
const value = first.slice(eq + 1).trim();
if (!value || value === "deleted" || /max-age=0\b/i.test(line)) this.map.delete(name);
else this.map.set(name, value);
}
}
get size() {
return this.map.size;
}
header(): string {
return [...this.map].map(([k, v]) => `${k}=${v}`).join("; ");
}
}
/** 账密登录;失败抛 SiteLoginError */
export async function loginNexusPhp(input: NexusLoginInput): Promise<NexusLoginResult> {
const fetchImpl = input.fetch ?? globalThis.fetch;
const base = input.baseUrl.endsWith("/") ? input.baseUrl : `${input.baseUrl}/`;
const jar = new Jar();
const timeoutMs = input.timeoutMs ?? 20_000;
const request = async (url: string, init: RequestInit = {}): Promise<{ res: Response; text: string }> => {
await input.limiter?.acquire(input.signal);
const timeout = AbortSignal.timeout(timeoutMs);
const signal = input.signal ? AbortSignal.any([input.signal, timeout]) : timeout;
const headers = new Headers(init.headers);
headers.set("user-agent", input.userAgent);
if (jar.size) headers.set("cookie", jar.header());
let res: Response;
try {
res = await fetchImpl(url, { ...init, headers, redirect: "manual", signal });
} catch (e) {
if (input.signal?.aborted) throw e;
throw new SiteLoginError(
"network",
redactText(`请求 ${url} 失败:${timeout.aborted ? "超时" : (e as Error).message}`),
);
}
jar.take(res);
const text = await res.text().catch(() => "");
if (res.status >= 500 && res.status !== 503) throw new SiteLoginError("network", `站点返回 HTTP ${res.status}`);
if ((res.status === 403 || res.status === 503) && underChallenge(text)) {
throw new SiteLoginError("challenge", REASON_TEXT.challenge);
}
if (res.status === 503) throw new SiteLoginError("network", "站点返回 HTTP 503");
return { res, text };
};
// 1. 登录页
const loginUrl = new URL(input.login.path, base).toString();
const page = await request(loginUrl);
if (underChallenge(page.text)) throw new SiteLoginError("challenge", REASON_TEXT.challenge);
// 2. 验证码
if (/imagecode\.php/i.test(page.text)) throw new SiteLoginError("captcha", REASON_TEXT.captcha);
const $ = load(page.text);
const byAction = $("form[action*='takelogin']").first();
const form = byAction.length ? byAction : $(`input[name='${input.login.passwordField}']`).closest("form").first();
const action = new URL(form.attr("action") || "takelogin.php", loginUrl).toString();
// 3. 表单
const body = new URLSearchParams();
const fieldNames = new Set<string>();
const inputs = form.length ? form.find("input") : $("input");
inputs.each((_, el) => {
const name = $(el).attr("name");
if (!name) return;
fieldNames.add(name.toLowerCase());
const type = ($(el).attr("type") ?? "text").toLowerCase();
if (type === "hidden") body.set(name, $(el).attr("value") ?? "");
if (type === "checkbox" && $(el).is("[checked]")) body.set(name, $(el).attr("value") ?? "on");
});
for (const [k, v] of pairs(input.login.selectDefaults)) body.set(k, v);
for (const [k, v] of pairs(input.login.extraFormData)) body.set(k, v);
body.set(input.login.usernameField, input.username);
body.set(input.login.passwordField, input.password);
const tfField = [...fieldNames].find((n) => TWO_FACTOR_FIELDS.includes(n));
if (tfField && input.totpSecret) body.set(tfField, totpCode(input.totpSecret, input.now?.() ?? new Date()));
// 4. 提交 → 首页判定
const post = await request(action, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded", referer: loginUrl },
body: body.toString(),
});
const location = post.res.headers.get("location") ?? "";
const home = await request(new URL("index.php", base).toString());
const successSel = input.loginSuccess || "a[href*='logout']";
const home$ = load(home.text);
if (home.res.status < 300 && home$(successSel).length > 0 && jar.size) {
return { cookie: jar.header(), userAgent: input.userAgent };
}
// 5. 失败归类
if (underChallenge(post.text) || underChallenge(home.text))
throw new SiteLoginError("challenge", REASON_TEXT.challenge);
const post$ = load(post.text);
const errText = post$(input.login.errorCss).text().replace(/\s+/g, " ").trim();
const pageText = post$("body").text().replace(/\s+/g, " ").trim();
if (/imagecode\.php/i.test(post.text)) throw new SiteLoginError("captcha", REASON_TEXT.captcha);
const nextHasTwoFactor =
TWO_FACTOR_FIELDS.some((f) => new RegExp(`name=["']?${f}["'\\s>]`, "i").test(post.text)) ||
/two_step|2fa|otp/i.test(location);
if (nextHasTwoFactor && !input.totpSecret) throw new SiteLoginError("two_factor", REASON_TEXT.two_factor);
const reason = classifyLoginError(errText || pageText.slice(0, 400));
if (reason !== "unknown")
throw new SiteLoginError(reason, `${REASON_TEXT[reason]}${errText ? `:${clip(errText)}` : ""}`);
throw new SiteLoginError("unknown", errText ? `登录失败:${clip(errText)}` : REASON_TEXT.auth_expired);
}
function clip(s: string, n = 200): string {
return s.length > n ? `${s.slice(0, n)}…` : s;
}
export function loginFailureText(reason: SiteLoginFailure): string {
return REASON_TEXT[reason];
}
@@ -145,6 +145,8 @@ export interface UmtSiteExtension {
* 主路径仍是 MP 用户信息解析器(`schema`),此处为备用数据,M2 使用。
*/
profile?: Record<string, string>;
/** VIP 等级名(用户数据 vip 判定) */
vipLevels?: string[];
displayName?: string;
/** 站点时间时区偏移,如 "+08:00";pubdate/freedate 按此转 UTC */
timezone: string;
@@ -21,6 +21,7 @@
* 3. 网络异常与非 200/500/403 状态:与 MP 一样返回空串继续,另把脱敏后的原因记在 `httpErrors`(入 raw)。
* 4. 响应解码:按 Content-Type / `<meta charset>` 选 TextDecoder(MP `get_decoded_html_content` 用 chardet 探测)。
*/
import { load as loadCheerio } from "cheerio";
import { redactText } from "../../rss/redact.ts";
import { parseSizeMp } from "../spider/spider.ts";
import { extractDomain, isLoggedIn, underChallenge } from "./common.ts";
@@ -259,7 +260,8 @@ export abstract class SiteParserBase {
),
);
let guard = 0;
while (nextPage !== null && nextPage !== undefined && nextPage !== false && guard++ < MAX_PAGES) {
const maxExtra = Math.max(0, (this._opts.maxSeedingPages ?? MAX_PAGES + 1) - 1);
while (nextPage !== null && nextPage !== undefined && nextPage !== false && guard++ < maxExtra) {
nextPage = await this._parseUserTorrentSeedingInfo(
await this._getPageContent(
urljoin(urljoin(this._baseUrl, this._torrentSeedingPage), nextPage),
@@ -407,6 +409,23 @@ export abstract class SiteParserBase {
}
/** MP `refresh_userdata` 里构造 `SiteUserData` 的部分 */
/** VIP 判定(本项目扩展,见 SiteUserInfo.vip) */
protected detectVip(): boolean | null {
const css = this._opts.vipCss?.replace(/::.*$/, "").trim();
const levels = this._opts.vipLevels?.filter(Boolean) ?? [];
if (!css && !levels.length) return null;
if (css && typeof this._indexHtml === "string" && this._indexHtml) {
try {
if (loadCheerio(this._indexHtml)(css).length > 0) return true;
} catch {
// 选择器不合法按未命中处理
}
}
const level = (this.userLevel ?? "").toLowerCase();
if (level && levels.some((l) => level.includes(l.toLowerCase()))) return true;
return false;
}
toUserInfo(): SiteUserInfo {
const num = (v: unknown) => {
const n = typeof v === "number" ? v : Number(v);
@@ -435,6 +454,7 @@ export abstract class SiteParserBase {
seedingInfo: this.seedingInfo.map(([a, b]) => [num(a), num(b)] as [number, number]),
messageUnread: num(this.messageUnread),
messageUnreadContents: messages,
vip: this.detectVip(),
errMsg: this.errMsg,
raw: this.toDict(),
};
@@ -48,7 +48,8 @@ export class MTorrentSiteUserInfo extends SiteParserBase {
protected _parseSitePage(_htmlText: string): void {
// 更换 api 地址
this._baseUrl = `https://api.${extractDomain(this._baseUrl)}`;
// 本项目扩展:站点配置了独立 API 地址(site.config.apiBaseUrl)时用它
this._baseUrl = this._apiUrl ? this._apiUrl.replace(/\/+$/, "") : `https://api.${extractDomain(this._baseUrl)}`;
this._userTrafficPage = null;
this._userDetailPage = null;
this._userBasicPage = "api/member/profile";
@@ -69,6 +69,11 @@ export interface SiteUserInfo {
messageUnreadContents: SiteUnreadMessage[];
/** H&R 未达标数:MP 解析器不提供,保留字段供站点扩展 */
hrCount?: number;
/**
* VIP(本项目扩展):定义 `umt.profile.vip_css` 在首页命中,或 `umt.vipLevels` 与等级匹配 → true;
* 两者都没配置为 null(不显示)
*/
vip: boolean | null;
/** 解析错误说明(MP `err_msg`,如"未检测到已登陆") */
errMsg: string | null;
raw: Record<string, unknown>;
@@ -93,4 +98,10 @@ export interface SiteUserParserOptions {
/** 共享站点限流器(与 list/search 共用) */
limiter?: { acquire(signal?: AbortSignal): Promise<void> };
signal?: AbortSignal;
/** 做种分页上限(含第一页);缺省 50 */
maxSeedingPages?: number;
/** VIP 判定:首页选择器(定义 umt.profile.vip_css,可带 ::attr / ::text 后缀,判定时去掉) */
vipCss?: string | null;
/** VIP 判定:等级名列表(定义 umt.vipLevels),不区分大小写包含匹配 */
vipLevels?: readonly string[] | null;
}
+6 -1
View File
@@ -14,7 +14,12 @@ export {
type MockMteamFailMode,
type MockMteamOptions,
} from "./mock-mteam.ts";
export { createMockNexusPhpSite, type MockNexusPhpOptions, type MockNexusPhpSite } from "./mock-nexusphp.ts";
export {
createMockNexusPhpSite,
type MockLoginGuard,
type MockNexusPhpOptions,
type MockNexusPhpSite,
} from "./mock-nexusphp.ts";
export { type MockMteamTorrent, MTEAM_FIXTURE_TORRENTS } from "./mteam-fixtures.ts";
export { AUDIENCES_FIXTURE_RELEASES, type MockNexusPromo, type MockNexusRelease } from "./nexusphp-fixtures.ts";
export { buildFakeTorrent, type FakeTorrent } from "./torrent.ts";
+4
View File
@@ -125,6 +125,10 @@ export async function createMockMteamApi(opts: MockMteamOptions = {}): Promise<M
memberCount: { uploaded: "1000", downloaded: "10", shareRate: "100" },
},
});
case "/api/member/getUserTorrentList":
return json(res, { code: "0", message: "SUCCESS", data: { data: [] } });
case "/api/tracker/myPeerStatus":
return json(res, { code: "0", message: "SUCCESS", data: { seeder: "0", leecher: "0" } });
default:
return json(res, { code: "1", message: "not found" }, 404);
}
+75 -5
View File
@@ -14,8 +14,15 @@ export interface MockNexusPhpOptions {
/** 登录失效时的表现:200 登录页(默认)或 302 跳转 login.php */
loginMode?: "page" | "redirect";
siteName?: string;
/** 账密登录(takelogin.php)接受的用户名 / 密码;缺省 mockuser / mock-password */
username?: string;
password?: string;
/** 登录关卡:验证码 / 二次验证 / 账号封禁;缺省无 */
loginGuard?: MockLoginGuard;
}
export type MockLoginGuard = "none" | "captcha" | "two_factor" | "blocked";
export interface MockNexusPhpSite extends StartedServer {
cookie: string;
releases: MockNexusRelease[];
@@ -25,6 +32,10 @@ export interface MockNexusPhpSite extends StartedServer {
failNext(n: number, status: number, retryAfter?: string): void;
/** 改版模拟:列表页返回无法识别的结构 */
setBrokenLayout(v: boolean): void;
/** 切换登录关卡 */
setLoginGuard(v: MockLoginGuard): void;
/** takelogin.php 收到的 POST 次数 */
readonly loginAttempts: number;
/** 各 .torrent 的 infohash,供断言 */
infoHashOf(id: string): string;
}
@@ -42,23 +53,48 @@ function esc(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
/** 观众新版 userbar(NexusAudiences 用户数据解析器读 data-uploader-*;数值与 AUDIENCES_USER_INDEX 一致) */
const AUDIENCES_USERBAR = `<div class="site-userbar">
<a class="site-userbar__user" href="userdetails.php?id=10001" data-uploader-url="userdetails.php?id=10001" data-uploader-label="mockuser" data-uploader-badge="Elite User" data-uploader-stats='[{"label":"上传量:","tone":"uploaded","value":"2.00 TB"},{"label":"下载量","tone":"downloaded","value":"100.00 GB"},{"label":"爆米花","tone":"bonus","value":"8,888.5"},{"label":"活跃","tone":"active","value":"↑ 30 / ↓ 1"}]'>mockuser</a>
<span class="site-userbar__compact-metric site-userbar__compact-metric--ratio"><span>分享率</span><span>20.48</span></span>
<a class="site-userbar__compact-tool" href="messages.php" title="收件箱 12/0">信</a>
</div>`;
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>`
<a href="logout.php">退出</a></div>${AUDIENCES_USERBAR}`
: "";
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 {
function loginPage(siteName: string, guard: MockLoginGuard = "none"): string {
const captcha =
guard === "captcha"
? `<input type="hidden" name="imagehash" value="mockhash"><img src="imagecode.php?action=regimage&amp;imagehash=mockhash"><input type="text" name="imagestring">`
: "";
const twoStep = guard === "two_factor" ? `<input type="text" name="two_step_code">` : "";
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>`;
<input type="hidden" name="returnto" value="index.php">
<input type="text" name="username"><input type="password" name="password">${captcha}${twoStep}<input type="submit" value="登录"></form></body></html>`;
}
function loginFailedPage(siteName: string, text: string): string {
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${esc(siteName)} :: 登录失败</title></head>
<body><table><tr><td class="text">${esc(text)}</td></tr></table></body></html>`;
}
const USER_DETAILS = `<html><body><table>
<tr><td class="rowhead">加入日期</td><td class="rowfollow">2020-05-06 07:08:09 (5年前)</td></tr>
</table></body></html>`;
const USER_SEEDING = `<table class="table table-bordered"><tr><td>类型</td><td>数量</td><td>体积</td></tr>
<tr><td>电影</td><td>20</td><td>2.00 TB</td></tr>
<tr><td>Total</td><td>31</td><td>3.50 TB</td></tr></table>`;
function promoImg(r: MockNexusRelease): string {
if (!r.promo) return "";
const title = r.promoUntil ?? "";
@@ -125,6 +161,10 @@ export async function createMockNexusPhpSite(opts: MockNexusPhpOptions = {}): Pr
const pageBase = opts.pageBase ?? 0;
const siteName = opts.siteName ?? "观众";
let loggedIn = true;
let guard: MockLoginGuard = opts.loginGuard ?? "none";
let loginAttempts = 0;
const username = opts.username ?? "mockuser";
const password = opts.password ?? "mock-password";
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 })]));
@@ -133,14 +173,34 @@ export async function createMockNexusPhpSite(opts: MockNexusPhpOptions = {}): Pr
res.writeHead(status, { "content-type": "text/html; charset=utf-8" }).end(html);
};
const server = await startHttpServer((req, res) => {
const server = await startHttpServer((req, res, raw) => {
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 (url.pathname === "/login.php") return send(res, 200, loginPage(siteName, guard));
if (url.pathname === "/takelogin.php" && req.method === "POST") {
loginAttempts++;
const form = new URLSearchParams(raw);
if (guard === "captcha") return send(res, 200, loginFailedPage(siteName, "验证码错误!"));
if (form.get("username") !== username || form.get("password") !== password) {
return send(res, 200, loginFailedPage(siteName, "用户名或密码不正确!或者你还没有通过验证?"));
}
if (guard === "blocked") return send(res, 200, loginFailedPage(siteName, "该账号已被禁用"));
if (guard === "two_factor" && !/^\d{6}$/.test(form.get("two_step_code") ?? "")) {
return send(res, 200, loginFailedPage(siteName, "两步验证码错误"));
}
loggedIn = true;
res
.writeHead(302, {
location: "index.php",
"set-cookie": cookie.split(";").map((p) => `${p.trim()}; path=/; httponly`),
})
.end();
return;
}
if (!loggedIn || !hasCookie(req, cookie)) {
if (opts.loginMode === "redirect") {
res.writeHead(302, { location: `login.php?returnto=${encodeURIComponent(url.pathname.slice(1))}` }).end();
@@ -153,6 +213,10 @@ export async function createMockNexusPhpSite(opts: MockNexusPhpOptions = {}): Pr
case "/index.php":
case "/":
return send(res, 200, shell(siteName, "首页", "<h2>最新公告</h2>", true));
case "/userdetails.php":
return send(res, 200, USER_DETAILS);
case "/getusertorrentlistajax.php":
return send(res, 200, USER_SEEDING);
case "/torrents.php": {
if (broken) return send(res, 200, shell(siteName, "种子", `<div class="new-grid">改版了</div>`, true));
const kw = (q.get("search") ?? "").toLowerCase().trim();
@@ -211,6 +275,12 @@ export async function createMockNexusPhpSite(opts: MockNexusPhpOptions = {}): Pr
setBrokenLayout(v) {
broken = v;
},
setLoginGuard(v) {
guard = v;
},
get loginAttempts() {
return loginAttempts;
},
infoHashOf(id) {
const t = torrents.get(id);
if (!t) throw new Error(`no torrent ${id}`);