diff --git a/packages/adapters/src/sites/user-parsers/base.ts b/packages/adapters/src/sites/user-parsers/base.ts
index 9637bc9b..b9f447eb 100644
--- a/packages/adapters/src/sites/user-parsers/base.ts
+++ b/packages/adapters/src/sites/user-parsers/base.ts
@@ -1,11 +1,11 @@
/**
* SPDX-License-Identifier: GPL-3.0-only
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
- * @mp-source app/modules/indexer/parser/__init__.py @ e6820a8
+ * @mp-source app/modules/indexer/parser/__init__.py @ 3b156a5
*
* 站点用户数据解析器基类。
*
- * 迁移自 MoviePilot e6820a8(GPLv3)`app/modules/indexer/parser/__init__.py` `SiteParserBase`,
+ * 迁移自 MoviePilot 3b156a5(GPLv3)`app/modules/indexer/parser/__init__.py` `SiteParserBase`,
* 函数一一对应(Python 私有 `_x_y` → TS `_xY`):
* - `parse` → `parse`(页面获取顺序:首页/登录判定 → `_parse_site_page` → 基础信息 → 详情 → 未读消息 → 流量 → 做种分页)
* - `_pase_unread_msgs` → `_paseUnreadMsgs`(保留 MP 拼写)、`_parse_seeding_pages` → `_parseSeedingPages`
@@ -18,7 +18,7 @@
* 与 MP 的差异(安全与健壮性):
* 1. 凭据经构造参数注入,只放请求头;`toDict()`/`raw` 不含 cookie/apikey/token。
* 2. 做种页与消息页翻页设上限(`MAX_PAGES`=50),防止站点返回自引用"下一页"导致死循环(MP 无上限)。
- * 3. 网络异常与非 200/500/403 状态:与 MP 一样返回空串继续,另把脱敏后的原因记在 `httpErrors`(入 raw)。
+ * 3. 非 200 状态记录诊断;网络异常与非 200/500/403 状态返回空串继续,原因脱敏后记在 `httpErrors`(入 raw)。
* 4. 响应解码:按 Content-Type / `` 选 TextDecoder(MP `get_decoded_html_content` 用 chardet 探测)。
*/
import { load as loadCheerio } from "cheerio";
@@ -114,6 +114,8 @@ export abstract class SiteParserBase {
protected _userBasicHeaders: Headers_ | null = null;
// 用户详情
protected _userDetailPage: string | null = "userdetails.php?id=";
+ /** 当前会话接口可在还不知道用户 ID 时读取详情。 */
+ protected _userDetailRequiresUserid = true;
protected _userDetailParams: Params | null = null;
protected _userDetailHeaders: Headers_ | null = null;
// 流量
@@ -192,7 +194,7 @@ export abstract class SiteParserBase {
} else {
await this._parseUserBaseInfo(this._indexHtml as string);
}
- if (this._userDetailPage) {
+ if (this._userDetailPage && (!this._userDetailRequiresUserid || this._hasValidUserid())) {
await this._parseUserDetailInfo(
await this._getPageContent(
urljoin(this._baseUrl, this._userDetailPage),
@@ -251,7 +253,7 @@ export abstract class SiteParserBase {
/** MP `_parse_seeding_pages` */
protected async _parseSeedingPages(): Promise {
- if (!this._torrentSeedingPage) return;
+ if (!this._torrentSeedingPage || !this._hasValidUserid()) return;
let nextPage: NextPage = await this._parseUserTorrentSeedingInfo(
await this._getPageContent(
urljoin(this._baseUrl, this._torrentSeedingPage),
@@ -273,6 +275,12 @@ export abstract class SiteParserBase {
}
}
+ /** MP `_has_valid_userid`:空白与占位 ID 0 不触发用户专属请求。 */
+ protected _hasValidUserid(): boolean {
+ const id = this.userid?.trim();
+ return Boolean(id && id !== "0");
+ }
+
/** MP `_prepare_html_text`:去掉 `\d+px` 与 `#\d+` 干扰 */
static prepareHtmlText(htmlText: string): string {
return String(htmlText ?? "")
@@ -331,9 +339,9 @@ export abstract class SiteParserBase {
this.httpErrors.push(redactText(`${init.method} ${url}:${timeout.aborted ? "超时" : (e as Error).message}`));
return "";
}
+ if (res.status !== 200) this.httpErrors.push(`${init.method}:HTTP ${res.status}`);
if (![200, 500, 403].includes(res.status)) {
await res.body?.cancel().catch(() => {});
- this.httpErrors.push(redactText(`${init.method} ${url}:HTTP ${res.status}`));
return "";
}
const bytes = new Uint8Array(await res.arrayBuffer());
diff --git a/packages/adapters/src/sites/user-parsers/bitpt.ts b/packages/adapters/src/sites/user-parsers/bitpt.ts
index 9cd23f3b..c402419c 100644
--- a/packages/adapters/src/sites/user-parsers/bitpt.ts
+++ b/packages/adapters/src/sites/user-parsers/bitpt.ts
@@ -1,7 +1,7 @@
/**
* SPDX-License-Identifier: GPL-3.0-only
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
- * @mp-source app/modules/indexer/parser/bitpt.py @ e6820a8
+ * @mp-source app/modules/indexer/parser/bitpt.py @ 3b156a5
*
* 极速之星(bitpt.cn)站点用户信息解析器(原作者 ThedoRap)。
*
@@ -11,7 +11,7 @@
* `parse` → `parse`(基类流程后取 uid → `userdetails.php?uid=` → `browse.php?t=myseed&st=2&d=desc&p=n` 逐页累加)。
* BeautifulSoup 以 cheerio 等价实现。
*
- * 与 MP 的偏离:末尾不把字段转字符串(同 zhixing.ts);做种翻页上限 50 页。
+ * 数值字段按 MP v3.2.4 保持数值;本项目做种翻页上限 50 页。
*/
import { load } from "cheerio";
import { type NextPage, SiteParserBase, urljoin } from "./base.ts";
diff --git a/packages/adapters/src/sites/user-parsers/common.test.ts b/packages/adapters/src/sites/user-parsers/common.test.ts
new file mode 100644
index 00000000..4b1a825f
--- /dev/null
+++ b/packages/adapters/src/sites/user-parsers/common.test.ts
@@ -0,0 +1,11 @@
+import { describe, expect, it } from "vitest";
+import { extractDomain } from "./common.ts";
+
+describe("站点域名归一", () => {
+ it("Cookie风格前导点不进入站点域名,特殊站点与多级地址保持原规则", () => {
+ expect(extractDomain(".tracker.example.com")).toBe("example.com");
+ expect(extractDomain("...")).toBe("");
+ expect(extractDomain("https://pt.sjtu.edu.cn/")).toBe("pt.sjtu.edu.cn");
+ expect(extractDomain("https://u2.dmhy.org/")).toBe("u2.dmhy.org");
+ });
+});
diff --git a/packages/adapters/src/sites/user-parsers/common.ts b/packages/adapters/src/sites/user-parsers/common.ts
index 37ebba45..37c9ff5e 100644
--- a/packages/adapters/src/sites/user-parsers/common.ts
+++ b/packages/adapters/src/sites/user-parsers/common.ts
@@ -1,12 +1,12 @@
/**
* SPDX-License-Identifier: GPL-3.0-only
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
- * @mp-source app/foundation/text.py @ e6820a8
- * @mp-source app/foundation/temporal.py @ e6820a8
- * @mp-source app/domain/site.py @ e6820a8
- * @mp-source app/adapters/network/cloudflare.py @ e6820a8
+ * @mp-source app/foundation/text.py @ 3b156a5
+ * @mp-source app/foundation/temporal.py @ 3b156a5
+ * @mp-source app/domain/site.py @ 3b156a5
+ * @mp-source app/adapters/network/cloudflare.py @ 3b156a5
*
- * 解析器公共工具:MoviePilot e6820a8(GPLv3)
+ * 解析器公共工具:MoviePilot 3b156a5(GPLv3)
* - `app/foundation/text.py` `parse_int/parse_float/contains_chinese` → `parseIntMp/parseFloatMp/containsChinese`
* - `app/foundation/temporal.py` `normalize_datetime/format_timestamp` → 同名(dateparser 的保守子集)
* - `app/domain/site.py` `SiteUtils.is_logged_in` → `isLoggedIn`
@@ -192,6 +192,8 @@ export function extractDomain(url: string | null | undefined): string {
}
}
if (!netloc) return "";
+ netloc = netloc.replace(/^\.+/, "");
+ if (!netloc) return "";
const labels = netloc.split(".");
if (labels.length > 3) return netloc;
return labels.slice(-2).join(".");
diff --git a/packages/adapters/src/sites/user-parsers/discuz.test.ts b/packages/adapters/src/sites/user-parsers/discuz.test.ts
index cc16c253..7e575ee2 100644
--- a/packages/adapters/src/sites/user-parsers/discuz.test.ts
+++ b/packages/adapters/src/sites/user-parsers/discuz.test.ts
@@ -33,4 +33,14 @@ describe("DiscuzUserInfo(MP discuz.py)", () => {
expect(info.errMsg).toContain("未检测到已登陆");
expect(info.userId).toBeNull();
});
+
+ it.each(["分享率 (1,234.5)", "分享率:8.25"])("分享率读取整条文本且不覆盖积分: %s", async (ratio) => {
+ const m = createMockFetch({
+ "/": { body: DISCUZ_USER_INDEX },
+ "/home.php": { body: `` },
+ });
+ const info = await runParser(DiscuzUserInfo, { fetch: m.fetch });
+ expect(info.ratio).toBe(ratio.includes("1,234") ? 1234.5 : 8.25);
+ expect(info.bonus).toBe(700);
+ });
});
diff --git a/packages/adapters/src/sites/user-parsers/discuz.ts b/packages/adapters/src/sites/user-parsers/discuz.ts
index c660bc24..83edcc43 100644
--- a/packages/adapters/src/sites/user-parsers/discuz.ts
+++ b/packages/adapters/src/sites/user-parsers/discuz.ts
@@ -1,14 +1,14 @@
/**
* SPDX-License-Identifier: GPL-3.0-only
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
- * @mp-source app/modules/indexer/parser/discuz.py @ e6820a8
+ * @mp-source app/modules/indexer/parser/discuz.py @ 3b156a5
*
* Discuz!X 站点用户信息解析器。
*
* 函数对照(`DiscuzUserInfo`):`_parse_user_base_info` → `_parseUserBaseInfo`、`_parse_site_page` → 空、
* `_parse_user_detail_info` → `_parseUserDetailInfo`、`_parse_user_torrent_seeding_info` →
* `_parseUserTorrentSeedingInfo`、`_parse_user_traffic_info` / `_parse_message_unread_links` / `_parse_message_content` → 空。
- * XPath 与正则逐字保留(含 MP 把"分享率"括号值写入 bonus 的原行为,随后被"积分"覆盖)。
+ * 分享率从整条列表项读取,独立于积分字段。
*/
import { type NextPage, SiteParserBase } from "./base.ts";
import { normalizeDatetime, parseFloatMp, parseIntMp, reSearch } from "./common.ts";
@@ -45,10 +45,12 @@ export class DiscuzUserInfo extends SiteParserBase {
if (levels.length) this.userLevel = levels[levels.length - 1]!.trim();
const joinAt = html.strings('//li[em[text()="注册时间"]]/text()');
if (joinAt.length) this.joinAt = normalizeDatetime(joinAt[0]!.trim()) ?? null;
- const ratioText = html.strings('//li[contains(.//text(), "分享率")]//text()');
- if (ratioText.length) {
- const m = reSearch(/\(([\d,.]+)\)/, ratioText[0]);
- if (m?.[1]?.trim()) this.bonus = parseFloatMp(m[1]);
+ for (const item of html.nodes('//li[contains(., "分享率")]')) {
+ const m = /分享率\s*[::]?\s*[((]?\s*([\d,.]+)/.exec(item.text());
+ if (m) {
+ this.ratio = parseFloatMp(m[1]);
+ break;
+ }
}
const bonusText = html.strings('//li[em[text()="积分"]]/text()');
if (bonusText.length) this.bonus = parseFloatMp(bonusText[0]!.trim());
diff --git a/packages/adapters/src/sites/user-parsers/dom.ts b/packages/adapters/src/sites/user-parsers/dom.ts
index d955405b..a05545aa 100644
--- a/packages/adapters/src/sites/user-parsers/dom.ts
+++ b/packages/adapters/src/sites/user-parsers/dom.ts
@@ -1,11 +1,11 @@
/**
* SPDX-License-Identifier: GPL-3.0-only
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
- * @mp-source app/foundation/dom.py @ e6820a8
+ * @mp-source app/foundation/dom.py @ 3b156a5
*
* lxml `etree.HTML(...).xpath(...)` 的 TS 等价层(站点用户信息解析器专用)。
*
- * MP 解析器(app/modules/indexer/parser/*.py,提交 e6820a8,GPLv3)全部用 lxml XPath 1.0 取值;
+ * MP 解析器(app/modules/indexer/parser/*.py,提交 3b156a5,GPLv3)全部用 lxml XPath 1.0 取值;
* 为了让 XPath 表达式能逐字迁移,本层:
* 1. 用 htmlparser2(经 cheerio,HTML 模式)解析——与 libxml2 HTML 解析器一样**不插入 **,
* 标签/属性名小写,实体解码(` ` → U+00A0);
diff --git a/packages/adapters/src/sites/user-parsers/file-list.ts b/packages/adapters/src/sites/user-parsers/file-list.ts
index b269f90f..61263bec 100644
--- a/packages/adapters/src/sites/user-parsers/file-list.ts
+++ b/packages/adapters/src/sites/user-parsers/file-list.ts
@@ -1,7 +1,7 @@
/**
* SPDX-License-Identifier: GPL-3.0-only
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
- * @mp-source app/modules/indexer/parser/file_list.py @ e6820a8
+ * @mp-source app/modules/indexer/parser/file_list.py @ 3b156a5
*
* FileList 站点用户数据解析器(MP `FileListSiteUserInfo`)。
* 函数对照:`_parse_site_page` → `_parseSitePage`、`_parse_user_base_info` → `_parseUserBaseInfo`、
diff --git a/packages/adapters/src/sites/user-parsers/gazelle.ts b/packages/adapters/src/sites/user-parsers/gazelle.ts
index fa2be55f..e338466c 100644
--- a/packages/adapters/src/sites/user-parsers/gazelle.ts
+++ b/packages/adapters/src/sites/user-parsers/gazelle.ts
@@ -1,7 +1,7 @@
/**
* SPDX-License-Identifier: GPL-3.0-only
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
- * @mp-source app/modules/indexer/parser/gazelle.py @ e6820a8
+ * @mp-source app/modules/indexer/parser/gazelle.py @ 3b156a5
*
* Gazelle 站点用户数据解析器(MP `GazelleSiteUserInfo`)。
* 函数对照:`_parse_logged_in` → `_parseLoggedIn`、`_parse_user_base_info` → `_parseUserBaseInfo`、
diff --git a/packages/adapters/src/sites/user-parsers/hddolby.test.ts b/packages/adapters/src/sites/user-parsers/hddolby.test.ts
index 8a74ad9d..15566911 100644
--- a/packages/adapters/src/sites/user-parsers/hddolby.test.ts
+++ b/packages/adapters/src/sites/user-parsers/hddolby.test.ts
@@ -50,6 +50,21 @@ describe("HDDolbySiteUserInfo(MP hddolby.py)", () => {
fetchMessages: true,
});
expect(info.userId).toBeNull();
- expect(m.requests.map((r) => r.url.pathname)).toEqual(["/api/v1/user/data", "/api/v1/user/peers"]);
+ expect(m.requests.map((r) => r.url.pathname)).toEqual(["/api/v1/user/data"]);
+ });
+
+ it("独立API地址保留路径前缀", async () => {
+ const m = createMockFetch({
+ "https://api.test.invalid/custom/v2/user/data": { body: HDDOLBY_USER_DATA },
+ "https://api.test.invalid/custom/v2/user/peers": { body: HDDOLBY_USER_PEERS },
+ });
+ const info = await runParser(HDDolbySiteUserInfo, {
+ fetch: m.fetch,
+ url: "https://www.hddolby.com/",
+ apiUrl: "https://api.test.invalid/custom/v2/",
+ apikey: "key",
+ });
+ expect(info).toMatchObject({ userId: "1", seeding: 2, seedingSize: 1500 });
+ expect(m.requests.map((r) => r.url.pathname)).toEqual(["/custom/v2/user/data", "/custom/v2/user/peers"]);
});
});
diff --git a/packages/adapters/src/sites/user-parsers/hddolby.ts b/packages/adapters/src/sites/user-parsers/hddolby.ts
index 8ba6c277..6c6a3691 100644
--- a/packages/adapters/src/sites/user-parsers/hddolby.ts
+++ b/packages/adapters/src/sites/user-parsers/hddolby.ts
@@ -1,7 +1,7 @@
/**
* SPDX-License-Identifier: GPL-3.0-only
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
- * @mp-source app/modules/indexer/parser/hddolby.py @ e6820a8
+ * @mp-source app/modules/indexer/parser/hddolby.py @ 3b156a5
*
* HDDolby 用户数据解析器(MP `HDDolbySiteUserInfo`,apikey 模式)。
* 函数对照:`HDDolby_sysRoleList` → `HDDOLBY_SYS_ROLE_LIST`、`_parse_site_page` → `_parseSitePage`(api.<域>)、
@@ -49,16 +49,17 @@ export class HDDolbySiteUserInfo extends SiteParserBase {
override requestMode = "apikey" as const;
protected _parseSitePage(_htmlText: string): void {
- this._baseUrl = `https://api.${extractDomain(this._baseUrl)}`;
+ const apiUrl = this._apiUrl || `https://api.${extractDomain(this._baseUrl)}/api/v1`;
+ this._baseUrl = `${apiUrl.replace(/\/+$/, "")}/`;
this._userTrafficPage = null;
this._userDetailPage = null;
- this._userBasicPage = "api/v1/user/data";
+ this._userBasicPage = "user/data";
this._userBasicParams = {};
this._userBasicHeaders = { "Content-Type": "application/json", Accept: "application/json, text/plain, */*" };
this._sysMailUnreadPage = null;
this._userMailUnreadPage = null;
this._mailUnreadParams = {};
- this._torrentSeedingPage = "api/v1/user/peers";
+ this._torrentSeedingPage = "user/peers";
this._torrentSeedingParams = {};
this._torrentSeedingHeaders = { "Content-Type": "application/json", Accept: "application/json, text/plain, */*" };
this._additionHeaders = { "x-api-key": this.apikey ?? "" };
diff --git a/packages/adapters/src/sites/user-parsers/ipt.ts b/packages/adapters/src/sites/user-parsers/ipt.ts
index 66fb6cba..74b47361 100644
--- a/packages/adapters/src/sites/user-parsers/ipt.ts
+++ b/packages/adapters/src/sites/user-parsers/ipt.ts
@@ -1,7 +1,7 @@
/**
* SPDX-License-Identifier: GPL-3.0-only
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
- * @mp-source app/modules/indexer/parser/ipt.py @ e6820a8
+ * @mp-source app/modules/indexer/parser/ipt.py @ 3b156a5
*
* IPTorrents 站点用户数据解析器(MP `IptSiteUserInfo`)。
* 函数对照:`_parse_user_base_info` → `_parseUserBaseInfo`、`_parse_site_page`(空)、
diff --git a/packages/adapters/src/sites/user-parsers/mtorrent.test.ts b/packages/adapters/src/sites/user-parsers/mtorrent.test.ts
index 12c02a10..2a466e49 100644
--- a/packages/adapters/src/sites/user-parsers/mtorrent.test.ts
+++ b/packages/adapters/src/sites/user-parsers/mtorrent.test.ts
@@ -88,4 +88,17 @@ describe("MTorrentSiteUserInfo(MP mtorrent.py)", () => {
expect(m2.requests).toHaveLength(0);
expect(info2.errMsg).toBeTruthy();
});
+
+ it("roleName覆盖旧角色编号,空做种页直接停止,不反复查询过时的总数", async () => {
+ const m = createMockFetch({
+ [`POST ${API}/api/member/profile`]: {
+ body: { code: "0", data: { id: 91, username: "reader", role: "2", roleName: "Special Member" } },
+ },
+ [`POST ${API}/api/member/getUserTorrentList`]: { body: { code: "0", data: { data: [] } } },
+ [`POST ${API}/api/tracker/myPeerStatus`]: { body: { code: "0", data: { seeder: 100 } } },
+ });
+ const info = await runParser(MTorrentSiteUserInfo, { fetch: m.fetch, url: "https://kp.m-team.cc/", apikey: "key" });
+ expect(info).toMatchObject({ userLevel: "Special Member", seeding: 0 });
+ expect(m.requests.map((r) => r.url.pathname)).toEqual(["/api/member/profile", "/api/member/getUserTorrentList"]);
+ });
});
diff --git a/packages/adapters/src/sites/user-parsers/mtorrent.ts b/packages/adapters/src/sites/user-parsers/mtorrent.ts
index 97d0a0e8..827666b7 100644
--- a/packages/adapters/src/sites/user-parsers/mtorrent.ts
+++ b/packages/adapters/src/sites/user-parsers/mtorrent.ts
@@ -1,7 +1,7 @@
/**
* SPDX-License-Identifier: GPL-3.0-only
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
- * @mp-source app/modules/indexer/parser/mtorrent.py @ e6820a8
+ * @mp-source app/modules/indexer/parser/mtorrent.py @ 3b156a5
*
* M-Team(mTorrent API)用户数据解析器(MP `MTorrentSiteUserInfo`,apikey 模式)。
* 函数对照:`MTeam_sysRoleList` → `MTEAM_SYS_ROLE_LIST`、`_parse_site_page` → `_parseSitePage`(改用 api.<域> 接口)、
@@ -79,7 +79,7 @@ export class MTorrentSiteUserInfo extends SiteParserBase {
const memberCount = (u.memberCount ?? {}) as Json;
this.userid = u.id == null ? null : String(u.id);
this.username = (u.username as string) ?? null;
- this.userLevel = MTEAM_SYS_ROLE_LIST[String(u.role || "1")] ?? null;
+ this.userLevel = (u.roleName as string) || MTEAM_SYS_ROLE_LIST[String(u.role || "1")] || null;
this.joinAt = ((u.memberStatus ?? {}) as Json).createdDate as string | null;
this.upload = pyInt(memberCount.uploaded || "0");
this.download = pyInt(memberCount.downloaded || "0");
@@ -117,6 +117,7 @@ export class MTorrentSiteUserInfo extends SiteParserBase {
this.seeding += pageSeeding;
this.seedingSize += pageSeedingSize;
this.seedingInfo.push(...pageSeedingInfo);
+ if (!pageSeeding) return null;
// 查询总做种数
let seederCount = 0;
diff --git a/packages/adapters/src/sites/user-parsers/nexus-audiences.test.ts b/packages/adapters/src/sites/user-parsers/nexus-audiences.test.ts
index c5d3de27..a0121ba8 100644
--- a/packages/adapters/src/sites/user-parsers/nexus-audiences.test.ts
+++ b/packages/adapters/src/sites/user-parsers/nexus-audiences.test.ts
@@ -72,4 +72,30 @@ describe("NexusAudiencesSiteUserInfo(MP nexus_audiences.py)", () => {
expect(NexusAudiencesSiteUserInfo.extractInboxUnreadBadge(" 5 ")).toBe(5);
expect(NexusAudiencesSiteUserInfo.extractInboxUnreadBadge("新")).toBeNull();
});
+
+ it("优先顶部用户栏,忽略列表上传者卡片的身份、等级与流量", async () => {
+ const m = createMockFetch({
+ "/": {
+ body: `退出
+
+ other999
+ `,
+ },
+ });
+ const info = await runParser(NexusAudiencesSiteUserInfo, { fetch: m.fetch });
+ expect(info).toMatchObject({ userId: "11", username: "self", userLevel: "Power User", upload: 8 * GiB, ratio: 4 });
+ });
+
+ it("没有顶部用户栏时只读取当前ID的卡片,不读取其他用户", async () => {
+ const m = createMockFetch({
+ "/": {
+ body: `退出self
+
+
+ `,
+ },
+ });
+ const info = await runParser(NexusAudiencesSiteUserInfo, { fetch: m.fetch });
+ expect(info).toMatchObject({ userId: "11", username: "self", upload: 8 * GiB });
+ });
});
diff --git a/packages/adapters/src/sites/user-parsers/nexus-audiences.ts b/packages/adapters/src/sites/user-parsers/nexus-audiences.ts
index 8ab96bf7..97cf49cd 100644
--- a/packages/adapters/src/sites/user-parsers/nexus-audiences.ts
+++ b/packages/adapters/src/sites/user-parsers/nexus-audiences.ts
@@ -1,7 +1,7 @@
/**
* SPDX-License-Identifier: GPL-3.0-only
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
- * @mp-source app/modules/indexer/parser/nexus_audiences.py @ e6820a8
+ * @mp-source app/modules/indexer/parser/nexus_audiences.py @ 3b156a5
*
* 观众(Audiences)站点用户信息解析器,继承 NexusPHP 通用解析。
*
@@ -273,16 +273,27 @@ export class NexusAudiencesSiteUserInfo extends NexusPhpSiteUserInfo {
private parseUserbarInfo(htmlText: string): void {
const html = parseHtml(htmlText);
if (!hasChildElements(html)) return;
- for (const node of html.nodes("//*[@data-uploader-url or @data-uploader-stats]")) {
+ const userbar = html.nodes('//*[contains(concat(" ", normalize-space(@class), " "), " site-userbar ")]')[0];
+ const scope = userbar ?? html;
+ let nodes = scope.nodes(".//*[@data-uploader-url or @data-uploader-stats]");
+ if (!userbar) {
+ const matching = nodes.filter((node) => {
+ const url = node.attr("data-uploader-url");
+ if (!url || !this.userid) return false;
+ return /userdetails\.php\?id=([^&]+)/.exec(url)?.[1] === this.userid;
+ });
+ nodes = matching.length ? matching : nodes.length === 1 ? nodes : [];
+ }
+ for (const node of nodes) {
this.parseUserIdentity(node);
this.parseUploaderStats(node.attr("data-uploader-stats"));
}
// data-uploader-stats 不包含分享率,需从 compact metric 的 class 中读取
- this.parseCompactMetric(html, "ratio", "ratio");
- this.parseCompactMetric(html, "uploaded", "upload");
- this.parseCompactMetric(html, "downloaded", "download");
- this.parseCompactMetric(html, "bonus", "bonus");
- this.parseCompactMetric(html, "active", "active");
+ this.parseCompactMetric(scope, "ratio", "ratio");
+ this.parseCompactMetric(scope, "uploaded", "upload");
+ this.parseCompactMetric(scope, "downloaded", "download");
+ this.parseCompactMetric(scope, "bonus", "bonus");
+ this.parseCompactMetric(scope, "active", "active");
}
private parseUserIdentity(node: XNode): void {
@@ -292,7 +303,11 @@ export class NexusAudiencesSiteUserInfo extends NexusPhpSiteUserInfo {
const username = node.attr("data-uploader-label");
if (username?.trim()) this.username = username.trim();
const level = node.attr("data-uploader-badge");
- if (level?.trim()) this.userLevel = level.trim();
+ const commonLevel = level
+ ?.trim()
+ .replace(/^\([^)]*\)\s*/, "")
+ .trim();
+ if (commonLevel) this.userLevel = commonLevel;
}
private parseUploaderStats(statsText: string | null): void {
@@ -316,12 +331,12 @@ export class NexusAudiencesSiteUserInfo extends NexusPhpSiteUserInfo {
private parseCompactMetric(html: XNode, metric: string, field: string): void {
let values = html.strings(
- `//*[contains(concat(" ", normalize-space(@class), " "), " site-userbar__compact-metric--${metric} ")]` +
+ `.//*[contains(concat(" ", normalize-space(@class), " "), " site-userbar__compact-metric--${metric} ")]` +
"//span[normalize-space()][last()]/text()",
);
if (!values.length) {
values = html.strings(
- `//*[contains(concat(" ", normalize-space(@class), " "), " site-userbar__compact-metric--${metric} ")]` +
+ `.//*[contains(concat(" ", normalize-space(@class), " "), " site-userbar__compact-metric--${metric} ")]` +
"/text()",
);
}
@@ -381,7 +396,7 @@ export class NexusAudiencesSiteUserInfo extends NexusPhpSiteUserInfo {
/** 做种页只读 Total 汇总行 */
protected override async _parseSeedingPages(): Promise {
- if (!this._torrentSeedingPage) return;
+ if (!this._torrentSeedingPage || !this._hasValidUserid()) return;
this._torrentSeedingHeaders = { Referer: urljoin(this._baseUrl, this._userDetailPage ?? "") };
const htmlText = await this._getPageContent(
urljoin(this._baseUrl, this._torrentSeedingPage),
diff --git a/packages/adapters/src/sites/user-parsers/nexus-hhanclub.ts b/packages/adapters/src/sites/user-parsers/nexus-hhanclub.ts
index 4ff3ecfc..46fabba9 100644
--- a/packages/adapters/src/sites/user-parsers/nexus-hhanclub.ts
+++ b/packages/adapters/src/sites/user-parsers/nexus-hhanclub.ts
@@ -1,7 +1,7 @@
/**
* SPDX-License-Identifier: GPL-3.0-only
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
- * @mp-source app/modules/indexer/parser/nexus_hhanclub.py @ e6820a8
+ * @mp-source app/modules/indexer/parser/nexus_hhanclub.py @ 3b156a5
*
* 憨憨(HHClub)站点用户信息解析器,继承 NexusPHP 通用解析。
*
diff --git a/packages/adapters/src/sites/user-parsers/nexus-php.test.ts b/packages/adapters/src/sites/user-parsers/nexus-php.test.ts
index 00806dda..03bac5e8 100644
--- a/packages/adapters/src/sites/user-parsers/nexus-php.test.ts
+++ b/packages/adapters/src/sites/user-parsers/nexus-php.test.ts
@@ -58,7 +58,7 @@ describe("NexusPhpSiteUserInfo(MP nexus_php.py)", () => {
const m = createMockFetch({ "/": { body: NEXUS_PHP_CF_CHALLENGE, status: 403 } });
const info = await runParser(NexusPhpSiteUserInfo, { fetch: m.fetch });
expect(info.errMsg).toContain("未检测到已登陆");
- expect(info.raw.http_errors).toEqual([expect.stringContaining("Cloudflare")]);
+ expect(info.raw.http_errors).toEqual(["GET:HTTP 403", expect.stringContaining("Cloudflare")]);
});
it("_fixup_next_page_url:补 userid/type,已有 userid 原样,无 userid 停止", () => {
@@ -68,4 +68,151 @@ describe("NexusPhpSiteUserInfo(MP nexus_php.py)", () => {
expect(NexusPhpSiteUserInfo.fixupNextPageUrl("x.php?userid=3&page=2", "7")).toBe("x.php?userid=3&page=2");
expect(NexusPhpSiteUserInfo.fixupNextPageUrl("x.php?page=2", null)).toBeNull();
});
+
+ it.each([null, "0"])("无有效用户ID %s 时不请求详情与做种", async (id) => {
+ const link = id === null ? "" : `用户`;
+ const m = createMockFetch({ "/": { body: `退出${link}` } });
+ await runParser(NexusPhpSiteUserInfo, { fetch: m.fetch });
+ expect(m.requests.map((r) => r.url.pathname)).toEqual(["/"]);
+ });
+
+ it("UUID 身份在详情与分页请求中保持一致", async () => {
+ const id = "12345678-abcd-4abc-8def-123456789abc";
+ const m = createMockFetch({
+ "/": {
+ body: `退出uuid-user`,
+ },
+ "/userdetails.php": { body: "资料" },
+ "/getusertorrentlistajax.php": (req) => ({
+ body: `${req.url.searchParams.has("page") ? "" : '下一页'}`,
+ }),
+ });
+ const info = await runParser(NexusPhpSiteUserInfo, { fetch: m.fetch });
+ expect(info).toMatchObject({ userId: id, username: "uuid-user", seeding: 2, seedingSize: 4 * 1024 ** 3 });
+ const requests = m.requests.filter((r) => r.url.pathname === "/getusertorrentlistajax.php");
+ expect(requests).toHaveLength(2);
+ for (const req of requests) {
+ expect(req.url.searchParams.get("useruuid")).toBe(id);
+ expect(req.url.searchParams.has("userid")).toBe(false);
+ }
+ expect(NexusPhpSiteUserInfo.fixupNextPageUrl("x.php?useruuid=existing&page=2", id)).toBe(
+ "x.php?useruuid=existing&page=2",
+ );
+ });
+
+ it("详情表格补齐首页缺失的流量和分享率,排除总上传等无关字段", async () => {
+ const m = createMockFetch({
+ "/": {
+ body: '退出reader',
+ },
+ "/userdetails.php": {
+ body: "| 总上传 | 999 TB |
| 上传量: | 24 GiB (已优惠) |
| 下载量: 3 GiB |
| 分享率 | 8.125 |
",
+ },
+ });
+ const info = await runParser(NexusPhpSiteUserInfo, { fetch: m.fetch });
+ expect(info).toMatchObject({ upload: 24 * 1024 ** 3, download: 3 * 1024 ** 3, ratio: 8.125 });
+ });
+
+ it("详情流量只回填零字段,并在没有显示分享率时用最终流量计算", async () => {
+ const m = createMockFetch({
+ "/": {
+ body: '退出reader上传: 12 GB',
+ },
+ "/userdetails.php": {
+ body: "",
+ },
+ });
+ const info = await runParser(NexusPhpSiteUserInfo, { fetch: m.fetch });
+ expect(info).toMatchObject({ upload: 12 * 1024 ** 3, download: 3 * 1024 ** 3, ratio: 4 });
+ });
+
+ it.each([
+ ['1,234.5', 1234.5],
+ ['魔力: 2,345.6', 2345.6],
+ ['', 3456.7],
+ ["无魔力值数据", 0],
+ ])("魔力值DOM布局 %s", async (markup, bonus) => {
+ const m = createMockFetch({ "/": { body: `退出${markup}` } });
+ const info = await runParser(NexusPhpSiteUserInfo, { fetch: m.fetch });
+ expect(info.bonus).toBe(bonus);
+ });
+
+ it("等级标记取当前UID的通用等级,不拿站点组名当等级", async () => {
+ const m = createMockFetch({
+ "/": {
+ body: '退出reader[UID=99][(other) Wrong][UID=21][(本站组名) Elite User]',
+ },
+ });
+ const info = await runParser(NexusPhpSiteUserInfo, { fetch: m.fetch });
+ expect(info.userLevel).toBe("Elite User");
+ });
+
+ it("Pter完整做种列表替换详情兜底,不重复累计,并发送导航请求头", async () => {
+ const row =
+ '| 图标 | 种子 | 时间 | 2 GB | 6 |
';
+ const m = createMockFetch({
+ "/": {
+ body: '退出reader',
+ },
+ "/userdetails.php": {
+ body: ``,
+ },
+ "/getusertorrentlist.php": { body: `` },
+ });
+ const info = await runParser(NexusPhpSiteUserInfo, { fetch: m.fetch, url: "https://pterclub.net/" });
+ expect(info).toMatchObject({
+ seeding: 2,
+ seedingSize: 4 * 1024 ** 3,
+ seedingInfo: [
+ [6, 2 * 1024 ** 3],
+ [6, 2 * 1024 ** 3],
+ ],
+ });
+ for (const req of m.requests.slice(1)) {
+ expect(req.headers["sec-fetch-mode"]).toBe("navigate");
+ expect(req.headers.accept).toContain("text/html");
+ }
+ });
+
+ it("做种接口失败时保留详情页列表,汇总数量和体积优先于部分分页", async () => {
+ const m = createMockFetch({
+ "/": {
+ body: '退出reader',
+ },
+ "/userdetails.php": {
+ body: "当前做种:10 做种大小:25 GB
",
+ },
+ });
+ const info = await runParser(NexusPhpSiteUserInfo, { fetch: m.fetch, url: "https://pterclub.com/" });
+ expect(info).toMatchObject({ seeding: 10, seedingSize: 25 * 1024 ** 3, seedingInfo: [[4, 2 * 1024 ** 3]] });
+ });
+
+ it("Keepfrds使用专用入口和第五/六列,保留详情汇总", async () => {
+ const m = createMockFetch({
+ "/": {
+ body: '退出reader',
+ },
+ "/userdetails.php": { body: "当前做种:9 做种大小:30 GB
" },
+ "/torrents.php": {
+ body: '',
+ },
+ });
+ const info = await runParser(NexusPhpSiteUserInfo, { fetch: m.fetch, url: "https://keepfrds.com/" });
+ expect(info).toMatchObject({ seeding: 9, seedingSize: 30 * 1024 ** 3, seedingInfo: [[7, 3 * 1024 ** 3]] });
+ expect(m.requests.at(-1)!.url.searchParams.get("option-torrents")).toBe("3");
+ });
+
+ it("葡萄只读做种汇总,不把全站种子链接当个人做种列表", async () => {
+ const m = createMockFetch({
+ "/": {
+ body: '退出reader',
+ },
+ "/viewtorrentsdetail.php": {
+ body: '当前做种:12 做种大小:40 GiB
全站种子下一页',
+ },
+ });
+ const info = await runParser(NexusPhpSiteUserInfo, { fetch: m.fetch, url: "https://pt.sjtu.edu.cn/" });
+ expect(info).toMatchObject({ seeding: 12, seedingSize: 40 * 1024 ** 3, seedingInfo: [] });
+ expect(m.requests.some((r) => r.url.pathname === "/torrents.php")).toBe(false);
+ });
});
diff --git a/packages/adapters/src/sites/user-parsers/nexus-php.ts b/packages/adapters/src/sites/user-parsers/nexus-php.ts
index a1437d78..048e4d14 100644
--- a/packages/adapters/src/sites/user-parsers/nexus-php.ts
+++ b/packages/adapters/src/sites/user-parsers/nexus-php.ts
@@ -1,18 +1,18 @@
/**
* SPDX-License-Identifier: GPL-3.0-only
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
- * @mp-source app/modules/indexer/parser/nexus_php.py @ e6820a8
+ * @mp-source app/modules/indexer/parser/nexus_php.py @ 3b156a5
*
* NexusPHP 站点用户信息解析器。
*
- * 迁移自 MoviePilot e6820a8(GPLv3)`app/modules/indexer/parser/nexus_php.py` `NexusPhpSiteUserInfo`,
+ * 迁移自 MoviePilot 3b156a5(GPLv3)`app/modules/indexer/parser/nexus_php.py` `NexusPhpSiteUserInfo`,
* 函数对照:`_parse_site_page` → `_parseSitePage`、`_parse_message_unread` → `_parseMessageUnread`、
* `_parse_user_base_info` → `_parseUserBaseInfo`、`_parse_user_traffic_info` → `_parseUserTrafficInfo`、
* `_parse_ucoin` → `parseUcoin`、`_parse_user_torrent_seeding_info` → `_parseUserTorrentSeedingInfo`、
* `_fixup_next_page_url` → `fixupNextPageUrl`、`_parse_user_detail_info` → `_parseUserDetailInfo`、
* `_fixup_torrent_seeding_page` → `_fixupTorrentSeedingPage`、`_get_user_level` → `_getUserLevel`、
* `_parse_message_unread_links` → `_parseMessageUnreadLinks`、`_parse_message_content` → `_parseMessageContent`、
- * `_fixup_traffic_info` → `_fixupTrafficInfo`。XPath 与正则逐字保留。
+ * `_fixup_traffic_info` → `_fixupTrafficInfo`;UUID、站点专属做种入口与汇总按 v3.2.4 行为更新。
*
* 差异:做种表"大小列/做种人数列"条数不一致时 MP 抛 IndexError 中断整站解析,这里缺失的做种人数记 0。
*/
@@ -28,14 +28,44 @@ export function round3(x: number): number {
export class NexusPhpSiteUserInfo extends SiteParserBase {
static override schema: import("./types.ts").SiteSchemaValue | null = SiteSchema.NexusPhp;
+ private summarySeeding: number | null = null;
+ private summarySeedingSize: number | null = null;
+
+ private get seedingDomain(): string {
+ return this._siteDomain.toLowerCase().replace(/^www\./, "");
+ }
+
+ private static isUuid(id: string | null): boolean {
+ return /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(id?.trim() ?? "");
+ }
+
+ /** 不同 NexusPHP 站点的做种入口;UUID 站点使用独立的参数名。 */
+ protected _buildTorrentSeedingPage(): string {
+ if (!this._hasValidUserid()) return "";
+ if (["pterclub.net", "pterclub.com"].includes(this.seedingDomain)) {
+ this._torrentSeedingHeaders = {
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
+ "Sec-Fetch-Dest": "document",
+ "Sec-Fetch-Mode": "navigate",
+ "Sec-Fetch-Site": "same-origin",
+ };
+ this._userDetailHeaders = { ...this._torrentSeedingHeaders };
+ return `getusertorrentlist.php?userid=${this.userid}&type=seeding`;
+ }
+ if (this.seedingDomain === "keepfrds.com") return `torrents.php?option-torrents=3&userid=${this.userid}`;
+ if (this.seedingDomain === "pt.sjtu.edu.cn") return `viewtorrentsdetail.php?id=${this.userid}`;
+ const key = NexusPhpSiteUserInfo.isUuid(this.userid) ? "useruuid" : "userid";
+ return `getusertorrentlistajax.php?${key}=${this.userid}&type=seeding`;
+ }
protected _parseSitePage(htmlText: string): void {
const text = this._prepareHtmlText(htmlText);
- const userDetail = reSearch(/userdetails.php\?id=(\d+)/, text);
+ const userDetail = reSearch(/userdetails\.php\?(?:id|uuid)=([0-9A-Za-z_-]+)/, text);
if (userDetail?.[0].trim()) {
this._userDetailPage = userDetail[0].trim().replace(/^\/+/, "");
this.userid = userDetail[1]!;
- this._torrentSeedingPage = `getusertorrentlistajax.php?userid=${this.userid}&type=seeding`;
+ this._torrentSeedingPage = this._buildTorrentSeedingPage();
} else {
const m = reSearch(/(userdetails)/, text);
if (m?.[0].trim()) {
@@ -71,6 +101,8 @@ export class NexusPhpSiteUserInfo extends SiteParserBase {
const html = parseHtml(htmlText);
if (!hasChildElements(html)) return;
+ const level = this.parseUserLevelMarker(html.text());
+ if (level) this.userLevel = level;
let ret = html.strings(`//a[contains(@href, "userdetails") and contains(@href, "${this.userid}")]//b//text()`);
if (ret.length) {
this.username = String(ret[0]);
@@ -98,26 +130,80 @@ export class NexusPhpSiteUserInfo extends SiteParserBase {
const [hasUcoin, bonus] = NexusPhpSiteUserInfo.parseUcoin(html);
this.bonus = bonus;
if (hasUcoin) return;
- const tmps = html ? html.strings('//a[contains(@href,"mybonus")]/text()') : [];
- if (tmps.length) {
- const bonusText = String(tmps[0]).trim();
- const m = reSearch(/([\d,.]+)/, bonusText);
- if (m && m[1]!.trim()) {
- this.bonus = parseFloatMp(m[1]);
+ const bonusLinks = html?.nodes('//a[contains(@href,"mybonus")]') ?? [];
+ for (const link of bonusLinks) {
+ for (const value of link.strings("text()")) {
+ const match = /([\d,.]+)/.exec(value.trim());
+ if (match) {
+ this.bonus = parseFloatMp(match[1]);
+ return;
+ }
+ }
+ const nested = /^[\s[((【]*([\d][\d,.]*)[\s\]))】]*$/.exec(link.text().trim());
+ const tail = /[::]\s*([\d,.]+)/.exec(link.strings("following-sibling::text()[1]")[0] ?? "");
+ if (nested || tail) {
+ this.bonus = parseFloatMp((nested ?? tail)![1]);
return;
}
}
+ for (const link of bonusLinks) {
+ for (const container of link.nodes('ancestor::*[position() <= 4][contains(., "魔力值")]')) {
+ const value = container.text();
+ if (value.length > 200) continue;
+ const match = /魔力值.*?[::]\s*([\d,.]+)/s.exec(value);
+ if (match) {
+ this.bonus = parseFloatMp(match[1]);
+ return;
+ }
+ }
+ }
let bonusMatch = reSearch(/mybonus.[[\]::<>/a-zA-Z_\-="'\s#;.(使用魔力值豆]+\s*([\d,.]+)[<()&\s]/, text);
if (bonusMatch && bonusMatch[1]!.trim()) {
this.bonus = parseFloatMp(bonusMatch[1]);
return;
}
- bonusMatch = reSearch(/[魔力值|\]][[\]::<>/a-zA-Z_\-="'\s#;]+\s*([\d,.]+|"[\d,.]+")[<>()&\s]/s, text);
+ bonusMatch = reSearch(/(?:魔力值|\])[[\]::<>/a-zA-Z_\-="'\s#;]+\s*([\d,.]+|"[\d,.]+")[<>()&\s]/s, text);
if (bonusMatch && bonusMatch[1]!.trim()) {
this.bonus = parseFloatMp(bonusMatch[1]!.replace(/^"+|"+$/g, ""));
}
}
+ /** 完整列表替换详情页的兜底列表;缺页时保留详情事实,站点汇总优先。 */
+ protected override async _parseSeedingPages(): Promise {
+ const previous = { count: this.seeding, size: this.seedingSize, info: [...this.seedingInfo] };
+ this.seeding = 0;
+ this.seedingSize = 0;
+ this.seedingInfo = [];
+ try {
+ await super._parseSeedingPages();
+ } catch (error) {
+ this.seeding = previous.count;
+ this.seedingSize = previous.size;
+ this.seedingInfo = previous.info;
+ throw error;
+ }
+ if ((!this.seeding || !this.seedingSize) && (previous.count || previous.size)) {
+ this.seeding = previous.count;
+ this.seedingSize = previous.size;
+ if (previous.info.length) this.seedingInfo = previous.info;
+ }
+ if (this.summarySeeding !== null) this.seeding = this.summarySeeding;
+ if (this.summarySeedingSize !== null) this.seedingSize = this.summarySeedingSize;
+ }
+
+ private parseSeedingSummary(html: XNode): void {
+ if (!["pterclub.net", "pterclub.com", "keepfrds.com", "pt.sjtu.edu.cn"].includes(this.seedingDomain)) return;
+ const text = html.strings("//text()[not(ancestor::script) and not(ancestor::style)]").join(" ");
+ const count =
+ /当前做种\s*[::]?\s*([\d,]+)/.exec(text) ??
+ (["pterclub.net", "pterclub.com"].includes(this.seedingDomain)
+ ? /正在做种的种子\s*[((]\s*共\s*([\d,]+)\s*个/.exec(text)
+ : null);
+ const size = /做种大小\s*[::]?\s*([\d,.]+\s*[KMGTPI]i?B)/i.exec(text);
+ if (count) this.seeding = this.summarySeeding = parseIntMp(count[1]);
+ if (size) this.seedingSize = this.summarySeedingSize = this.numFilesize(size[1]);
+ }
+
/** MP `_parse_ucoin`:金/银/铜币统一换算为铜币 */
static parseUcoin(html: XNode | null): [boolean, number] {
if (hasChildElements(html)) {
@@ -140,6 +226,9 @@ export class NexusPhpSiteUserInfo extends SiteParserBase {
protected _parseUserTorrentSeedingInfo(htmlText: string, multiPage = false): NextPage {
const html = parseHtml(String(htmlText ?? "").replaceAll("\\/", "/"));
if (!hasChildElements(html)) return null;
+ this.parseSeedingSummary(html);
+ // 葡萄只返回汇总,不跟页面上的全站种子入口。
+ if (this.seedingDomain === "pt.sjtu.edu.cn") return null;
// 首页存在扩展链接,使用扩展链接
const seedingUrlText = html.strings('//a[contains(@href,"torrents.php") and contains(@href,"seeding")]/@href');
@@ -160,22 +249,42 @@ export class NexusPhpSiteUserInfo extends SiteParserBase {
if (html.nodes(seedersColXpath).length) {
seedersCol = html.nodes(`${seedersColXpath}/preceding-sibling::td`).length + 1;
}
+ if (this.seedingDomain === "keepfrds.com") {
+ sizeCol = 5;
+ seedersCol = 6;
+ }
let pageSeeding = 0;
let pageSeedingSize = 0;
const pageSeedingInfo: Array<[number, number]> = [];
- const tableClass = html.nodes('//table[@class="torrents"]').length ? '//table[@class="torrents"]' : "";
- const seedingSizes = html.nodes(`${tableClass}//tr[position()>1]/td[${sizeCol}]`);
- let seedingSeeders = html.strings(`${tableClass}//tr[position()>1]/td[${seedersCol}]/b/a/text()`);
- if (!seedingSeeders.length)
- seedingSeeders = html.strings(`${tableClass}//tr[position()>1]/td[${seedersCol}]//text()`);
- if (seedingSizes.length && seedingSeeders.length) {
- pageSeeding = seedingSizes.length;
- for (let i = 0; i < seedingSizes.length; i++) {
- const size = this.numFilesize(seedingSizes[i]!.text().trim());
- const seeders = parseIntMp(seedingSeeders[i]);
+ if (["pterclub.net", "pterclub.com"].includes(this.seedingDomain)) {
+ const rows = html.nodes(
+ '//*[@id="outer"]/table/tbody/tr | //*[@id="outer"]/table/tr' +
+ '|//table[contains(concat(" ", normalize-space(@class), " "), " torrents ")]/tbody/tr' +
+ '|//table[contains(concat(" ", normalize-space(@class), " "), " torrents ")]/tr',
+ );
+ for (const row of rows) {
+ const cells = row.nodes("./td | ./th");
+ if (cells.length < 5 || !cells[1]!.nodes(".//a").length) continue;
+ const size = this.numFilesize(cells[3]!.text().trim());
+ if (!size) continue;
+ pageSeeding += 1;
pageSeedingSize += size;
- pageSeedingInfo.push([seeders, size]);
+ pageSeedingInfo.push([parseIntMp(cells[4]!.text().trim()), size]);
+ }
+ } else {
+ const tableClass = html.nodes('//table[@class="torrents"]').length ? '//table[@class="torrents"]' : "";
+ const seedingSizes = html.nodes(`${tableClass}//tr[position()>1]/td[${sizeCol}]`);
+ let seedingSeeders = html.strings(`${tableClass}//tr[position()>1]/td[${seedersCol}]/b/a/text()`);
+ if (!seedingSeeders.length)
+ seedingSeeders = html.strings(`${tableClass}//tr[position()>1]/td[${seedersCol}]//text()`);
+ if (seedingSizes.length && seedingSeeders.length) {
+ pageSeeding = seedingSizes.length;
+ for (let i = 0; i < seedingSizes.length; i++) {
+ const size = this.numFilesize(seedingSizes[i]!.text().trim());
+ pageSeedingSize += size;
+ pageSeedingInfo.push([parseIntMp(seedingSeeders[i]), size]);
+ }
}
}
this.seeding += pageSeeding;
@@ -208,9 +317,9 @@ export class NexusPhpSiteUserInfo extends SiteParserBase {
// parse_qsl → dict:同名键后者覆盖,保留插入顺序
const params = new Map();
for (const [k, v] of new URLSearchParams(query)) params.set(k, v);
- if (params.get("userid")) return nextPage;
+ if (params.get("userid") || params.get("useruuid")) return nextPage;
if (!userid) return null;
- params.set("userid", userid);
+ params.set(NexusPhpSiteUserInfo.isUuid(userid) ? "useruuid" : "userid", userid);
if (!params.has("type")) params.set("type", "seeding");
const qs = new URLSearchParams([...params.entries()]).toString();
return `${path}${qs ? `?${qs}` : ""}${fragment}`;
@@ -219,6 +328,7 @@ export class NexusPhpSiteUserInfo extends SiteParserBase {
protected _parseUserDetailInfo(htmlText: string): void {
const html = parseHtml(htmlText);
if (!hasChildElements(html)) return;
+ this.parseSeedingSummary(html);
this._getUserLevel(html);
this._fixupTrafficInfo(html);
@@ -233,20 +343,22 @@ export class NexusPhpSiteUserInfo extends SiteParserBase {
}
// 做种体积 & 做种数(seeding 页面获取不到的话,此处再获取一次)
- const seedingSizes = html.nodes(
- '//tr/td[text()="当前上传"]/following-sibling::td[1]//table[tr[1][td[4 and text()="尺寸"]]]//tr[position()>1]/td[4]',
+ const seedingTables = html.nodes(
+ '//tr/td[normalize-space(.)="当前上传" or normalize-space(.)="当前做种"]/following-sibling::td[1]//table',
);
- const seedingSeeders = html.strings(
- '//tr/td[text()="当前上传"]/following-sibling::td[1]//table[tr[1][td[5 and text()="做种者"]]]//tr[position()>1]/td[5]//text()',
- );
- let tmpSeeding = seedingSizes.length;
+ let tmpSeeding = 0;
let tmpSeedingSize = 0;
const tmpSeedingInfo: Array<[number, number]> = [];
- for (let i = 0; i < seedingSizes.length; i++) {
- const size = this.numFilesize(seedingSizes[i]!.text().trim());
- const seeders = parseIntMp(seedingSeeders[i]);
- tmpSeedingSize += size;
- tmpSeedingInfo.push([seeders, size]);
+ for (const table of seedingTables) {
+ for (const row of table.nodes("./tr | ./tbody/tr")) {
+ const cells = row.nodes("./td | ./th");
+ if (cells.length < 5) continue;
+ const size = this.numFilesize(cells[3]!.text().trim());
+ if (!size) continue;
+ tmpSeeding += 1;
+ tmpSeedingSize += size;
+ tmpSeedingInfo.push([parseIntMp(cells[4]!.text().trim()), size]);
+ }
}
if (!this.seedingSize) this.seedingSize = tmpSeedingSize;
if (!this.seeding) this.seeding = tmpSeeding;
@@ -277,10 +389,10 @@ export class NexusPhpSiteUserInfo extends SiteParserBase {
);
const csrfText = html.strings('//meta[@name="x-csrf"]/@content');
if (!this._torrentSeedingPage && seedingUrlText.length) {
- const userJs = reSearch(/javascript: getusertorrentlistajax\(\s*'(\d+)/, seedingUrlText[0]);
+ const userJs = reSearch(/javascript:\s*getusertorrentlistajax\(\s*'([^']+)'/, seedingUrlText[0]);
if (userJs?.[1]?.trim()) {
this.userid = userJs[1].trim();
- this._torrentSeedingPage = `getusertorrentlistajax.php?userid=${this.userid}&type=seeding`;
+ this._torrentSeedingPage = this._buildTorrentSeedingPage();
}
} else if (seedingUrlText.length && csrfText.length) {
if (csrfText[0]!.trim()) {
@@ -322,6 +434,14 @@ export class NexusPhpSiteUserInfo extends SiteParserBase {
}
}
}
+ const level = this.parseUserLevelMarker(html.text());
+ if (level) this.userLevel = level;
+ }
+
+ private parseUserLevelMarker(text: string): string | null {
+ const id = this.userid ? this.userid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : "\\d+";
+ const marker = new RegExp(`\\[\\s*UID\\s*=\\s*${id}\\s*\\]\\s*\\[\\s*\\([^\\)\\]]+\\)\\s*([^\\]]+?)\\s*\\]`, "i");
+ return marker.exec(text)?.[1]?.trim() || null;
}
protected _parseMessageUnreadLinks(htmlText: string, msgLinks: string[]): NextPage {
@@ -353,9 +473,34 @@ export class NexusPhpSiteUserInfo extends SiteParserBase {
/** MP `_fixup_traffic_info` */
protected _fixupTrafficInfo(html: XNode): void {
+ if (!this.upload) {
+ const value = this.trafficTableValue(html, /上[传傳]量?/);
+ if (value !== null) this.upload = this.numFilesize(value);
+ }
+ if (!this.download) {
+ const value = this.trafficTableValue(html, /下[载載]量?/);
+ if (value !== null) this.download = this.numFilesize(value);
+ }
+ if (!this.ratio) {
+ const value = this.trafficTableValue(html, /分享率/);
+ this.ratio = value !== null ? parseFloatMp(value) : this.download ? round3(this.upload / this.download) : 0;
+ }
if (!this.bonus) {
const bonusText = html.strings('//tr/td[text()="魔力值" or text()="猫粮"]/following-sibling::td[1]/text()');
if (bonusText.length) this.bonus = parseFloatMp(bonusText[0]!.trim());
}
}
+
+ private trafficTableValue(html: XNode, field: RegExp): string | null {
+ const label = new RegExp(`^${field.source}\\s*[::]?\\s*(.*)$`);
+ for (const cell of html.nodes("//tr/td[not(.//td)]")) {
+ const match = label.exec(cell.text().replace(/\s+/g, " ").trim());
+ if (!match) continue;
+ const value =
+ match[1]!.trim() || cell.nodes("following-sibling::td[1]")[0]?.text().replace(/\s+/g, " ").trim() || "";
+ const parsed = /^([\d,.]+(?:\s*[KMGTPI]i?B)?)(?:\s*[((].*)?$/i.exec(value);
+ if (parsed) return parsed[1]!;
+ }
+ return null;
+ }
}
diff --git a/packages/adapters/src/sites/user-parsers/nexus-project.ts b/packages/adapters/src/sites/user-parsers/nexus-project.ts
index bb6e83a2..9a5874f3 100644
--- a/packages/adapters/src/sites/user-parsers/nexus-project.ts
+++ b/packages/adapters/src/sites/user-parsers/nexus-project.ts
@@ -1,7 +1,7 @@
/**
* SPDX-License-Identifier: GPL-3.0-only
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
- * @mp-source app/modules/indexer/parser/nexus_project.py @ e6820a8
+ * @mp-source app/modules/indexer/parser/nexus_project.py @ 3b156a5
*
* NexusProject 站点用户信息解析器,继承 NexusPHP 通用解析。
*
diff --git a/packages/adapters/src/sites/user-parsers/nexus-rabbit.ts b/packages/adapters/src/sites/user-parsers/nexus-rabbit.ts
index 56f48db0..07e2f71c 100644
--- a/packages/adapters/src/sites/user-parsers/nexus-rabbit.ts
+++ b/packages/adapters/src/sites/user-parsers/nexus-rabbit.ts
@@ -1,7 +1,7 @@
/**
* SPDX-License-Identifier: GPL-3.0-only
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
- * @mp-source app/modules/indexer/parser/nexus_rabbit.py @ e6820a8
+ * @mp-source app/modules/indexer/parser/nexus_rabbit.py @ 3b156a5
*
* NexusRabbit(兔子/layui-hares 模板)站点用户信息解析器。
*
diff --git a/packages/adapters/src/sites/user-parsers/registry.test.ts b/packages/adapters/src/sites/user-parsers/registry.test.ts
index a345c620..a632c6f9 100644
--- a/packages/adapters/src/sites/user-parsers/registry.test.ts
+++ b/packages/adapters/src/sites/user-parsers/registry.test.ts
@@ -47,4 +47,57 @@ describe("用户信息解析器注册表(MP refresh_userdata)", () => {
expect(info).toMatchObject({ schema: "NexusPhp", userId: null });
expect(info!.errMsg).toContain("未检测到已登陆");
});
+
+ it("回退只尝试通用网页模型,不探测专站API", async () => {
+ const m = createMockFetch({
+ "/": { body: '退出没有用户资料
' },
+ });
+ const info = await refreshSiteUserData({
+ name: "demo",
+ url: "https://pt.example.com/",
+ schema: SiteSchema.NexusPhp,
+ apikey: "key",
+ fetch: m.fetch,
+ });
+ expect(info).toMatchObject({ userId: null, errMsg: "未获取到站点用户信息,请检查 Cookie 是否有效" });
+ expect(m.requests.map((r) => r.url.pathname)).toEqual(["/", "/", "/index.php", "/", "/index.php"]);
+ expect(m.requests.every((r) => r.url.host === "pt.example.com")).toBe(true);
+ });
+
+ it.each([
+ ["/account/current", "https://pt.example.com/base/account/current"],
+ ["https://account.example.net/current", "https://account.example.net/current"],
+ ])("用户资料入口 %s 保留原站域名并使用默认UA", async (userdataPath, expected) => {
+ const m = createMockFetch({
+ [expected]: { body: NEXUS_PHP_INDEX },
+ "/userdetails.php": { body: NEXUS_PHP_USERDETAILS },
+ });
+ const info = await refreshSiteUserData({
+ name: "demo",
+ url: "https://pt.example.com/base/",
+ schema: SiteSchema.NexusPhp,
+ userdataPath,
+ defaultUa: "Default/1",
+ fetch: m.fetch,
+ });
+ expect(m.requests[0]!.url.href).toBe(expected);
+ expect(m.requests[0]!.headers["user-agent"]).toBe("Default/1");
+ expect(info).toMatchObject({ domain: "example.com", userId: "10086" });
+ });
+
+ it("公开站不回退也不增加缺失用户ID错误;站点UA优先", async () => {
+ const m = createMockFetch({ "/": { body: '退出' } });
+ const info = await refreshSiteUserData({
+ name: "public",
+ url: "https://pt.example.com/",
+ schema: SiteSchema.NexusPhp,
+ public: true,
+ ua: "Site/2",
+ defaultUa: "Default/1",
+ fetch: m.fetch,
+ });
+ expect(info).toMatchObject({ userId: null, errMsg: null });
+ expect(m.requests).toHaveLength(1);
+ expect(m.requests[0]!.headers["user-agent"]).toBe("Site/2");
+ });
});
diff --git a/packages/adapters/src/sites/user-parsers/registry.ts b/packages/adapters/src/sites/user-parsers/registry.ts
index 6915dd05..e8558f36 100644
--- a/packages/adapters/src/sites/user-parsers/registry.ts
+++ b/packages/adapters/src/sites/user-parsers/registry.ts
@@ -7,7 +7,7 @@
* - `IndexerModule.__init__` 里 `ModuleHelper.load('app.modules.indexer.parser', filter=有 schema)` → `USER_PARSER_CLASSES`
* (按模块文件名字母序,与 pkgutil 遍历顺序一致;回退时按此顺序尝试)
* - `IndexerModule.refresh_userdata` → `refreshSiteUserData`:按站点声明的 schema 取解析器;解析后 userid 为空且非公开站时,
- * 依次尝试其他 schema(兼容 schema 标注错误/变种站),其他 schema 的异常只记录不抛出。
+ * 仅尝试 Gazelle/NexusPhp/Unit3d 通用模型,避免探测专站 API;回退异常不抛出。
*
* 与 MP 一致:声明 schema 解析器的异常原样抛出(由调用方记录),只有回退尝试的异常被吞掉。
* 差异:结果不含凭据;`fetchMessages` 默认 false(MP 由全局 SITE_MESSAGE 控制)。
@@ -31,7 +31,7 @@ import { SmallHorseSiteUserInfo } from "./small-horse.ts";
import { SunnyPTSiteUserInfo } from "./sunnypt.ts";
import { TNodeSiteUserInfo } from "./tnode.ts";
import { TorrentLeechSiteUserInfo } from "./torrent-leech.ts";
-import type { SiteSchemaValue, SiteUserInfo, SiteUserParserOptions } from "./types.ts";
+import { SiteSchema, type SiteSchemaValue, type SiteUserInfo, type SiteUserParserOptions } from "./types.ts";
import { Unit3dSiteUserInfo } from "./unit3d.ts";
import { YemaSiteUserInfo } from "./yema.ts";
import { ZhixingSiteUserInfo } from "./zhixing.ts";
@@ -72,15 +72,27 @@ export interface SiteUserDataRequest extends Omit([SiteSchema.Gazelle, SiteSchema.NexusPhp, SiteSchema.Unit3d]);
+
/** MP `refresh_userdata`:未找到解析器返回 null */
export async function refreshSiteUserData(site: SiteUserDataRequest): Promise {
const make = (schema: string | null | undefined): SiteParserBase | null => {
const cls = getUserParserClass(schema);
if (!cls) return null;
- const { name, schema: _s, public: _p, ...rest } = site;
- return new cls({ ...rest, siteName: name });
+ const { name, schema: _s, public: _p, userdataPath, defaultUa, ...rest } = site;
+ const path = userdataPath?.trim();
+ const url = path
+ ? /^https?:\/\//.test(path)
+ ? path
+ : new URL(path.replace(/^\/+/, ""), `${site.url.replace(/\/+$/, "")}/`).toString()
+ : site.url;
+ return new cls({ ...rest, siteName: name, url, ua: site.ua || defaultUa });
};
let obj = make(site.schema);
if (!obj) return null;
@@ -90,7 +102,7 @@ export async function refreshSiteUserData(site: SiteUserDataRequest): Promise([String(site.schema)]);
for (const cls of USER_PARSER_CLASSES) {
const value = cls.schema as SiteSchemaValue | null;
- if (!value || tried.has(value)) continue;
+ if (!value || tried.has(value) || !FALLBACK_SCHEMAS.has(value)) continue;
tried.add(value);
const alt = make(value);
if (!alt) continue;
@@ -105,6 +117,9 @@ export async function refreshSiteUserData(site: SiteUserDataRequest): Promise {
expect(info.userId).toBeNull();
expect(info.errMsg).toBe("API Key 无效");
});
+
+ it.each([null, [], "unexpected"])("消息API返回非对象data %s 时按空消息处理", async (data) => {
+ const m = createMockFetch({
+ "GET https://rousi.pro/api/v1/profile": { body: ROUSI_USER_PROFILE },
+ "GET https://rousi.pro/api/messages": { body: { code: 0, data } },
+ });
+ const info = await runParser(RousiSiteUserInfo, {
+ fetch: m.fetch,
+ url: "https://rousi.pro/",
+ apikey: "key",
+ token: "token",
+ fetchMessages: true,
+ });
+ expect(info).toMatchObject({ userId: "1", messageUnread: 0, messageUnreadContents: [] });
+ });
});
diff --git a/packages/adapters/src/sites/user-parsers/rousi.ts b/packages/adapters/src/sites/user-parsers/rousi.ts
index 6fd811e6..d28720f9 100644
--- a/packages/adapters/src/sites/user-parsers/rousi.ts
+++ b/packages/adapters/src/sites/user-parsers/rousi.ts
@@ -1,7 +1,7 @@
/**
* SPDX-License-Identifier: GPL-3.0-only
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
- * @mp-source app/modules/indexer/parser/rousi.py @ e6820a8
+ * @mp-source app/modules/indexer/parser/rousi.py @ 3b156a5
*
* Rousi.pro(PeerGo)用户数据解析器(MP `RousiSiteUserInfo`,apikey 模式)。
* 函数对照:`_parse_site_page` → `_parseSitePage`(api/v1/profile,Bearer API Key)、`_parse_logged_in`(恒 True)、
@@ -128,7 +128,9 @@ export class RousiSiteUserInfo extends SiteParserBase {
const qs = new URLSearchParams({ page: String(page), page_size: "100", unread_only: "true" });
const res = await this._rawJson("GET", `${urljoin(this._baseUrl, "api/messages")}?${qs}`, headers);
if (!res || (res.code ?? -1) !== 0) return { messages: [], total_pages: 0 };
- return (res.data ?? {}) as Json;
+ return res.data && typeof res.data === "object" && !Array.isArray(res.data)
+ ? (res.data as Json)
+ : { messages: [], total_pages: 0 };
};
let page = 0;
let res = await getMessageList(page);
diff --git a/packages/adapters/src/sites/user-parsers/small-horse.ts b/packages/adapters/src/sites/user-parsers/small-horse.ts
index 3c7daf39..1ae8a9b4 100644
--- a/packages/adapters/src/sites/user-parsers/small-horse.ts
+++ b/packages/adapters/src/sites/user-parsers/small-horse.ts
@@ -1,7 +1,7 @@
/**
* SPDX-License-Identifier: GPL-3.0-only
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
- * @mp-source app/modules/indexer/parser/small_horse.py @ e6820a8
+ * @mp-source app/modules/indexer/parser/small_horse.py @ 3b156a5
*
* Small Horse(Gazelle 变种)站点用户信息解析器。
*
diff --git a/packages/adapters/src/sites/user-parsers/sunnypt.ts b/packages/adapters/src/sites/user-parsers/sunnypt.ts
index 5eff0d3c..cfa73d81 100644
--- a/packages/adapters/src/sites/user-parsers/sunnypt.ts
+++ b/packages/adapters/src/sites/user-parsers/sunnypt.ts
@@ -1,7 +1,7 @@
/**
* SPDX-License-Identifier: GPL-3.0-only
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
- * @mp-source app/modules/indexer/parser/sunnypt.py @ e6820a8
+ * @mp-source app/modules/indexer/parser/sunnypt.py @ 3b156a5
*
* SunnyPT MoviePilot API 用户数据解析器(MP `SunnyPTSiteUserInfo`,apikey 模式)。
* 函数对照:`_parse_site_page` → `_parseSitePage`(api_url,缺省 https://api.sunnypt.top/api/v1/mp)、
diff --git a/packages/adapters/src/sites/user-parsers/tnode.ts b/packages/adapters/src/sites/user-parsers/tnode.ts
index f8bfbaaa..e4559c0b 100644
--- a/packages/adapters/src/sites/user-parsers/tnode.ts
+++ b/packages/adapters/src/sites/user-parsers/tnode.ts
@@ -1,7 +1,7 @@
/**
* SPDX-License-Identifier: GPL-3.0-only
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
- * @mp-source app/modules/indexer/parser/tnode.py @ e6820a8
+ * @mp-source app/modules/indexer/parser/tnode.py @ 3b156a5
*
* TNode 站点用户数据解析器(MP `TNodeSiteUserInfo`)。
* 函数对照:`_parse_site_page` → `_parseSitePage`(首页取 x-csrf-token,改走 JSON 接口)、
@@ -34,6 +34,7 @@ export class TNodeSiteUserInfo extends SiteParserBase {
if (csrf) {
this._additionHeaders = { "X-CSRF-TOKEN": csrf[1]! };
this._userDetailPage = "api/user/getMainInfo";
+ this._userDetailRequiresUserid = false;
this._torrentSeedingPage = "api/user/listTorrentActivity?id=&type=seeding&page=1&size=20000";
}
}
diff --git a/packages/adapters/src/sites/user-parsers/torrent-leech.ts b/packages/adapters/src/sites/user-parsers/torrent-leech.ts
index 3c1a761f..dcce1569 100644
--- a/packages/adapters/src/sites/user-parsers/torrent-leech.ts
+++ b/packages/adapters/src/sites/user-parsers/torrent-leech.ts
@@ -1,7 +1,7 @@
/**
* SPDX-License-Identifier: GPL-3.0-only
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
- * @mp-source app/modules/indexer/parser/torrent_leech.py @ e6820a8
+ * @mp-source app/modules/indexer/parser/torrent_leech.py @ 3b156a5
*
* TorrentLeech 站点用户信息解析器(MP `TorrentLeechSiteUserInfo`)。
* 函数对照:`_parse_site_page` → `_parseSitePage`、`_parse_user_base_info` → `_parseUserBaseInfo`、
diff --git a/packages/adapters/src/sites/user-parsers/types.ts b/packages/adapters/src/sites/user-parsers/types.ts
index 6e0e55f3..3f53a46f 100644
--- a/packages/adapters/src/sites/user-parsers/types.ts
+++ b/packages/adapters/src/sites/user-parsers/types.ts
@@ -1,12 +1,12 @@
/**
* SPDX-License-Identifier: GPL-3.0-only
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
- * @mp-source app/modules/indexer/parser/__init__.py @ e6820a8
- * @mp-source app/schemas/site.py @ e6820a8
+ * @mp-source app/modules/indexer/parser/__init__.py @ 3b156a5
+ * @mp-source app/schemas/site.py @ 3b156a5
*
* 站点用户信息解析类型。
*
- * 来源:MoviePilot e6820a8(GPLv3)`app/modules/indexer/parser/__init__.py` `SiteSchema`、
+ * 来源:MoviePilot 3b156a5(GPLv3)`app/modules/indexer/parser/__init__.py` `SiteSchema`、
* `app/schemas/site.py` `SiteUserData`、`app/modules/indexer/__init__.py` `refresh_userdata`。
*/
diff --git a/packages/adapters/src/sites/user-parsers/unit3d.test.ts b/packages/adapters/src/sites/user-parsers/unit3d.test.ts
index 61392771..d422809b 100644
--- a/packages/adapters/src/sites/user-parsers/unit3d.test.ts
+++ b/packages/adapters/src/sites/user-parsers/unit3d.test.ts
@@ -16,6 +16,7 @@ describe("Unit3dSiteUserInfo(MP unit3d.py)", () => {
const info = await runParser(Unit3dSiteUserInfo, { fetch: m.fetch });
expect(info).toMatchObject({
schema: "Unit3d",
+ userId: "carol",
username: "carol",
userLevel: "Elite",
joinAt: "2020-02-03 04:05:06",
@@ -42,4 +43,14 @@ describe("Unit3dSiteUserInfo(MP unit3d.py)", () => {
expect(info.errMsg).toContain("未检测到已登陆");
expect(info.raw.http_errors).toEqual([expect.stringContaining("HTTP 404")]);
});
+
+ it("新式bonus路径只读取当前用户的积分", async () => {
+ const m = createMockFetch({
+ "/": {
+ body: '退出设置99991,234.5',
+ },
+ });
+ const info = await runParser(Unit3dSiteUserInfo, { fetch: m.fetch });
+ expect(info).toMatchObject({ userId: "carol", username: "carol", bonus: 1234.5 });
+ });
});
diff --git a/packages/adapters/src/sites/user-parsers/unit3d.ts b/packages/adapters/src/sites/user-parsers/unit3d.ts
index 8c69d458..731fb706 100644
--- a/packages/adapters/src/sites/user-parsers/unit3d.ts
+++ b/packages/adapters/src/sites/user-parsers/unit3d.ts
@@ -1,7 +1,7 @@
/**
* SPDX-License-Identifier: GPL-3.0-only
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
- * @mp-source app/modules/indexer/parser/unit3d.py @ e6820a8
+ * @mp-source app/modules/indexer/parser/unit3d.py @ 3b156a5
*
* UNIT3D 站点用户数据解析器(MP `Unit3dSiteUserInfo`)。
* 函数对照:`_parse_user_base_info` → `_parseUserBaseInfo`、`_parse_site_page`(空)、
@@ -24,11 +24,18 @@ export class Unit3dSiteUserInfo extends SiteParserBase {
const m = reSearch(/\/users\/(.+)\/settings/, hrefs[0]);
if (m?.[0].trim()) {
this.username = m[1]!;
+ this.userid = this.username;
this._torrentSeedingPage = `/users/${this.username}/active?perPage=100&client=&seeding=include`;
this._userDetailPage = `/users/${this.username}`;
}
}
- const bonus = html.nodes('//a[contains(@href, "bonus/earnings")]');
+ let bonus = html.nodes('//a[contains(@href, "bonus/earnings")]');
+ if (!bonus.length && this.username) {
+ bonus = html.nodes('//a[contains(@href, "/users/") and contains(@href, "/bonus/")]').filter((node) => {
+ const path = new URL(node.attr("href") ?? "", this._baseUrl).pathname;
+ return path.startsWith(`/users/${this.username}/bonus/`);
+ });
+ }
if (bonus.length) {
const m = reSearch(/([\d,.]+)/, bonus[0]!.text());
if (m?.[1]?.trim()) this.bonus = parseFloatMp(m[1]);
diff --git a/packages/adapters/src/sites/user-parsers/yema.ts b/packages/adapters/src/sites/user-parsers/yema.ts
index 257a601f..6d4fe6c0 100644
--- a/packages/adapters/src/sites/user-parsers/yema.ts
+++ b/packages/adapters/src/sites/user-parsers/yema.ts
@@ -1,7 +1,7 @@
/**
* SPDX-License-Identifier: GPL-3.0-only
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
- * @mp-source app/modules/indexer/parser/yema.py @ e6820a8
+ * @mp-source app/modules/indexer/parser/yema.py @ 3b156a5
*
* YemaPT 开放 API 用户数据解析器(MP `YemaSiteUserInfo`,apikey 模式)。
* 函数对照:`_parse_site_page` → `_parseSitePage`(POST openApi/user/fetchBasicInfo.json,Authorization 头为 AuthKey)、
diff --git a/packages/adapters/src/sites/user-parsers/zhixing.ts b/packages/adapters/src/sites/user-parsers/zhixing.ts
index 24087323..0dd05985 100644
--- a/packages/adapters/src/sites/user-parsers/zhixing.ts
+++ b/packages/adapters/src/sites/user-parsers/zhixing.ts
@@ -1,7 +1,7 @@
/**
* SPDX-License-Identifier: GPL-3.0-only
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
- * @mp-source app/modules/indexer/parser/zhixing.py @ e6820a8
+ * @mp-source app/modules/indexer/parser/zhixing.py @ 3b156a5
*
* 知行(pt.zhixing.bjtu.edu.cn)站点用户信息解析器(原作者 ThedoRap)。
*
@@ -12,7 +12,7 @@
* BeautifulSoup 以 cheerio 等价实现。
*
* 与 MP 的偏离:
- * 1. MP 末尾把全部字段转成字符串(避免其 UI join 报错);这里保持数值类型(`toUserInfo` 语义相同)。
+ * 1. 数值字段与 MP v3.2.4 一样保持数值,缺失身份字段按本项目合同保留 null。
* 2. 做种翻页设上限 50 页(MP `while True`)。
*/
import { load } from "cheerio";