byr-pt-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1654 @@
1
+ import { CliAppError } from "@onemoreproduct/cli-core";
2
+
3
+ //#region src/domain/byr-metadata.ts
4
+ function aliases(value, ...extra) {
5
+ return [String(value), ...extra];
6
+ }
7
+ const BYR_CATEGORY_FACET = {
8
+ key: "category",
9
+ name: "类别",
10
+ mode: "append",
11
+ options: [
12
+ {
13
+ value: 408,
14
+ name: "电影",
15
+ aliases: aliases(408, "movie", "movies", "film")
16
+ },
17
+ {
18
+ value: 401,
19
+ name: "剧集",
20
+ aliases: aliases(401, "series", "tv", "show", "drama")
21
+ },
22
+ {
23
+ value: 404,
24
+ name: "动漫",
25
+ aliases: aliases(404, "anime", "animation")
26
+ },
27
+ {
28
+ value: 402,
29
+ name: "音乐",
30
+ aliases: aliases(402, "music")
31
+ },
32
+ {
33
+ value: 405,
34
+ name: "综艺",
35
+ aliases: aliases(405, "variety", "reality")
36
+ },
37
+ {
38
+ value: 403,
39
+ name: "游戏",
40
+ aliases: aliases(403, "game", "games")
41
+ },
42
+ {
43
+ value: 406,
44
+ name: "软件",
45
+ aliases: aliases(406, "software", "app", "apps")
46
+ },
47
+ {
48
+ value: 407,
49
+ name: "资料",
50
+ aliases: aliases(407, "material", "materials", "doc", "docs")
51
+ },
52
+ {
53
+ value: 409,
54
+ name: "体育",
55
+ aliases: aliases(409, "sports", "sport")
56
+ },
57
+ {
58
+ value: 410,
59
+ name: "纪录",
60
+ aliases: aliases(410, "documentary", "documentaries", "docu")
61
+ }
62
+ ]
63
+ };
64
+ const BYR_INCLDEAD_FACET = {
65
+ key: "incldead",
66
+ name: "显示断种/活种",
67
+ options: [
68
+ {
69
+ value: 0,
70
+ name: "全部",
71
+ aliases: aliases(0, "all")
72
+ },
73
+ {
74
+ value: 1,
75
+ name: "仅活种",
76
+ aliases: aliases(1, "alive", "active")
77
+ },
78
+ {
79
+ value: 2,
80
+ name: "仅断种",
81
+ aliases: aliases(2, "dead")
82
+ }
83
+ ]
84
+ };
85
+ const BYR_SPSTATE_FACET = {
86
+ key: "spstate",
87
+ name: "促销种子",
88
+ options: [
89
+ {
90
+ value: 0,
91
+ name: "全部",
92
+ aliases: aliases(0, "all")
93
+ },
94
+ {
95
+ value: 1,
96
+ name: "普通",
97
+ aliases: aliases(1, "normal", "none")
98
+ },
99
+ {
100
+ value: 2,
101
+ name: "免费",
102
+ aliases: aliases(2, "free")
103
+ },
104
+ {
105
+ value: 3,
106
+ name: "2X",
107
+ aliases: aliases(3, "2x")
108
+ },
109
+ {
110
+ value: 4,
111
+ name: "2X免费",
112
+ aliases: aliases(4, "2xfree", "2x-free")
113
+ },
114
+ {
115
+ value: 5,
116
+ name: "50%",
117
+ aliases: aliases(5, "50", "half")
118
+ },
119
+ {
120
+ value: 6,
121
+ name: "2X 50%",
122
+ aliases: aliases(6, "2x50", "2x-50")
123
+ },
124
+ {
125
+ value: 7,
126
+ name: "30%",
127
+ aliases: aliases(7, "30", "thirty")
128
+ }
129
+ ]
130
+ };
131
+ const BYR_BOOKMARKED_FACET = {
132
+ key: "bookmarked",
133
+ name: "显示收藏",
134
+ options: [
135
+ {
136
+ value: 0,
137
+ name: "全部",
138
+ aliases: aliases(0, "all")
139
+ },
140
+ {
141
+ value: 1,
142
+ name: "仅收藏",
143
+ aliases: aliases(1, "only", "bookmarked")
144
+ },
145
+ {
146
+ value: 2,
147
+ name: "仅未收藏",
148
+ aliases: aliases(2, "unbookmarked", "not-bookmarked")
149
+ }
150
+ ]
151
+ };
152
+ const BYR_LEVEL_REQUIREMENTS = [
153
+ {
154
+ id: 1,
155
+ name: "User",
156
+ privilege: "新用户的默认级别:上传字幕;发布趣味盒;查看用户列表;查看NFO文档;"
157
+ },
158
+ {
159
+ id: 2,
160
+ name: "Power User",
161
+ interval: "P14D",
162
+ uploaded: "32GB",
163
+ ratio: 1.05,
164
+ privilege: "请求续种;查看排行榜;查看普通日志;删除自己上传的字幕;使用流量条;更新外部信息;新增求种"
165
+ },
166
+ {
167
+ id: 3,
168
+ name: "Elite User",
169
+ interval: "P56D",
170
+ uploaded: "512GB",
171
+ ratio: 1.55,
172
+ privilege: "查看其它用户的种子历史(如果用户隐私等级未设置为“强”);直接发布种子"
173
+ },
174
+ {
175
+ id: 4,
176
+ name: "Crazy User",
177
+ interval: "P84D",
178
+ uploaded: "1024GB",
179
+ ratio: 2.05,
180
+ privilege: "购买邀请;发送邀请;在做种/下载/发布的时候选择匿名模式"
181
+ },
182
+ {
183
+ id: 5,
184
+ name: "Insane User",
185
+ interval: "P168D",
186
+ uploaded: "2048GB",
187
+ ratio: 2.55,
188
+ privilege: "申请发布徽章;更新外部信息;购买用户名特效"
189
+ },
190
+ {
191
+ id: 6,
192
+ name: "Veteran User",
193
+ interval: "P168D",
194
+ uploaded: "4096GB",
195
+ ratio: 3.05,
196
+ privilege: "查看其他用户的评论和帖子历史记录(如果用户隐私等级未设置为“强”);查看种子结构"
197
+ },
198
+ {
199
+ id: 7,
200
+ name: "Extreme User",
201
+ interval: "P168D",
202
+ uploaded: "8192GB",
203
+ ratio: 3.55,
204
+ privilege: "可以购买用户名特效(动态)"
205
+ },
206
+ {
207
+ id: 8,
208
+ name: "Ultimate User",
209
+ interval: "P336D",
210
+ uploaded: "32768GB",
211
+ ratio: 4.05,
212
+ privilege: "更加高级"
213
+ },
214
+ {
215
+ id: 9,
216
+ name: "Nexus Master",
217
+ interval: "P48W",
218
+ uploaded: "131072GB",
219
+ ratio: 4.55,
220
+ privilege: "最高晋级用户等级:使用魔力值修改用户名(支持中文);可以领取专属荣誉徽章"
221
+ },
222
+ {
223
+ id: 100,
224
+ name: "贵宾",
225
+ groupType: "vip",
226
+ privilege: "免除分享率考核"
227
+ },
228
+ {
229
+ id: 200,
230
+ name: "养老族",
231
+ groupType: "manager",
232
+ privilege: "免除上传速度监测"
233
+ },
234
+ {
235
+ id: 201,
236
+ name: "发布员",
237
+ groupType: "manager",
238
+ privilege: "查看匿名用户的真实身份;查看被禁止的种子;访问论坛工作组专区"
239
+ },
240
+ {
241
+ id: 202,
242
+ name: "总版主",
243
+ groupType: "manager",
244
+ privilege: "管理种子,包括编辑/删除/设优惠/置顶;管理种子评论;管理论坛帖子;管理群聊区;管理趣味盒;管理字幕区;查看机密日志;查看管理组信箱"
245
+ },
246
+ {
247
+ id: 203,
248
+ name: "维护开发员",
249
+ groupType: "manager",
250
+ privilege: "管理站点设定和代码"
251
+ },
252
+ {
253
+ id: 204,
254
+ name: "主管",
255
+ groupType: "manager",
256
+ privilege: "管理组成员的任免;发放特殊用户组和管理组的工资(魔力值);管理站点任务系统;其他未被提及的权限"
257
+ }
258
+ ];
259
+ function normalizeAlias(value) {
260
+ return value.trim().toLowerCase();
261
+ }
262
+ function parseFacetValue(inputs, options) {
263
+ const aliasMap = /* @__PURE__ */ new Map();
264
+ for (const option of options) {
265
+ aliasMap.set(String(option.value), option.value);
266
+ for (const alias of option.aliases) aliasMap.set(normalizeAlias(alias), option.value);
267
+ }
268
+ const values = [];
269
+ const invalid = [];
270
+ for (const input of inputs) for (const token of input.split(",")) {
271
+ const key = normalizeAlias(token);
272
+ if (key.length === 0) continue;
273
+ const mapped = aliasMap.get(key);
274
+ if (mapped === void 0) {
275
+ invalid.push(token.trim());
276
+ continue;
277
+ }
278
+ values.push(mapped);
279
+ }
280
+ return {
281
+ values: Array.from(new Set(values)),
282
+ invalid
283
+ };
284
+ }
285
+ function parseCategoryAliases(inputs) {
286
+ return parseFacetValue(inputs, BYR_CATEGORY_FACET.options);
287
+ }
288
+ function parseSimpleFacetAliases(facet, inputs) {
289
+ return parseFacetValue(inputs, facet.options);
290
+ }
291
+ function guessByrLevelId(levelName) {
292
+ const normalized = normalizeAlias(levelName);
293
+ if (normalized.length === 0) return;
294
+ return BYR_LEVEL_REQUIREMENTS.find((level) => normalizeAlias(level.name) === normalized)?.id;
295
+ }
296
+ function getByrMetadata() {
297
+ return {
298
+ category: BYR_CATEGORY_FACET,
299
+ incldead: BYR_INCLDEAD_FACET,
300
+ spstate: BYR_SPSTATE_FACET,
301
+ bookmarked: BYR_BOOKMARKED_FACET,
302
+ levels: BYR_LEVEL_REQUIREMENTS
303
+ };
304
+ }
305
+
306
+ //#endregion
307
+ //#region src/domain/http/session.ts
308
+ var HttpSession = class {
309
+ baseUrl;
310
+ timeoutMs;
311
+ fetchImpl;
312
+ userAgent;
313
+ cookieJar = /* @__PURE__ */ new Map();
314
+ constructor(options) {
315
+ this.baseUrl = normalizeBaseUrl$1(options.baseUrl);
316
+ this.timeoutMs = sanitizeTimeout$1(options.timeoutMs);
317
+ this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
318
+ this.userAgent = options.userAgent ?? "byr-pt-cli";
319
+ if (typeof this.fetchImpl !== "function") throw new CliAppError({
320
+ code: "E_UNKNOWN",
321
+ message: "Global fetch is unavailable. Use Node 18+ or provide fetchImpl."
322
+ });
323
+ mergeCookieHeader(this.cookieJar, options.initialCookie);
324
+ }
325
+ hasCookie(name) {
326
+ return this.cookieJar.has(name);
327
+ }
328
+ cookieSize() {
329
+ return this.cookieJar.size;
330
+ }
331
+ clearCookies() {
332
+ this.cookieJar.clear();
333
+ }
334
+ serializeCookies() {
335
+ return serializeCookieJar(this.cookieJar);
336
+ }
337
+ mergeCookieHeader(cookieHeader) {
338
+ mergeCookieHeader(this.cookieJar, cookieHeader);
339
+ }
340
+ async fetch(options) {
341
+ const headers = new Headers(options.headers);
342
+ if (options.includeAuthCookie) {
343
+ const cookieHeader = serializeCookieJar(this.cookieJar);
344
+ if (cookieHeader.length > 0) headers.set("cookie", cookieHeader);
345
+ }
346
+ if (!headers.has("user-agent")) headers.set("user-agent", this.userAgent);
347
+ const url = resolveUrl$2(this.baseUrl, options.pathOrUrl);
348
+ const controller = new AbortController();
349
+ const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
350
+ try {
351
+ const response = await this.fetchImpl(url, {
352
+ method: options.method,
353
+ headers,
354
+ body: options.body,
355
+ redirect: options.redirect,
356
+ signal: controller.signal
357
+ });
358
+ mergeSetCookies(this.cookieJar, response.headers);
359
+ return response;
360
+ } catch (error) {
361
+ if (error instanceof CliAppError) throw error;
362
+ if (error instanceof Error && error.name === "AbortError") throw new CliAppError({
363
+ code: "E_UPSTREAM_TIMEOUT",
364
+ message: `BYR request timed out after ${this.timeoutMs}ms`,
365
+ details: { url: url.toString() }
366
+ });
367
+ throw new CliAppError({
368
+ code: "E_UPSTREAM_NETWORK",
369
+ message: "Failed to reach BYR upstream",
370
+ details: {
371
+ url: url.toString(),
372
+ reason: error instanceof Error ? error.message : String(error)
373
+ },
374
+ cause: error
375
+ });
376
+ } finally {
377
+ clearTimeout(timeout);
378
+ }
379
+ }
380
+ async fetchText(options, contextMessage) {
381
+ const response = await this.fetch(options);
382
+ if (!response.ok) throw mapNonOkResponse(response, contextMessage);
383
+ return response.text();
384
+ }
385
+ };
386
+ function mapNonOkResponse(response, contextMessage) {
387
+ if (response.status === 404) return new CliAppError({
388
+ code: "E_NOT_FOUND_RESOURCE",
389
+ message: "BYR resource was not found",
390
+ details: { status: response.status }
391
+ });
392
+ return new CliAppError({
393
+ code: "E_UPSTREAM_BAD_RESPONSE",
394
+ message: `${contextMessage} (status ${response.status})`,
395
+ details: { status: response.status }
396
+ });
397
+ }
398
+ function sanitizeTimeout$1(timeoutMs) {
399
+ if (timeoutMs === void 0 || !Number.isFinite(timeoutMs) || timeoutMs <= 0) return 15e3;
400
+ return Math.floor(timeoutMs);
401
+ }
402
+ function normalizeBaseUrl$1(baseUrl) {
403
+ const normalized = baseUrl.trim();
404
+ if (normalized.length === 0) return "https://byr.pt/";
405
+ return normalized.endsWith("/") ? normalized : `${normalized}/`;
406
+ }
407
+ function resolveUrl$2(baseUrl, pathOrUrl) {
408
+ if (/^https?:\/\//i.test(pathOrUrl)) return new URL(pathOrUrl);
409
+ return new URL(pathOrUrl, baseUrl);
410
+ }
411
+ function mergeCookieHeader(cookieJar, cookieHeader) {
412
+ if (cookieHeader === void 0 || cookieHeader.trim().length === 0) return;
413
+ for (const part of cookieHeader.split(";")) {
414
+ const segment = part.trim();
415
+ if (segment.length === 0) continue;
416
+ const eqIndex = segment.indexOf("=");
417
+ if (eqIndex <= 0) continue;
418
+ const name = segment.slice(0, eqIndex).trim();
419
+ const value = segment.slice(eqIndex + 1).trim();
420
+ if (name.length > 0) cookieJar.set(name, value);
421
+ }
422
+ }
423
+ function serializeCookieJar(cookieJar) {
424
+ return Array.from(cookieJar.entries()).map(([name, value]) => `${name}=${value}`).join("; ");
425
+ }
426
+ function mergeSetCookies(cookieJar, headers) {
427
+ const setCookieHeaders = readSetCookieHeaders(headers);
428
+ for (const raw of setCookieHeaders) {
429
+ const firstSegment = raw.split(";", 1)[0]?.trim();
430
+ if (firstSegment === void 0 || firstSegment.length === 0) continue;
431
+ const eqIndex = firstSegment.indexOf("=");
432
+ if (eqIndex <= 0) continue;
433
+ const cookieName = firstSegment.slice(0, eqIndex).trim();
434
+ const cookieValue = firstSegment.slice(eqIndex + 1).trim();
435
+ if (cookieName.length === 0) continue;
436
+ if (cookieValue.length === 0 || cookieValue === "deleted") {
437
+ cookieJar.delete(cookieName);
438
+ continue;
439
+ }
440
+ cookieJar.set(cookieName, cookieValue);
441
+ }
442
+ }
443
+ function readSetCookieHeaders(headers) {
444
+ const headersWithSetCookie = headers;
445
+ if (typeof headersWithSetCookie.getSetCookie === "function") return headersWithSetCookie.getSetCookie();
446
+ if (typeof headersWithSetCookie.raw === "function") return headersWithSetCookie.raw()["set-cookie"] ?? [];
447
+ const fallback = headers.get("set-cookie");
448
+ if (fallback === null) return [];
449
+ return splitCombinedSetCookieHeader(fallback);
450
+ }
451
+ function splitCombinedSetCookieHeader(raw) {
452
+ const parts = [];
453
+ let buffer = "";
454
+ let insideExpires = false;
455
+ for (let index = 0; index < raw.length; index += 1) {
456
+ const char = raw[index];
457
+ if (raw.slice(index).toLowerCase().startsWith("expires=")) insideExpires = true;
458
+ if (char === "," && !insideExpires) {
459
+ const normalized = buffer.trim();
460
+ if (normalized.length > 0) parts.push(normalized);
461
+ buffer = "";
462
+ continue;
463
+ }
464
+ if (char === ";") insideExpires = false;
465
+ buffer += char;
466
+ }
467
+ const normalized = buffer.trim();
468
+ if (normalized.length > 0) parts.push(normalized);
469
+ return parts;
470
+ }
471
+
472
+ //#endregion
473
+ //#region src/domain/nexusphp/parser.ts
474
+ const SIZE_PATTERN = /\b\d+(?:\.\d+)?\s*(?:B|KB|MB|GB|TB|PB|KIB|MIB|GIB|TIB)\b/i;
475
+ const INTEGER_PATTERN = /^\d+$/;
476
+ function parseLoginForm(html) {
477
+ const forms = Array.from(html.matchAll(/<form\b([^>]*)>([\s\S]*?)<\/form>/gi));
478
+ for (const formMatch of forms) {
479
+ const formAttrs = parseTagAttributes(formMatch[1]);
480
+ const formBody = formMatch[2] ?? "";
481
+ if (!/type\s*=\s*["']?password/i.test(formBody)) continue;
482
+ const action = formAttrs.action ?? "/takelogin.php";
483
+ const hiddenFields = new URLSearchParams();
484
+ const inputs = Array.from(formBody.matchAll(/<input\b([^>]*)>/gi)).map((match) => parseTagAttributes(match[1]));
485
+ let usernameField;
486
+ let passwordField;
487
+ const requiresManualField = [];
488
+ for (const input of inputs) {
489
+ const name = input.name?.trim();
490
+ if (name === void 0 || name.length === 0) continue;
491
+ const type = (input.type ?? "text").toLowerCase();
492
+ const value = input.value ?? "";
493
+ if (type === "hidden") hiddenFields.set(name, value);
494
+ if (type === "password") {
495
+ passwordField = name;
496
+ continue;
497
+ }
498
+ if (usernameField === void 0 && (type === "text" || type === "email")) {
499
+ if (looksLikeUsernameField(name)) usernameField = name;
500
+ }
501
+ if (isManualVerificationField(name) && type !== "hidden") requiresManualField.push(name);
502
+ }
503
+ if (usernameField === void 0) usernameField = inputs.find((input) => {
504
+ const type = (input.type ?? "text").toLowerCase();
505
+ const name = input.name?.trim();
506
+ if (name === void 0 || name.length === 0) return false;
507
+ if (type !== "text" && type !== "email") return false;
508
+ return !isManualVerificationField(name);
509
+ })?.name?.trim();
510
+ if (usernameField !== void 0 && passwordField !== void 0) return {
511
+ action,
512
+ usernameField,
513
+ passwordField,
514
+ hiddenFields,
515
+ requiresManualField
516
+ };
517
+ }
518
+ throw new CliAppError({
519
+ code: "E_UPSTREAM_BAD_RESPONSE",
520
+ message: "Unable to find BYR login form fields"
521
+ });
522
+ }
523
+ function looksLikeUsernameField(name) {
524
+ return /user(name)?|email|login|uid/i.test(name);
525
+ }
526
+ function isManualVerificationField(name) {
527
+ return /captcha|verify|code|image|string|otp|token|2fa|security/i.test(name);
528
+ }
529
+ function parseTagAttributes(raw) {
530
+ const attributes = {};
531
+ for (const match of raw.matchAll(/([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g)) {
532
+ const key = match[1]?.toLowerCase();
533
+ if (key === void 0) continue;
534
+ attributes[key] = decodeHtmlEntities(match[2] ?? match[3] ?? match[4] ?? "");
535
+ }
536
+ return attributes;
537
+ }
538
+ function looksLikeLoginPage(html) {
539
+ const lower = html.toLowerCase();
540
+ if (!lower.includes("password")) return false;
541
+ if (lower.includes("takelogin.php") || lower.includes("login.php")) return true;
542
+ return /登录|signin|sign in|not authorized|auth_form/.test(lower);
543
+ }
544
+ function looksLikeNotFoundPage(html) {
545
+ return /not\s+found|不存在|沒有找到|没有找到|invalid\s+torrent|does\s+not\s+exist/i.test(html);
546
+ }
547
+ function parseSearchItems(html, limit, baseUrl) {
548
+ const items = [];
549
+ const rows = extractTopLevelRows(extractTableByClass(html, "torrents") ?? html);
550
+ for (const rowHtml of rows) {
551
+ if (rowHtml.length === 0) continue;
552
+ if (!/details\.php\?[^"']*id=\d+/i.test(rowHtml)) continue;
553
+ const cells = extractTopLevelCells(rowHtml);
554
+ const detailCellIndex = cells.findIndex((cell) => /details\.php\?[^"']*id=\d+/i.test(cell));
555
+ const detailCell = detailCellIndex >= 0 ? cells[detailCellIndex] : rowHtml;
556
+ const detailAnchor = /<a\b([^>]*)href\s*=\s*["']([^"']*details\.php\?[^"']*id=(\d+)[^"']*)["'][^>]*>([\s\S]*?)<\/a\s*>/i.exec(detailCell) ?? /<a\b([^>]*)href\s*=\s*["']([^"']*details\.php\?[^"']*id=(\d+)[^"']*)["'][^>]*>([\s\S]*?)<\/a\s*>/i.exec(rowHtml);
557
+ if (detailAnchor === null || detailAnchor[3] === void 0) continue;
558
+ const id = detailAnchor[3];
559
+ const title = normalizeText(parseTagAttributes(detailAnchor[1] ?? "").title ?? detailAnchor[4] ?? "");
560
+ if (title.length === 0) continue;
561
+ const categoryCell = detailCellIndex > 0 ? cells[detailCellIndex - 1] : rowHtml;
562
+ const usesLegacyColumnOrder = detailCellIndex >= 0 && findSize([cells[detailCellIndex + 1] ?? ""]) !== void 0;
563
+ const commentsCell = detailCellIndex >= 0 ? cells[detailCellIndex + (usesLegacyColumnOrder ? 4 : 1)] : void 0;
564
+ const timeCell = detailCellIndex >= 0 ? cells[detailCellIndex + (usesLegacyColumnOrder ? 7 : 2)] : void 0;
565
+ const sizeCell = detailCellIndex >= 0 ? cells[detailCellIndex + (usesLegacyColumnOrder ? 1 : 3)] : void 0;
566
+ const seedersCell = detailCellIndex >= 0 ? cells[detailCellIndex + (usesLegacyColumnOrder ? 2 : 4)] : void 0;
567
+ const leechersCell = detailCellIndex >= 0 ? cells[detailCellIndex + (usesLegacyColumnOrder ? 3 : 5)] : void 0;
568
+ const completedCell = detailCellIndex >= 0 ? cells[detailCellIndex + (usesLegacyColumnOrder ? 5 : 6)] : void 0;
569
+ const authorCell = detailCellIndex >= 0 ? cells[detailCellIndex + (usesLegacyColumnOrder ? 6 : 7)] : void 0;
570
+ const subTitle = inferSubTitle(detailCell, title);
571
+ const fallbackCells = extractTableCells(rowHtml);
572
+ const { seeders: inferredSeeders, leechers: inferredLeechers } = inferPeers(rowHtml, fallbackCells);
573
+ const size = findSize([sizeCell ?? "", ...fallbackCells]) ?? "unknown";
574
+ const sizeBytes = parseSizeToBytes(size);
575
+ const url = resolveUrl$1(baseUrl, `/details.php?id=${id}`).toString();
576
+ const link = normalizeDetailsToDownloadUrl(extractDownloadUrl(rowHtml, id, baseUrl) ?? resolveUrl$1(baseUrl, `/download.php?id=${id}`).toString());
577
+ const category = inferCategory(categoryCell) ?? inferCategory(rowHtml);
578
+ const status = inferStatus(rowHtml);
579
+ const progress = inferProgress(rowHtml);
580
+ const seeders = parseCellInteger(seedersCell) ?? inferredSeeders;
581
+ const leechers = parseCellInteger(leechersCell) ?? inferredLeechers;
582
+ const completed = parseCellInteger(completedCell) ?? extractPeerCountByPattern(rowHtml, /snatch|completed|完成/i) ?? void 0;
583
+ const comments = parseCellInteger(commentsCell) ?? extractPeerCountByPattern(rowHtml, /comment|评论/i) ?? void 0;
584
+ const author = inferAuthor(authorCell ?? rowHtml) ?? normalizeCellText(authorCell);
585
+ const timeRaw = inferTime(timeCell ?? rowHtml) ?? normalizeCellText(timeCell);
586
+ const time = timeRaw ? normalizeDateValue(timeRaw) : void 0;
587
+ const extImdb = inferExternalId(detailCell, /imdb/i) ?? inferExternalId(rowHtml, /imdb/i);
588
+ const extDouban = inferExternalId(detailCell, /douban/i) ?? inferExternalId(rowHtml, /douban/i);
589
+ items.push({
590
+ id,
591
+ title,
592
+ size,
593
+ seeders,
594
+ leechers,
595
+ tags: extractTags(title, rowHtml),
596
+ subTitle: subTitle.length > 0 ? subTitle : void 0,
597
+ url,
598
+ link,
599
+ category: category ?? void 0,
600
+ status,
601
+ progress,
602
+ completed,
603
+ comments,
604
+ author,
605
+ time: time && time.length > 0 ? time : void 0,
606
+ extImdb,
607
+ extDouban,
608
+ sizeBytes
609
+ });
610
+ if (items.length >= limit) break;
611
+ }
612
+ return items;
613
+ }
614
+ function extractTableByClass(html, className) {
615
+ const escapedClassName = className.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
616
+ const match = new RegExp(`<table\\b[^>]*class\\s*=\\s*["'][^"']*\\b${escapedClassName}\\b[^"']*["'][^>]*>`, "i").exec(html);
617
+ if (match === null || match.index === void 0) return;
618
+ const endIndex = findMatchingTagEnd(html, match.index, "table");
619
+ if (endIndex === void 0) return;
620
+ return html.slice(match.index, endIndex);
621
+ }
622
+ function findMatchingTagEnd(html, startIndex, tagName) {
623
+ const tagPattern = new RegExp(`<\\/?${tagName}\\b[^>]*>`, "gi");
624
+ let depth = 0;
625
+ let match;
626
+ while ((match = tagPattern.exec(html)) !== null) {
627
+ const tag = match[0];
628
+ const index = match.index;
629
+ if (index < startIndex) continue;
630
+ if (tag.startsWith("</")) {
631
+ depth -= 1;
632
+ if (depth === 0) return index + tag.length;
633
+ continue;
634
+ }
635
+ depth += 1;
636
+ }
637
+ }
638
+ function extractTopLevelRows(tableHtml) {
639
+ return extractTopLevelTagBlocks(tableHtml, "tr");
640
+ }
641
+ function extractTopLevelCells(rowHtml) {
642
+ return extractTopLevelTagBlocks(rowHtml, "td").map((cell) => extractInnerTagContent(cell, "td"));
643
+ }
644
+ function extractTopLevelTagBlocks(html, tagName) {
645
+ const blocks = [];
646
+ const tagPattern = new RegExp(`<\\/?${tagName}\\b[^>]*>`, "gi");
647
+ let depth = 0;
648
+ let blockStart = -1;
649
+ for (const match of html.matchAll(tagPattern)) {
650
+ const tag = match[0];
651
+ const index = match.index ?? -1;
652
+ if (index < 0) continue;
653
+ if (tag.startsWith("</")) {
654
+ if (depth <= 0) continue;
655
+ depth -= 1;
656
+ if (depth === 0 && blockStart >= 0) {
657
+ blocks.push(html.slice(blockStart, index + tag.length));
658
+ blockStart = -1;
659
+ }
660
+ continue;
661
+ }
662
+ if (depth === 0) blockStart = index;
663
+ depth += 1;
664
+ }
665
+ return blocks;
666
+ }
667
+ function extractInnerTagContent(block, tagName) {
668
+ const lower = block.toLowerCase();
669
+ const closeTag = `</${tagName.toLowerCase()}>`;
670
+ const openEnd = block.indexOf(">");
671
+ const closeStart = lower.lastIndexOf(closeTag);
672
+ if (openEnd < 0 || closeStart < 0 || closeStart <= openEnd) return block;
673
+ return block.slice(openEnd + 1, closeStart);
674
+ }
675
+ function parseTorrentDetail(html, id, baseUrl) {
676
+ if (looksLikeNotFoundPage(html)) throw new CliAppError({
677
+ code: "E_NOT_FOUND_RESOURCE",
678
+ message: `Torrent not found: ${id}`,
679
+ details: { id }
680
+ });
681
+ const title = extractDetailTitle(html);
682
+ if (title.length === 0) throw new CliAppError({
683
+ code: "E_UPSTREAM_BAD_RESPONSE",
684
+ message: "Unable to parse torrent title from BYR detail page",
685
+ details: { id }
686
+ });
687
+ const detailScope = extractTableByClass(html, "rowtable") ?? html;
688
+ const labelValues = parseLabelValuePairs(detailScope);
689
+ const cells = extractTableCells(detailScope);
690
+ const basicInfo = pickLabelValue(labelValues, ["基本信息", "basic info"]) ?? "";
691
+ const peerCounts = parsePeerSummary(pickLabelValue(labelValues, [
692
+ "同伴",
693
+ "peers",
694
+ "peer"
695
+ ]) ?? detailScope);
696
+ const size = pickLabelValue(labelValues, [
697
+ "大小",
698
+ "体积",
699
+ "size"
700
+ ]) ?? findSize([basicInfo, ...cells]) ?? "unknown";
701
+ const seeders = peerCounts?.seeders ?? pickIntegerLabelValue(labelValues, [
702
+ "做种",
703
+ "seed",
704
+ "seeders",
705
+ "当前种子"
706
+ ]) ?? extractPeerCountByPattern(detailScope, /seed(?:ers?)?/i) ?? 0;
707
+ const leechers = peerCounts?.leechers ?? pickIntegerLabelValue(labelValues, [
708
+ "下载数",
709
+ "下载者",
710
+ "leech",
711
+ "leechers",
712
+ "吸血"
713
+ ]) ?? extractPeerCountByPattern(detailScope, /leech(?:ers?)?|下载/i) ?? 0;
714
+ const category = pickLabelValue(labelValues, [
715
+ "分类",
716
+ "类型",
717
+ "category",
718
+ "type"
719
+ ]) ?? extractCategoryFromTypeSpan(detailScope) ?? extractCategoryFromBasicInfo(basicInfo) ?? "Unknown";
720
+ const uploadedAt = normalizeDateValue(pickLabelValue(labelValues, [
721
+ "发布时间",
722
+ "发布于",
723
+ "添加时间",
724
+ "上传时间",
725
+ "uploaded",
726
+ "created"
727
+ ]) ?? extractPublishedTime(detailScope) ?? "");
728
+ const sourceUrl = resolveUrl$1(baseUrl, `/details.php?id=${encodeURIComponent(id)}`).toString();
729
+ const link = normalizeDetailsToDownloadUrl(extractDownloadUrl(detailScope, id, baseUrl) ?? extractDownloadUrl(html, id, baseUrl) ?? resolveUrl$1(baseUrl, `/download.php?id=${id}`).toString());
730
+ const subTitle = pickLabelValue(labelValues, ["副标题", "subtitle"]) ?? inferSubTitle(detailScope, title);
731
+ const extImdb = inferExternalId(detailScope, /imdb/i) ?? inferExternalId(html, /imdb/i);
732
+ const extDouban = inferExternalId(detailScope, /douban/i) ?? inferExternalId(html, /douban/i);
733
+ const sizeBytes = parseSizeToBytes(size);
734
+ const completed = pickIntegerLabelValue(labelValues, [
735
+ "完成",
736
+ "snatch",
737
+ "completed"
738
+ ]) ?? extractPeerCountByPattern(detailScope, /completed|snatch|完成/i) ?? void 0;
739
+ const comments = pickIntegerLabelValue(labelValues, ["评论", "comment"]) ?? extractPeerCountByPattern(detailScope, /comment|评论/i) ?? void 0;
740
+ const author = inferAuthor(detailScope) ?? inferAuthor(html);
741
+ return {
742
+ id,
743
+ title,
744
+ size,
745
+ seeders,
746
+ leechers,
747
+ tags: extractTags(title, detailScope),
748
+ uploadedAt,
749
+ category,
750
+ subTitle: subTitle.length > 0 ? subTitle : void 0,
751
+ url: sourceUrl,
752
+ link,
753
+ status: inferStatus(detailScope),
754
+ progress: inferProgress(detailScope),
755
+ completed,
756
+ comments,
757
+ author,
758
+ time: uploadedAt,
759
+ extImdb,
760
+ extDouban,
761
+ sizeBytes
762
+ };
763
+ }
764
+ function normalizeDetailsToDownloadUrl(url) {
765
+ if (url.includes("details.php")) return url.replace(/details\.php\?id=(\d+)/i, "download.php?id=$1").replace(/([?&])hit=1(&?)/i, (_, first, second) => second ? first : "").replace(/[?&]$/, "");
766
+ return url;
767
+ }
768
+ function extractDownloadUrl(html, id, baseUrl) {
769
+ const candidates = Array.from(html.matchAll(/href\s*=\s*["']([^"']*(?:download|details)[^"']*)["']/gi)).map((match) => match[1] ?? "");
770
+ for (const candidate of candidates) if (candidate.includes(`id=${id}`)) return normalizeDetailsToDownloadUrl(resolveUrl$1(baseUrl, decodeHtmlEntities(candidate)).toString());
771
+ if (candidates.length > 0) return normalizeDetailsToDownloadUrl(resolveUrl$1(baseUrl, decodeHtmlEntities(candidates[0])).toString());
772
+ }
773
+ function parseUserIdFromIndex(html) {
774
+ const explicit = /href\s*=\s*["'][^"']*userdetails\.php\?[^"']*id=(\d+)[^"']*["']/i.exec(html);
775
+ if (explicit !== null) return explicit[1];
776
+ }
777
+ function parseUserInfoFromDetails(html, fallbackId) {
778
+ const labelValues = parseLabelValuePairs(html);
779
+ const transferText = pickLabelValue(labelValues, [
780
+ "传输",
781
+ "傳送",
782
+ "transfers",
783
+ "分享率"
784
+ ]) ?? normalizeText(html);
785
+ const uploadedBytes = parseTransferValue(transferText, /(上[传傳]量|uploaded).+?([\d.]+ ?[ZEPTGMK]?i?B)/i);
786
+ const trueUploadedBytes = parseTransferValue(transferText, /((?:实际|真实)上传量|(?:實際|真實)上傳量|(?:real|actual) uploaded).+?([\d.]+ ?[ZEPTGMK]?i?B)/i);
787
+ const downloadedBytes = parseTransferValue(transferText, /(下[载載]量|downloaded).+?([\d.]+ ?[ZEPTGMK]?i?B)/i);
788
+ const trueDownloadedBytes = parseTransferValue(transferText, /((?:实际|真实)下载量|(?:實際|真實)下載量|(?:real|actual) downloaded).+?([\d.]+ ?[ZEPTGMK]?i?B)/i);
789
+ const id = parseUserIdFromIndex(html) ?? fallbackId;
790
+ const name = pickLabelValue(labelValues, [
791
+ "用户名",
792
+ "username",
793
+ "用户",
794
+ "user"
795
+ ]) ?? inferUserName(html) ?? "";
796
+ const messageCount = firstInteger(pickLabelValue(labelValues, ["消息", "messages"]) ?? extractMessageCountSnippet(html)) ?? 0;
797
+ const levelName = pickLabelValue(labelValues, [
798
+ "等级",
799
+ "等級",
800
+ "class"
801
+ ]) ?? inferLevelName(html) ?? "";
802
+ const bonus = extractNumber(pickLabelValue(labelValues, [
803
+ "魔力",
804
+ "积分",
805
+ "麦粒",
806
+ "星焱",
807
+ "karma"
808
+ ]) ?? "");
809
+ const seedingBonus = extractNumber(pickLabelValue(labelValues, [
810
+ "做种积分",
811
+ "做種積分",
812
+ "seeding points",
813
+ "保种积分"
814
+ ]) ?? "");
815
+ const joinTime = normalizeDateValue(pickLabelValue(labelValues, [
816
+ "加入日期",
817
+ "joindate",
818
+ "join date"
819
+ ]) ?? "");
820
+ const lastAccessAt = normalizeDateValue(pickLabelValue(labelValues, [
821
+ "最近动向",
822
+ "最近動向",
823
+ "last action"
824
+ ]) ?? "");
825
+ const hnrText = normalizeText(extractHnrSnippet(html));
826
+ const hnrPreWarning = firstInteger(hnrText) ?? 0;
827
+ const hnrUnsatisfied = /(\d+)\s*\/\s*(\d+)/.exec(hnrText) ? Number.parseInt(/(\d+)\s*\/\s*(\d+)/.exec(hnrText)[2], 10) : 0;
828
+ const trueUploaded = trueUploadedBytes > 0 ? trueUploadedBytes : uploadedBytes;
829
+ const trueDownloaded = trueDownloadedBytes > 0 ? trueDownloadedBytes : downloadedBytes;
830
+ return {
831
+ id,
832
+ name,
833
+ messageCount,
834
+ uploadedBytes,
835
+ downloadedBytes,
836
+ trueUploadedBytes,
837
+ trueDownloadedBytes,
838
+ ratio: trueDownloaded > 0 ? Number((trueUploaded / trueDownloaded).toFixed(3)) : 0,
839
+ levelName,
840
+ bonus,
841
+ seedingBonus,
842
+ hnrPreWarning,
843
+ hnrUnsatisfied,
844
+ joinTime,
845
+ lastAccessAt
846
+ };
847
+ }
848
+ function parseBonusPerHour(html) {
849
+ for (const pattern of [
850
+ /每小时能获取[^0-9]*([\d.]+)/i,
851
+ /you are currently getting[^0-9]*([\d.]+)/i,
852
+ /做种积分.*?([\d.]+)\s*\/\s*h/i,
853
+ /bonus[^0-9]*per hour[^0-9]*([\d.]+)/i
854
+ ]) {
855
+ const match = pattern.exec(normalizeText(html));
856
+ if (match !== null) return Number.parseFloat(match[1]);
857
+ }
858
+ return 0;
859
+ }
860
+ function parseSeedingStatus(html) {
861
+ const normalized = normalizeText(html);
862
+ const quick = /(\d+)\s*\|\s*([\d.]+\s*[ZEPTGMK]?i?B)/i.exec(normalized);
863
+ if (quick !== null) return {
864
+ seeding: Number.parseInt(quick[1], 10),
865
+ seedingSizeBytes: parseSizeToBytes(quick[2]) ?? 0
866
+ };
867
+ const rows = Array.from(html.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi));
868
+ if (rows.length <= 1) return {
869
+ seeding: 0,
870
+ seedingSizeBytes: 0
871
+ };
872
+ let seeding = 0;
873
+ let sizeSum = 0;
874
+ for (const row of rows.slice(1)) {
875
+ const size = findSize(extractTableCells(row[0] ?? ""));
876
+ if (size) {
877
+ seeding += 1;
878
+ sizeSum += parseSizeToBytes(size) ?? 0;
879
+ }
880
+ }
881
+ return {
882
+ seeding,
883
+ seedingSizeBytes: sizeSum
884
+ };
885
+ }
886
+ function parseUploads(html) {
887
+ const keyword = /<b>(\d+)<\/b>(条记录| records|條記錄)/i.exec(html);
888
+ if (keyword !== null) return Number.parseInt(keyword[1], 10);
889
+ const rows = Array.from(html.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi));
890
+ return rows.length > 1 ? rows.length - 1 : 0;
891
+ }
892
+ function parseTransferValue(source, pattern) {
893
+ const match = source.replace(/,/g, "").match(pattern);
894
+ if (match === null) return 0;
895
+ return parseSizeToBytes(match[2]) ?? 0;
896
+ }
897
+ function extractMessageCountSnippet(html) {
898
+ return /messages\.php[^>]*>([\s\S]{0,80})<\/a>/i.exec(html)?.[1] ?? "";
899
+ }
900
+ function extractHnrSnippet(html) {
901
+ return /myhr\.php[^>]*>([\s\S]{0,100})<\/a>/i.exec(html)?.[1] ?? "";
902
+ }
903
+ function inferUserName(html) {
904
+ const match = /userdetails\.php\?[^"']*id=\d+[^"']*["'][^>]*>([^<]+)</i.exec(html);
905
+ if (match === null) return;
906
+ const text = normalizeText(match[1]);
907
+ return text.length > 0 ? text : void 0;
908
+ }
909
+ function inferLevelName(html) {
910
+ const imageTitle = /(?:等级|等級|class)[\s\S]{0,200}?<img[^>]*title\s*=\s*["']([^"']+)["']/i.exec(html);
911
+ if (imageTitle !== null) return normalizeText(imageTitle[1]);
912
+ const fallback = /(?:等级|等級|class)[\s\S]{0,100}?<\/td>\s*<td[^>]*>([\s\S]*?)<\/td>/i.exec(html);
913
+ if (fallback !== null) {
914
+ const text = normalizeText(fallback[1]);
915
+ return text.length > 0 ? text : void 0;
916
+ }
917
+ }
918
+ function inferSubTitle(rowHtml, title) {
919
+ let scope = rowHtml;
920
+ const embeddedCell = /<td\b[^>]*class\s*=\s*["'][^"']*\bembedded\b[^"']*["'][^>]*>([\s\S]*?)<\/td>/i.exec(rowHtml);
921
+ if (embeddedCell !== null) scope = embeddedCell[1] ?? scope;
922
+ const split = scope.split(/<br\s*\/?>/i);
923
+ if (split.length < 2) return "";
924
+ for (const part of split.slice(1)) {
925
+ const candidate = normalizeText(part);
926
+ if (candidate.length === 0 || candidate === title) continue;
927
+ return candidate;
928
+ }
929
+ return "";
930
+ }
931
+ function inferCategory(html) {
932
+ const catLink = /class\s*=\s*["'][^"']*\bcat-link\b[^"']*["'][^>]*>([\s\S]*?)<\/a\s*>/i.exec(html);
933
+ if (catLink !== null) {
934
+ const text = normalizeText(catLink[1]);
935
+ if (text.length > 0) return text;
936
+ }
937
+ const iconTitle = /cat-icon[^>]*(?:title|alt)\s*=\s*["']([^"']+)["']/i.exec(html);
938
+ if (iconTitle !== null) {
939
+ const text = normalizeText(iconTitle[1]);
940
+ if (text.length > 0) return text;
941
+ }
942
+ const imageTitle = /<img[^>]*(?:title|alt)\s*=\s*["']([^"']+)["'][^>]*>/i.exec(html);
943
+ if (imageTitle !== null) {
944
+ const text = normalizeText(imageTitle[1]);
945
+ if (!/download|seed|leech|comment|size|time|流量正常计算|下载本种|收藏/i.test(text) && text.length > 0) return text;
946
+ }
947
+ }
948
+ function inferStatus(html) {
949
+ if (/finished\.png|completed/i.test(html)) return "completed";
950
+ if (/seeding/i.test(html)) return "seeding";
951
+ if (/leeching|downloading/i.test(html)) return "downloading";
952
+ if (/inactive/i.test(html)) return "inactive";
953
+ return "unknown";
954
+ }
955
+ function inferProgress(html) {
956
+ const titleMatch = /\btitle\s*=\s*["'][^"']*?\s(\d+(?:\.\d+)?)%["']/i.exec(html);
957
+ if (titleMatch !== null) return Number.parseFloat(titleMatch[1]);
958
+ return null;
959
+ }
960
+ function inferAuthor(html) {
961
+ const author = /sort=9[^>]*>([\s\S]*?)<\/a\s*>/i.exec(html);
962
+ if (author !== null) {
963
+ const text = normalizeText(author[1]);
964
+ return text.length > 0 ? text : void 0;
965
+ }
966
+ const generic = /userdetails\.php\?[^"']*id=\d+[^"']*["'][^>]*>([\s\S]*?)<\/a\s*>/i.exec(html);
967
+ if (generic !== null) {
968
+ const text = normalizeText(generic[1]);
969
+ return text.length > 0 ? text : void 0;
970
+ }
971
+ }
972
+ function inferTime(html) {
973
+ const withTitle = /<(?:span|time)\b[^>]*\btitle\s*=\s*["']([^"']+)["'][^>]*>/i.exec(html);
974
+ if (withTitle !== null) return withTitle[1];
975
+ const text = /icons\.time[\s\S]{0,120}?>\s*([^<]+)</i.exec(html);
976
+ if (text !== null) return text[1];
977
+ }
978
+ function inferExternalId(html, pattern) {
979
+ for (const match of html.matchAll(/<span[^>]*data-(doubanid|imdbid)\s*=\s*["']([^"']+)["']/gi)) if (pattern.test(match[1])) return match[2];
980
+ if (pattern.test("imdb")) {
981
+ const imdbPlugin = /imdbRatingPlugin[^>]*data-title\s*=\s*["']([^"']+)["']/i.exec(html);
982
+ if (imdbPlugin !== null) return imdbPlugin[1];
983
+ }
984
+ for (const match of html.matchAll(/<a[^>]*href\s*=\s*["']([^"']+)["'][^>]*>/gi)) if (pattern.test(match[1])) return match[1];
985
+ return null;
986
+ }
987
+ function parsePeerSummary(value) {
988
+ const match = /(\d+)\s*个做种者[\s\S]*?(\d+)\s*个下载者/i.exec(value);
989
+ if (match === null) return;
990
+ return {
991
+ seeders: Number.parseInt(match[1], 10),
992
+ leechers: Number.parseInt(match[2], 10)
993
+ };
994
+ }
995
+ function extractCategoryFromTypeSpan(html) {
996
+ const match = /id\s*=\s*["']type["'][^>]*>([\s\S]*?)<\/span>/i.exec(html);
997
+ if (match === null) return;
998
+ const category = normalizeText(match[1]);
999
+ return category.length > 0 ? category : void 0;
1000
+ }
1001
+ function extractCategoryFromBasicInfo(value) {
1002
+ const match = /(?:类型|type)\s*[::]\s*([^|]+)/i.exec(value);
1003
+ if (match === null) return;
1004
+ const category = normalizeText(match[1]);
1005
+ return category.length > 0 ? category : void 0;
1006
+ }
1007
+ function extractPublishedTime(value) {
1008
+ const match = /发布于\s*([0-9]{4}[年/-][0-9]{1,2}[月/-][0-9]{1,2}\s+[0-9]{1,2}:[0-9]{2}(?::[0-9]{2})?)/i.exec(value);
1009
+ if (match === null) return;
1010
+ return normalizeText(match[1]);
1011
+ }
1012
+ function parseLabelValuePairs(html) {
1013
+ const pairs = /* @__PURE__ */ new Map();
1014
+ const rows = Array.from(html.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi));
1015
+ for (const rowMatch of rows) {
1016
+ const cells = extractTableCells(rowMatch[0] ?? "");
1017
+ if (cells.length < 2) continue;
1018
+ for (let index = 0; index + 1 < cells.length; index += 2) {
1019
+ const label = normalizeLabel(cells[index]);
1020
+ const value = normalizeText(cells[index + 1]);
1021
+ if (label.length === 0 || value.length === 0) continue;
1022
+ if (!pairs.has(label)) pairs.set(label, value);
1023
+ }
1024
+ }
1025
+ return pairs;
1026
+ }
1027
+ function pickLabelValue(labelValues, candidates) {
1028
+ const normalizedCandidates = candidates.map((candidate) => normalizeLabel(candidate));
1029
+ for (const [label, value] of labelValues.entries()) for (const candidate of normalizedCandidates) if (label.includes(candidate)) return value;
1030
+ }
1031
+ function pickIntegerLabelValue(labelValues, candidates) {
1032
+ const value = pickLabelValue(labelValues, candidates);
1033
+ if (value === void 0) return;
1034
+ return firstInteger(value) ?? void 0;
1035
+ }
1036
+ function extractTableCells(html) {
1037
+ const cells = [];
1038
+ for (const cellMatch of html.matchAll(/<t[dh]\b[^>]*>([\s\S]*?)<\/t[dh]>/gi)) cells.push(cellMatch[1] ?? "");
1039
+ return cells;
1040
+ }
1041
+ function inferPeers(rowHtml, cells) {
1042
+ return {
1043
+ seeders: extractPeerCountByPattern(rowHtml, /seed(?:ers?)?|做种/i) ?? inferPeerFromCells(cells, -2) ?? 0,
1044
+ leechers: extractPeerCountByPattern(rowHtml, /leech(?:ers?)?|下载|吸血/i) ?? inferPeerFromCells(cells, -1) ?? 0
1045
+ };
1046
+ }
1047
+ function parseCellInteger(cell) {
1048
+ if (cell === void 0) return;
1049
+ const value = firstInteger(cell);
1050
+ return value === null ? void 0 : value;
1051
+ }
1052
+ function normalizeCellText(cell) {
1053
+ if (cell === void 0) return;
1054
+ const text = normalizeText(cell);
1055
+ return text.length > 0 ? text : void 0;
1056
+ }
1057
+ function extractPeerCountByPattern(html, keywordPattern) {
1058
+ const links = Array.from(html.matchAll(/<a\b[^>]*href\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/a\s*>/gi));
1059
+ for (const link of links) {
1060
+ const href = link[1] ?? "";
1061
+ if (!keywordPattern.test(href)) continue;
1062
+ const count = firstInteger(link[2] ?? "");
1063
+ if (count !== null) return count;
1064
+ }
1065
+ }
1066
+ function inferPeerFromCells(cells, positionFromEnd) {
1067
+ const numericCells = cells.map((cell) => normalizeText(cell)).filter((cell) => INTEGER_PATTERN.test(cell)).map((cell) => Number.parseInt(cell, 10));
1068
+ if (numericCells.length === 0) return;
1069
+ const index = numericCells.length + positionFromEnd;
1070
+ if (index < 0 || index >= numericCells.length) return;
1071
+ return numericCells[index];
1072
+ }
1073
+ function findSize(cells) {
1074
+ for (const cell of cells) {
1075
+ const text = normalizeText(cell);
1076
+ const match = SIZE_PATTERN.exec(text);
1077
+ if (match !== null) return match[0];
1078
+ }
1079
+ }
1080
+ function extractDetailTitle(html) {
1081
+ const headingMatch = /<(?:h1|h2)\b[^>]*>([\s\S]*?)<\/(?:h1|h2)>/i.exec(html);
1082
+ if (headingMatch !== null) {
1083
+ const heading = normalizeText(headingMatch[1] ?? "");
1084
+ if (heading.length > 0) return heading;
1085
+ }
1086
+ const titleTagMatch = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html);
1087
+ if (titleTagMatch !== null) {
1088
+ const titleTagText = normalizeText(titleTagMatch[1] ?? "");
1089
+ const quotedTitle = /["“”]([^"“”]+)["“”]/.exec(titleTagText);
1090
+ if (quotedTitle !== null) return normalizeText(quotedTitle[1] ?? "");
1091
+ return titleTagText.replace(/^BYRBT\s*::\s*/i, "").replace(/-\s*Powered\s+by\s+NexusPHP$/i, "").trim();
1092
+ }
1093
+ return "";
1094
+ }
1095
+ function normalizeDateValue(value) {
1096
+ const normalized = value.trim();
1097
+ if (normalized.length === 0) return "";
1098
+ const parsedDirect = new Date(normalized);
1099
+ if (Number.isFinite(parsedDirect.getTime())) return parsedDirect.toISOString();
1100
+ const match = /(?<year>\d{4})[年/-](?<month>\d{1,2})[月/-](?<day>\d{1,2})\D+(?<hour>\d{1,2}):(?<minute>\d{1,2})(?::(?<second>\d{1,2}))?/.exec(normalized);
1101
+ if (match?.groups === void 0) return normalized;
1102
+ const year = Number.parseInt(match.groups.year, 10);
1103
+ const month = Number.parseInt(match.groups.month, 10) - 1;
1104
+ const day = Number.parseInt(match.groups.day, 10);
1105
+ const hour = Number.parseInt(match.groups.hour, 10);
1106
+ const minute = Number.parseInt(match.groups.minute, 10);
1107
+ const second = Number.parseInt(match.groups.second ?? "0", 10);
1108
+ const parsedCustom = new Date(Date.UTC(year, month, day, hour, minute, second));
1109
+ if (!Number.isFinite(parsedCustom.getTime())) return normalized;
1110
+ return parsedCustom.toISOString();
1111
+ }
1112
+ function parseSizeToBytes(value) {
1113
+ const match = /^\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB|PB|KIB|MIB|GIB|TIB)\s*$/i.exec(value);
1114
+ if (match === null) return;
1115
+ const size = Number.parseFloat(match[1]);
1116
+ const unit = match[2].toUpperCase();
1117
+ if (!Number.isFinite(size)) return;
1118
+ const base = unit.endsWith("IB") ? 1024 : 1e3;
1119
+ const power = {
1120
+ B: 0,
1121
+ KB: 1,
1122
+ MB: 2,
1123
+ GB: 3,
1124
+ TB: 4,
1125
+ PB: 5,
1126
+ KIB: 1,
1127
+ MIB: 2,
1128
+ GIB: 3,
1129
+ TIB: 4
1130
+ }[unit];
1131
+ if (power === void 0) return;
1132
+ return Math.round(size * base ** power);
1133
+ }
1134
+ function resolveUrl$1(baseUrl, pathOrUrl) {
1135
+ if (/^https?:\/\//i.test(pathOrUrl)) return new URL(pathOrUrl);
1136
+ return new URL(pathOrUrl, baseUrl);
1137
+ }
1138
+ function normalizeText(html) {
1139
+ return decodeHtmlEntities(stripHtmlTags(html)).replace(/\s+/g, " ").trim();
1140
+ }
1141
+ function normalizeLabel(value) {
1142
+ return normalizeText(value).toLowerCase().replace(/[::]/g, "").replace(/\s+/g, "");
1143
+ }
1144
+ function stripHtmlTags(value) {
1145
+ return value.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ").replace(/<[^>]+>/g, " ");
1146
+ }
1147
+ function decodeHtmlEntities(value) {
1148
+ return value.replace(/&nbsp;/gi, " ").replace(/&amp;/gi, "&").replace(/&quot;/gi, "\"").replace(/&#39;/gi, "'").replace(/&lt;/gi, "<").replace(/&gt;/gi, ">").replace(/&#x([\da-fA-F]+);/g, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16))).replace(/&#(\d+);/g, (_, num) => String.fromCodePoint(Number.parseInt(num, 10)));
1149
+ }
1150
+ function firstInteger(value) {
1151
+ const match = /\b(\d+)\b/.exec(normalizeText(value));
1152
+ if (match === null) return null;
1153
+ return Number.parseInt(match[1], 10);
1154
+ }
1155
+ function extractNumber(value) {
1156
+ const normalized = value.replace(/,/g, "");
1157
+ const match = /[\d.]+/.exec(normalized);
1158
+ if (match === null) return 0;
1159
+ return Number.parseFloat(match[0]);
1160
+ }
1161
+ function extractTags(title, html) {
1162
+ const tags = /* @__PURE__ */ new Set();
1163
+ for (const match of title.matchAll(/\[([^\]]+)\]/g)) {
1164
+ const tag = match[1]?.trim();
1165
+ if (tag !== void 0 && tag.length > 0) tags.add(tag);
1166
+ }
1167
+ for (const [pattern, label] of [
1168
+ [/pro_free|免费/i, "Free"],
1169
+ [/pro_free2up|2xfree/i, "2xFree"],
1170
+ [/pro_2up/i, "2xUp"],
1171
+ [/pro_50pctdown2up|2x50/i, "2x50%"],
1172
+ [/pro_30pctdown|30%/i, "30%"],
1173
+ [/pro_50pctdown|50%/i, "50%"],
1174
+ [/hitandrun|h&r/i, "H&R"]
1175
+ ]) if (pattern.test(html)) tags.add(label);
1176
+ return Array.from(tags);
1177
+ }
1178
+
1179
+ //#endregion
1180
+ //#region src/domain/client.ts
1181
+ const DEFAULT_BASE_URL = "https://byr.pt";
1182
+ const DEFAULT_TIMEOUT_MS = 15e3;
1183
+ function createByrClientFromEnv(env = process.env) {
1184
+ return createByrClient({
1185
+ baseUrl: env.BYR_BASE_URL,
1186
+ timeoutMs: parseTimeoutMs(env.BYR_TIMEOUT_MS),
1187
+ cookie: env.BYR_COOKIE,
1188
+ username: env.BYR_USERNAME,
1189
+ password: env.BYR_PASSWORD
1190
+ });
1191
+ }
1192
+ function parseTimeoutMs(value) {
1193
+ if (value === void 0) return;
1194
+ const parsed = Number.parseInt(value, 10);
1195
+ if (!Number.isFinite(parsed) || parsed <= 0) return;
1196
+ return parsed;
1197
+ }
1198
+ function createByrClient(options = {}) {
1199
+ const baseUrl = normalizeBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL);
1200
+ const timeoutMs = sanitizeTimeout(options.timeoutMs);
1201
+ const username = options.username?.trim() ?? "";
1202
+ const password = options.password ?? "";
1203
+ const session = new HttpSession({
1204
+ baseUrl,
1205
+ timeoutMs,
1206
+ fetchImpl: options.fetchImpl,
1207
+ initialCookie: options.cookie,
1208
+ userAgent: "byr-pt-cli"
1209
+ });
1210
+ let authInitialized = session.cookieSize() > 0;
1211
+ async function ensureAuthenticated(forceRelogin = false) {
1212
+ if (forceRelogin) {
1213
+ session.clearCookies();
1214
+ authInitialized = false;
1215
+ }
1216
+ if (authInitialized) return;
1217
+ if (session.hasCookie("uid") && session.hasCookie("pass")) {
1218
+ authInitialized = true;
1219
+ return;
1220
+ }
1221
+ if (username.length === 0 || password.length === 0) throw buildAuthMissingError();
1222
+ await loginWithCredentials();
1223
+ authInitialized = true;
1224
+ }
1225
+ async function loginWithCredentials() {
1226
+ const form = parseLoginForm(await session.fetchText({
1227
+ pathOrUrl: "/login.php",
1228
+ method: "GET",
1229
+ includeAuthCookie: false,
1230
+ redirect: "follow"
1231
+ }, "Unable to load BYR login page"));
1232
+ if (form.requiresManualField.length > 0) throw new CliAppError({
1233
+ code: "E_AUTH_REQUIRED",
1234
+ message: "BYR login requires manual verification. Provide BYR_COOKIE instead.",
1235
+ details: { fields: form.requiresManualField }
1236
+ });
1237
+ const payload = new URLSearchParams(form.hiddenFields);
1238
+ payload.set(form.usernameField, username);
1239
+ payload.set(form.passwordField, password);
1240
+ await session.fetch({
1241
+ pathOrUrl: form.action,
1242
+ method: "POST",
1243
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1244
+ body: payload.toString(),
1245
+ includeAuthCookie: false,
1246
+ redirect: "manual"
1247
+ });
1248
+ if (looksLikeLoginPage(await session.fetchText({
1249
+ pathOrUrl: "/torrents.php",
1250
+ method: "GET",
1251
+ includeAuthCookie: true,
1252
+ redirect: "follow"
1253
+ }, "Unable to verify BYR login state"))) throw new CliAppError({
1254
+ code: "E_AUTH_INVALID",
1255
+ message: "BYR authentication failed. Check BYR_USERNAME/BYR_PASSWORD."
1256
+ });
1257
+ }
1258
+ async function fetchAuthenticatedHtml(pathOrUrl) {
1259
+ await ensureAuthenticated();
1260
+ const firstHtml = await session.fetchText({
1261
+ pathOrUrl,
1262
+ method: "GET",
1263
+ includeAuthCookie: true,
1264
+ redirect: "follow"
1265
+ }, `Unable to request ${pathOrUrl}`);
1266
+ if (!looksLikeLoginPage(firstHtml)) return firstHtml;
1267
+ if (username.length === 0 || password.length === 0) throw buildAuthExpiredError();
1268
+ await ensureAuthenticated(true);
1269
+ const secondHtml = await session.fetchText({
1270
+ pathOrUrl,
1271
+ method: "GET",
1272
+ includeAuthCookie: true,
1273
+ redirect: "follow"
1274
+ }, `Unable to request ${pathOrUrl}`);
1275
+ if (looksLikeLoginPage(secondHtml)) throw new CliAppError({
1276
+ code: "E_AUTH_INVALID",
1277
+ message: "BYR authentication failed after relogin."
1278
+ });
1279
+ return secondHtml;
1280
+ }
1281
+ return {
1282
+ async search(query, limit, options = {}) {
1283
+ const params = new URLSearchParams({ notnewword: "1" });
1284
+ if (options.imdb && options.imdb.trim().length > 0) {
1285
+ params.set("search", options.imdb.trim());
1286
+ params.set("search_area", "4");
1287
+ } else params.set("search", query);
1288
+ if (Array.isArray(options.categoryIds) && options.categoryIds.length > 0) for (const categoryId of options.categoryIds) params.set(`cat${categoryId}`, "1");
1289
+ if (options.incldead !== void 0) params.set("incldead", String(options.incldead));
1290
+ if (options.spstate !== void 0) params.set("spstate", String(options.spstate));
1291
+ if (options.bookmarked !== void 0) params.set("inclbookmarked", String(options.bookmarked));
1292
+ if (options.page !== void 0) params.set("page", String(options.page));
1293
+ return parseSearchItems(await fetchAuthenticatedHtml(`/torrents.php?${params.toString()}`), limit, baseUrl);
1294
+ },
1295
+ async getById(id) {
1296
+ return parseTorrentDetail(await fetchAuthenticatedHtml(`/details.php?id=${encodeURIComponent(id)}&hit=1`), id, baseUrl);
1297
+ },
1298
+ async getDownloadPlan(id) {
1299
+ const html = await fetchAuthenticatedHtml(`/details.php?id=${encodeURIComponent(id)}&hit=1`);
1300
+ const detail = parseTorrentDetail(html, id, baseUrl);
1301
+ const sourceUrl = extractDownloadUrl(html, id, baseUrl) ?? normalizeDetailsToDownloadUrl(resolveUrl(baseUrl, `/download.php?id=${encodeURIComponent(id)}`).toString());
1302
+ return {
1303
+ id: detail.id,
1304
+ fileName: deriveTorrentFileName(detail.id, detail.title),
1305
+ sourceUrl
1306
+ };
1307
+ },
1308
+ async downloadTorrent(id) {
1309
+ const plan = await this.getDownloadPlan(id);
1310
+ await ensureAuthenticated();
1311
+ const response = await session.fetch({
1312
+ pathOrUrl: plan.sourceUrl,
1313
+ method: "GET",
1314
+ includeAuthCookie: true,
1315
+ redirect: "follow"
1316
+ });
1317
+ if (!response.ok) throw mapNonOkResponse(response, "Failed to download torrent payload");
1318
+ if ((response.headers.get("content-type")?.toLowerCase() ?? "").includes("text/html")) {
1319
+ const html = await response.text();
1320
+ if (looksLikeLoginPage(html)) throw buildAuthExpiredError();
1321
+ if (looksLikeNotFoundPage(html)) throw new CliAppError({
1322
+ code: "E_NOT_FOUND_RESOURCE",
1323
+ message: `Torrent not found: ${id}`,
1324
+ details: { id }
1325
+ });
1326
+ throw new CliAppError({
1327
+ code: "E_UPSTREAM_BAD_RESPONSE",
1328
+ message: "BYR returned HTML instead of torrent content",
1329
+ details: { sourceUrl: plan.sourceUrl }
1330
+ });
1331
+ }
1332
+ const content = new Uint8Array(await response.arrayBuffer());
1333
+ if (content.byteLength === 0) throw new CliAppError({
1334
+ code: "E_UPSTREAM_BAD_RESPONSE",
1335
+ message: "BYR returned an empty torrent payload",
1336
+ details: { sourceUrl: plan.sourceUrl }
1337
+ });
1338
+ const fileName = extractFileNameFromContentDisposition(response.headers.get("content-disposition")) ?? plan.fileName;
1339
+ return {
1340
+ ...plan,
1341
+ fileName,
1342
+ content
1343
+ };
1344
+ },
1345
+ async getUserInfo() {
1346
+ const userId = parseUserIdFromIndex(await fetchAuthenticatedHtml("/index.php"));
1347
+ if (userId === void 0) throw new CliAppError({
1348
+ code: "E_UPSTREAM_BAD_RESPONSE",
1349
+ message: "Unable to parse BYR user id from index page"
1350
+ });
1351
+ const parsed = parseUserInfoFromDetails(await fetchAuthenticatedHtml(`/userdetails.php?id=${encodeURIComponent(userId)}`), userId);
1352
+ const bonusPerHour = parseBonusPerHour(await fetchAuthenticatedHtml("/mybonus.php?show=seed"));
1353
+ let seeding = 0;
1354
+ let seedingSizeBytes = 0;
1355
+ let uploads = 0;
1356
+ try {
1357
+ const seedingStatus = parseSeedingStatus(await fetchAuthenticatedHtml(`/getusertorrentlistajax.php?userid=${encodeURIComponent(userId)}&type=seeding`));
1358
+ seeding = seedingStatus.seeding;
1359
+ seedingSizeBytes = seedingStatus.seedingSizeBytes;
1360
+ } catch {}
1361
+ try {
1362
+ uploads = parseUploads(await fetchAuthenticatedHtml(`/getusertorrentlistajax.php?userid=${encodeURIComponent(userId)}&type=uploaded`));
1363
+ } catch {}
1364
+ const levelId = guessByrLevelId(parsed.levelName);
1365
+ const levelProgress = buildLevelProgress({
1366
+ levelId,
1367
+ levelName: parsed.levelName,
1368
+ trueUploadedBytes: parsed.trueUploadedBytes,
1369
+ joinTime: parsed.joinTime,
1370
+ ratio: parsed.ratio
1371
+ });
1372
+ return {
1373
+ ...parsed,
1374
+ levelId,
1375
+ bonusPerHour,
1376
+ seeding,
1377
+ seedingSizeBytes,
1378
+ uploads,
1379
+ levelProgress
1380
+ };
1381
+ },
1382
+ async getCategories() {
1383
+ return {
1384
+ category: BYR_CATEGORY_FACET,
1385
+ incldead: BYR_INCLDEAD_FACET,
1386
+ spstate: BYR_SPSTATE_FACET,
1387
+ bookmarked: BYR_BOOKMARKED_FACET
1388
+ };
1389
+ },
1390
+ async getLevelRequirements() {
1391
+ return BYR_LEVEL_REQUIREMENTS;
1392
+ },
1393
+ async verifyAuth() {
1394
+ try {
1395
+ return { authenticated: !looksLikeLoginPage(await fetchAuthenticatedHtml("/torrents.php")) };
1396
+ } catch (error) {
1397
+ if (error instanceof CliAppError && error.code.startsWith("E_AUTH_")) return { authenticated: false };
1398
+ throw error;
1399
+ }
1400
+ }
1401
+ };
1402
+ }
1403
+ function buildLevelProgress(input) {
1404
+ const currentLevelId = input.levelId;
1405
+ const nextLevel = BYR_LEVEL_REQUIREMENTS.find((level) => (currentLevelId === void 0 || level.id > currentLevelId) && (level.groupType ?? "user") === "user");
1406
+ if (nextLevel === void 0) return {
1407
+ currentLevelId,
1408
+ currentLevelName: input.levelName,
1409
+ met: true,
1410
+ unmet: []
1411
+ };
1412
+ const unmet = [];
1413
+ if (typeof nextLevel.uploaded === "string") {
1414
+ const requiredUploaded = parseSizeToBytes(nextLevel.uploaded) ?? 0;
1415
+ const met = input.trueUploadedBytes >= requiredUploaded;
1416
+ if (!met) unmet.push({
1417
+ field: "uploaded",
1418
+ required: nextLevel.uploaded,
1419
+ current: formatBytes(input.trueUploadedBytes),
1420
+ met
1421
+ });
1422
+ }
1423
+ if (typeof nextLevel.ratio === "number") {
1424
+ const met = input.ratio >= nextLevel.ratio;
1425
+ if (!met) unmet.push({
1426
+ field: "ratio",
1427
+ required: nextLevel.ratio,
1428
+ current: input.ratio,
1429
+ met
1430
+ });
1431
+ }
1432
+ if (typeof nextLevel.interval === "string") {
1433
+ const requiredMs = parseIsoDurationToMs(nextLevel.interval);
1434
+ const joinedMs = Date.parse(input.joinTime);
1435
+ if (requiredMs !== void 0 && Number.isFinite(joinedMs)) {
1436
+ const actualMs = Date.now() - joinedMs;
1437
+ const met = actualMs >= requiredMs;
1438
+ if (!met) unmet.push({
1439
+ field: "interval",
1440
+ required: nextLevel.interval,
1441
+ current: `${Math.floor(actualMs / 864e5)}d`,
1442
+ met
1443
+ });
1444
+ }
1445
+ }
1446
+ return {
1447
+ currentLevelId,
1448
+ currentLevelName: input.levelName,
1449
+ nextLevelId: nextLevel.id,
1450
+ nextLevelName: nextLevel.name,
1451
+ met: unmet.length === 0,
1452
+ unmet
1453
+ };
1454
+ }
1455
+ function parseIsoDurationToMs(value) {
1456
+ const match = /^P(?:(\d+)D)?(?:(\d+)W)?$/i.exec(value);
1457
+ if (match === null) return;
1458
+ return (Number.parseInt(match[1] ?? "0", 10) + Number.parseInt(match[2] ?? "0", 10) * 7) * 864e5;
1459
+ }
1460
+ function formatBytes(bytes) {
1461
+ if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
1462
+ const units = [
1463
+ "B",
1464
+ "KB",
1465
+ "MB",
1466
+ "GB",
1467
+ "TB"
1468
+ ];
1469
+ let value = bytes;
1470
+ let index = 0;
1471
+ while (value >= 1024 && index < units.length - 1) {
1472
+ value /= 1024;
1473
+ index += 1;
1474
+ }
1475
+ return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[index]}`;
1476
+ }
1477
+ function buildAuthMissingError() {
1478
+ return new CliAppError({
1479
+ code: "E_AUTH_REQUIRED",
1480
+ message: "Missing BYR credentials. Set BYR_COOKIE or BYR_USERNAME/BYR_PASSWORD.",
1481
+ details: { env: [
1482
+ "BYR_COOKIE",
1483
+ "BYR_USERNAME",
1484
+ "BYR_PASSWORD"
1485
+ ] }
1486
+ });
1487
+ }
1488
+ function buildAuthExpiredError() {
1489
+ return new CliAppError({
1490
+ code: "E_AUTH_REQUIRED",
1491
+ message: "BYR authentication is required or expired."
1492
+ });
1493
+ }
1494
+ function sanitizeTimeout(timeoutMs) {
1495
+ if (timeoutMs === void 0 || !Number.isFinite(timeoutMs) || timeoutMs <= 0) return DEFAULT_TIMEOUT_MS;
1496
+ return Math.floor(timeoutMs);
1497
+ }
1498
+ function normalizeBaseUrl(baseUrl) {
1499
+ const normalized = baseUrl.trim();
1500
+ if (normalized.length === 0) return DEFAULT_BASE_URL;
1501
+ return normalized.endsWith("/") ? normalized : `${normalized}/`;
1502
+ }
1503
+ function resolveUrl(baseUrl, pathOrUrl) {
1504
+ if (/^https?:\/\//i.test(pathOrUrl)) return new URL(pathOrUrl);
1505
+ return new URL(pathOrUrl, baseUrl);
1506
+ }
1507
+ function deriveTorrentFileName(id, title) {
1508
+ const sanitized = title.replace(/[\\/:*?"<>|]/g, " ").replace(/\s+/g, " ").trim().slice(0, 80);
1509
+ if (sanitized.length === 0) return `${id}.torrent`;
1510
+ return `${sanitized}.torrent`;
1511
+ }
1512
+ function extractFileNameFromContentDisposition(headerValue) {
1513
+ if (headerValue === null || headerValue.trim().length === 0) return;
1514
+ const utf8Match = /filename\*=UTF-8''([^;]+)/i.exec(headerValue);
1515
+ if (utf8Match !== null) return decodeURIComponentSafe(utf8Match[1]);
1516
+ const plainMatch = /filename="?([^";]+)"?/i.exec(headerValue);
1517
+ if (plainMatch !== null) return plainMatch[1]?.trim();
1518
+ }
1519
+ function decodeURIComponentSafe(value) {
1520
+ try {
1521
+ return decodeURIComponent(value);
1522
+ } catch {
1523
+ return value;
1524
+ }
1525
+ }
1526
+ const SAMPLE_TORRENTS = [
1527
+ {
1528
+ id: "1001",
1529
+ title: "Ubuntu 24.04 LTS x64",
1530
+ size: "4.6 GB",
1531
+ seeders: 82,
1532
+ leechers: 5,
1533
+ tags: ["linux", "iso"],
1534
+ uploadedAt: "2026-01-10T10:00:00.000Z",
1535
+ category: "OS"
1536
+ },
1537
+ {
1538
+ id: "1002",
1539
+ title: "Fedora Workstation 42",
1540
+ size: "2.4 GB",
1541
+ seeders: 31,
1542
+ leechers: 3,
1543
+ tags: ["linux", "desktop"],
1544
+ uploadedAt: "2026-01-13T12:30:00.000Z",
1545
+ category: "OS"
1546
+ },
1547
+ {
1548
+ id: "1200",
1549
+ title: "Open Source Fonts Pack",
1550
+ size: "920 MB",
1551
+ seeders: 12,
1552
+ leechers: 2,
1553
+ tags: ["fonts", "design"],
1554
+ uploadedAt: "2026-02-01T08:45:00.000Z",
1555
+ category: "Assets"
1556
+ }
1557
+ ];
1558
+ function createMockByrClient(records = SAMPLE_TORRENTS) {
1559
+ return {
1560
+ async search(query, limit) {
1561
+ const normalized = query.trim().toLowerCase();
1562
+ if (normalized.length === 0) return [];
1563
+ return records.filter((item) => item.title.toLowerCase().includes(normalized)).slice(0, limit).map((item) => ({
1564
+ id: item.id,
1565
+ title: item.title,
1566
+ size: item.size,
1567
+ seeders: item.seeders,
1568
+ leechers: item.leechers,
1569
+ tags: item.tags,
1570
+ sizeBytes: parseSizeToBytes(item.size),
1571
+ time: item.uploadedAt,
1572
+ category: item.category
1573
+ }));
1574
+ },
1575
+ async getById(id) {
1576
+ const detail = records.find((item) => item.id === id);
1577
+ if (detail === void 0) throw new CliAppError({
1578
+ code: "E_NOT_FOUND_RESOURCE",
1579
+ message: `Torrent not found: ${id}`,
1580
+ details: { id }
1581
+ });
1582
+ return {
1583
+ ...detail,
1584
+ sizeBytes: parseSizeToBytes(detail.size),
1585
+ time: detail.uploadedAt
1586
+ };
1587
+ },
1588
+ async getDownloadPlan(id) {
1589
+ const detail = await this.getById(id);
1590
+ return {
1591
+ id: detail.id,
1592
+ fileName: `${detail.id}.torrent`,
1593
+ sourceUrl: `https://byr.pt/download.php?id=${detail.id}`
1594
+ };
1595
+ },
1596
+ async downloadTorrent(id) {
1597
+ const plan = await this.getDownloadPlan(id);
1598
+ const encoder = new TextEncoder();
1599
+ return {
1600
+ ...plan,
1601
+ content: encoder.encode(`mock torrent payload for ${id}`)
1602
+ };
1603
+ },
1604
+ async getUserInfo() {
1605
+ return {
1606
+ id: "101",
1607
+ name: "mock-user",
1608
+ messageCount: 0,
1609
+ uploadedBytes: 1024 * 1024,
1610
+ downloadedBytes: 1024,
1611
+ trueUploadedBytes: 1024 * 1024,
1612
+ trueDownloadedBytes: 1024,
1613
+ ratio: 1024,
1614
+ levelName: "User",
1615
+ levelId: 1,
1616
+ bonus: 100,
1617
+ seedingBonus: 0,
1618
+ bonusPerHour: 5,
1619
+ seeding: 10,
1620
+ seedingSizeBytes: 500 * 1024 * 1024,
1621
+ uploads: 3,
1622
+ hnrPreWarning: 0,
1623
+ hnrUnsatisfied: 0,
1624
+ joinTime: "2025-01-01T00:00:00.000Z",
1625
+ lastAccessAt: "2026-01-01T00:00:00.000Z",
1626
+ levelProgress: {
1627
+ currentLevelId: 1,
1628
+ currentLevelName: "User",
1629
+ nextLevelId: 2,
1630
+ nextLevelName: "Power User",
1631
+ met: true,
1632
+ unmet: []
1633
+ }
1634
+ };
1635
+ },
1636
+ async getCategories() {
1637
+ return {
1638
+ category: BYR_CATEGORY_FACET,
1639
+ incldead: BYR_INCLDEAD_FACET,
1640
+ spstate: BYR_SPSTATE_FACET,
1641
+ bookmarked: BYR_BOOKMARKED_FACET
1642
+ };
1643
+ },
1644
+ async getLevelRequirements() {
1645
+ return getByrMetadata().levels;
1646
+ },
1647
+ async verifyAuth() {
1648
+ return { authenticated: true };
1649
+ }
1650
+ };
1651
+ }
1652
+
1653
+ //#endregion
1654
+ export { BYR_INCLDEAD_FACET as a, parseCategoryAliases as c, BYR_BOOKMARKED_FACET as i, parseSimpleFacetAliases as l, createByrClientFromEnv as n, BYR_SPSTATE_FACET as o, createMockByrClient as r, getByrMetadata as s, createByrClient as t };