fix(sites): HHClub parsing, resource list layout and request restraint (re-landed)
Re-lands901b01d, which was reverted wholesale by28974d1(BR-2 committed from the shared index). The agent UI files901b01dhad 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:
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 };
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user