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

Re-lands 901b01d, which was reverted wholesale by 28974d1 (BR-2 committed
from the shared index). The agent UI files 901b01d had rolled back are
left at their AGUI-2 versions; contracts, routes, db types and the runbook
part already reached main through later commits, so this carries the rest.

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 16:00:34 +08:00
1 parent 99e335d13c
commit 6e9e8efec2
22 files changed
+753 -76

No files matched your search

@@ -14,7 +14,7 @@ import {
type TmdbSharedCache,
} from "@umt/adapters";
import { DEFAULT_SETTINGS, type SitesSettings } from "@umt/contracts";
import { pgTokenBucketFor, type SiteRow } from "@umt/db";
import { pgTokenBucketFor, type SiteRow, sql } from "@umt/db";
import { CACHE_REGIONS, sharedCache, type TmdbCachePolicy, tmdbCachePolicy } from "../../shared-cache.ts";
import type { ServerContext } from "../types.ts";
@@ -170,7 +170,7 @@ export class SourcesRuntime {
if (hit && hit.sig === sig) return hit.adapter;
const lo = limiterOptions(def, site.rate_limit, defaults.defaultRateLimit);
// 跨进程:api / worker / worker:agent 对同一站点共用 PG 令牌桶(key = site:<id>);进程内先串行排队
const limiter =
const inner =
this.overrides.sharedRateLimit === false
? createSiteRateLimiter(lo)
: createSharedSiteRateLimiter({
@@ -183,6 +183,7 @@ export class SourcesRuntime {
"站点共享限流不可用,本次退回进程内限流",
),
});
const limiter = countingLimiter(inner, () => this.countSiteRequest(site.id));
const cfg = (site.config ?? {}) as { apiBaseUrl?: string };
const baseUrl =
def.umt.kind === "mteam" ? cfg.apiBaseUrl : sameUrl(site.base_url, def.domain) ? undefined : site.base_url;
@@ -197,6 +198,41 @@ export class SourcesRuntime {
return adapter;
}
/**
* 站点请求计数(site_request_count,按小时聚合):每拿到一个令牌记一次。写失败只记日志,不影响请求;
* 顺手删 7 天前的行(每进程每小时最多一次)。
*/
private lastCountPrune = 0;
countSiteRequest(siteId: string): void {
const hour = new Date(Math.floor(this.now().getTime() / 3_600_000) * 3_600_000);
const db = this.ctx.db;
void sql`insert into site_request_count (site_id, hour, count) values (${siteId}, ${hour}, 1)
on conflict (site_id, hour) do update set count = site_request_count.count + 1`
.execute(db)
.then(async () => {
if (Date.now() - this.lastCountPrune < 3_600_000) return;
this.lastCountPrune = Date.now();
await sql`delete from site_request_count where hour < ${new Date(hour.getTime() - 7 * 86_400_000)}`.execute(db);
})
.catch((err) =>
this.ctx.logger.warn({ siteId, err: { message: (err as Error).message } }, "站点请求计数写入失败"),
);
}
/** 最近 hours 小时(含当前小时)该站点的请求次数 */
async siteRequestCount(siteId: string, hours = 24): Promise<number> {
const hour = Math.floor(this.now().getTime() / 3_600_000) * 3_600_000;
const since = new Date(hour - (hours - 1) * 3_600_000);
const r = await sql<{ n: number }>`select coalesce(sum(count), 0)::int as n from site_request_count
where site_id = ${siteId} and hour >= ${since}`.execute(this.ctx.db);
return Number(r.rows[0]?.n ?? 0);
}
/** 站点访问默认:站点定义 umt.defaults 与 settings.sites 取更保守的一方 */
async siteAccess(siteKey: string): Promise<SiteAccess> {
return siteAccessFor(getSiteDefinition(siteKey) ?? null, await this.siteDefaults());
}
/** 已建立的站点限流器(测试与诊断用) */
limiterOf(siteId: string): SiteRateLimiter | null {
return this.adapters.get(siteId)?.limiter ?? null;
@@ -260,6 +296,63 @@ function sameUrl(a: string, b: string): boolean {
return a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
}
/** 站点访问默认(已合并定义与设置) */
export interface SiteAccess {
/** 站点行没设快照周期时用 */
snapshotIntervalSec: number;
snapshotMaxPages: number;
healthCheckIntervalSec: number;
detailCacheSec: number;
searchConcurrency: number;
}
type DefinitionDefaults = {
snapshotIntervalSec?: number;
snapshotMaxPages?: number;
healthCheckIntervalSec?: number;
detailCacheSec?: number;
searchConcurrency?: number;
};
/** 周期 / 缓存取较长,页数 / 并发取较小(定义值是按站点实测给的保守下限) */
export function siteAccessFor(def: SiteDefinition | null, s: SitesSettings): SiteAccess {
const d = ((def?.umt as { defaults?: DefinitionDefaults } | undefined)?.defaults ?? {}) as DefinitionDefaults;
const max = (a: number | undefined, b: number) => (a === undefined ? b : Math.max(a, b));
const min = (a: number | undefined, b: number) => (a === undefined ? b : Math.min(a, b));
return {
snapshotIntervalSec: max(d.snapshotIntervalSec, s.defaultSnapshotIntervalSec),
snapshotMaxPages: min(d.snapshotMaxPages, s.defaultSnapshotMaxPages),
healthCheckIntervalSec: max(d.healthCheckIntervalSec, s.defaultHealthCheckIntervalSec),
detailCacheSec: max(d.detailCacheSec, s.defaultDetailCacheSec),
searchConcurrency: d.searchConcurrency ?? 1,
};
}
/** 同站实时搜索并发(只看站点定义,同步取值;缺省 1) */
export function siteSearchConcurrency(siteKey: string): number {
const def = getSiteDefinition(siteKey);
return ((def?.umt as { defaults?: DefinitionDefaults } | undefined)?.defaults?.searchConcurrency ?? 1) as number;
}
/** 包一层限流器:每发放一个令牌回调一次(计数) */
function countingLimiter(inner: SiteRateLimiter, onGrant: () => void): SiteRateLimiter {
return {
get stats() {
return inner.stats;
},
async acquire(signal) {
await inner.acquire(signal);
onGrant();
},
tryAcquire() {
const ok = inner.tryAcquire();
if (ok) onGrant();
return ok;
},
penalize: (ms) => inner.penalize(ms),
};
}
type RateLimitLike = { requestsPerMinute?: number; minIntervalMs?: number; concurrency?: number };
/**