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

@@ -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;
}