fix(sites): HHClub parsing, resource list layout and request restraint

HHClub (憨憨) was checked read-only against a real account (one detail
request; list, user data and health came from the product's own fetch).

Parsing fixes:
- list titles carried the site's new-torrent marker "[新]" (58 of 100
  rows; the detail page title has none); stripped with a replace filter
- refreshing a detail overwrote seeders / leechers / snatched / category
  name / H&R with null because the HHClub div detail page lacks them;
  detail values now fall back to the list snapshot (mergeDetailOverList),
  and an unknown detail promotion keeps the list promotion
- level "贵宾" is VIP (umt.vipLevels)
- sanitized minimal fixture HHANCLUB_LIST_PAGE_2026 + tests; README marks
  HHClub as verified 2026-09-29
Size, counts, category ids/names, +08:00 time and subtitle were correct.

Request restraint:
- site definitions may carry umt.defaults; merged with settings.sites
  (longer intervals, fewer pages): new settings defaultSnapshotMaxPages 1,
  defaultHealthCheckIntervalSec 6 h, defaultDetailCacheSec 24 h; default
  burst 2. HHClub: snapshot 60 min page 1 only, health 6 h, search
  concurrency 1, detail cache 24 h, 1 token / 3 s burst 2
- periodic / manual snapshots are capped to that page count; scheduled
  health checks skip sites checked within the interval; detail refresh is
  served from cache inside the window; live searches on one site queue
- every rate-limiter token is counted per site per hour
  (site_request_count, migration 0046); GET /sites/{id}/request-stats;
  site overview and resource list show requests in the last 24 hours

Resource list: two-layer rows. Main line: title + spec tags (episodes,
resolution, source, codec, HDR, group, 国语/中字, site tags) + promotion
with time left + H&R. Second line: subtitle (one line, ellipsis) and
fixed-width category / size / seed-leech-snatched / upload time
(relative, absolute on hover) columns, stale badge, inventory overlay;
folds into a card on phones.

Also aligns identity_candidate.normalized_title order in db types with
the migrations (schema drift test).

Takes effect after the stack is rebuilt (migration 0046).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 15:18:15 +08:00
1 parent 20b63db254
commit 901b01d1a1
43 files changed
+1222 -1315

No files matched your search

@@ -1,4 +1,10 @@
import { CHDBITS_LIST_PAGE, CHDBITS_LOGIN_PAGE, HHANCLUB_DETAIL_PAGE, HHANCLUB_LIST_PAGE } from "@umt/testkit";
import {
CHDBITS_LIST_PAGE,
CHDBITS_LOGIN_PAGE,
HHANCLUB_DETAIL_PAGE,
HHANCLUB_LIST_PAGE,
HHANCLUB_LIST_PAGE_2026,
} from "@umt/testkit";
import { describe, expect, it } from "vitest";
import { getSiteDefinition, listSiteDefinitions } from "../registry.ts";
import { parseDetailPage } from "../spider/detail.ts";
@@ -102,6 +108,44 @@ describe("HHClub 定义(div 卡片模板)", () => {
});
});
it("2026-09-29 实测结构:去掉新种标记「[新]」,短剧 412、完成数 0、无促销、时间按 +08:00、下载链接 passkey 脱敏", () => {
const [a, b] = parse(HHANCLUB_LIST_PAGE_2026);
expect(a).toMatchObject({
siteReleaseId: "91001",
title: "示例短剧 1080p WEB-DL H265 AAC-HHWEB",
subtitle: "示例短剧 | 全30集 | 1080p",
category: { id: "412", name: "短剧" },
sizeBytes: Math.round(185.38 * 1024 ** 2),
seeders: 1,
leechers: 58,
snatched: 0,
promotion: { type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null },
hr: false,
uploadedAt: "2026-09-29T06:07:03.000Z",
detailUrl: "https://hhanclub.net/details.php?id=91001&hit=1",
});
expect(a!.downloadUrl).not.toContain("0000000000000000");
expect(b).toMatchObject({
title: "Example Show S01 2026 2160p WEB-DL H.264 AAC-HHWEB",
category: { id: "402", name: "电视剧" },
snatched: 187,
uploadedAt: "2026-09-29T04:03:28.000Z",
});
});
it("站点访问默认:快照只抓 1 页、健康检测 6 小时、详情缓存 24 小时、搜索并发 1;3 秒 1 个令牌、突发 2", () => {
expect(hhanclubDefinition.umt.defaults).toEqual({
snapshotIntervalSec: 3600,
snapshotMaxPages: 1,
healthCheckIntervalSec: 21600,
userDataIntervalSec: 21600,
searchConcurrency: 1,
detailCacheSec: 86400,
});
expect(hhanclubDefinition.umt.rateLimit).toEqual({ minIntervalMs: 3000, burst: 2 });
expect(hhanclubDefinition.umt.vipLevels).toContain("贵宾");
});
it("浏览从 page=0 起(MovieClaw page_offset -1);mtype=tv 选 402/403/407/412", () => {
expect(new SiteSpider(hhanclubDefinition, { page: 0 }).getSearchUrl()).toBe(
"https://hhanclub.net/torrents.php?page=0",
@@ -86,7 +86,17 @@
"attribute": "title"
},
"title": {
"text": "{% if fields['title_optional'] %}{{ fields['title_optional'] }}{% else %}{{ fields['title_default'] }}{% endif %}"
"text": "{% if fields['title_optional'] %}{{ fields['title_optional'] }}{% else %}{{ fields['title_default'] }}{% endif %}",
"filters": [
{
"name": "replace",
"args": ["[新]", ""]
},
{
"name": "replace",
"args": ["(新)", ""]
}
]
},
"details": {
"selector": "div.torrent-title a[href*='details.php']",
@@ -175,13 +185,13 @@
}
},
"umt": {
"version": "[email protected]",
"version": "[email protected]+umt.1",
"kind": "nexusphp",
"displayName": "憨憨",
"timezone": "+08:00",
"rateLimit": {
"minIntervalMs": 3000,
"burst": 1
"burst": 2
},
"capabilities": {
"list": true,
@@ -249,6 +259,15 @@
"douban": "a[href*='douban.com']::attr(href)",
"fileList": "td.rowfollow > ul > li::text"
}
},
"vipLevels": ["贵宾", "VIP"],
"defaults": {
"snapshotIntervalSec": 3600,
"snapshotMaxPages": 1,
"healthCheckIntervalSec": 21600,
"userDataIntervalSec": 21600,
"searchConcurrency": 1,
"detailCacheSec": 86400
}
}
}
@@ -74,6 +74,14 @@ const CapabilitiesSchema = z.strictObject({
download: z.boolean(),
rss: z.boolean(),
});
const AccessDefaultsSchema = z.strictObject({
snapshotIntervalSec: z.number().int().min(300).optional(),
snapshotMaxPages: z.number().int().min(1).max(10).optional(),
healthCheckIntervalSec: z.number().int().min(300).optional(),
userDataIntervalSec: z.number().int().min(300).optional(),
searchConcurrency: z.number().int().min(1).max(4).optional(),
detailCacheSec: z.number().int().min(0).optional(),
});
const RateLimitSchema = z.strictObject({ minIntervalMs: z.number().int().min(0), burst: z.number().int().min(1) });
const TimezoneSchema = z.string().regex(/^[+-]\d{2}:\d{2}$/, "时区须为 ±HH:MM 偏移");
const PairSchema = z.tuple([z.string(), z.string()]);
@@ -87,6 +95,8 @@ const UmtNexusSchema = z.strictObject({
authSupported: z.array(z.enum(["cookie", "credential", "apikey"])).optional(),
timezone: TimezoneSchema,
rateLimit: RateLimitSchema,
/** 站点级访问默认(与 settings.sites 取更保守的一方) */
defaults: AccessDefaultsSchema.optional(),
capabilities: CapabilitiesSchema,
categoryNames: z.record(z.string(), z.string()).optional(),
loginSuccess: z.string().optional(),