feat(sites): site requests follow browser UA, smooth UA switch, CF cookies browser-only (BR-4)

- every outbound site request (adapter list/search/detail/download/health,
  RSS, login, user data) carries the account UA via SourcesRuntime.siteFetch;
  no more Deno default UA
- priority: account manual > pending > global setting > browser identity
  (fallback: same-shape Windows Firefox UA); settings UA default is empty
- migration 0056: existing cookie/password accounts keep their old UA as
  ua_pending until re-login or browser cookie write-back switches cookie+UA
  in one transaction; browser_provider.identity caches the browser identity
- cf_clearance/__cf_bm stripped on browser write-back (names kept only)
- browser sessions get per-site egress (same proxy as HTTP, or direct)
- site account page shows UA source; raw UA in tech info

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-30 03:17:13 +08:00
1 parent ebd07c9230
commit adc853aa64
21 files changed
+955 -72

No files matched your search

@@ -24,6 +24,7 @@ import {
tmdbDetailRef,
} from "../../shared-cache.ts";
import type { ServerContext } from "../types.ts";
import { readBrowserUserAgent, resolveUserAgent, type UserAgentDefaults } from "./service/user-agent.ts";
// 模块运行时依赖:站点适配器与限流器(每站一个实例,令牌在 PG 跨进程共享)、RSS 抓取器、TMDB 客户端、时钟。
// 测试通过 createSourcesIdentityModule(overrides) 注入 fetch / TMDB / 时钟。
@@ -168,8 +169,28 @@ export class SourcesRuntime {
/**
* 站点请求用的 fetch:测试注入优先;站点开了"使用代理"且设置了 proxyUrl 时走代理(Deno.createHttpClient)。
* BR-4:每条请求都带 UA(请求自己设了就不动;否则 ua 参数,缺省按"跟随浏览器"),不再发 Deno 缺省的 `Deno/x.y.z`。
* ua 可以是函数:每次请求现场取(站点适配器按当前账号取,账号切换 UA 后立即生效)。
*/
siteFetch(site: Pick<SiteRow, "proxy">, defaults: SitesSettings = this.siteDefaultsCache): typeof globalThis.fetch {
siteFetch(
site: Pick<SiteRow, "proxy">,
defaults: SitesSettings = this.siteDefaultsCache,
ua?: string | (() => Promise<string>),
): typeof globalThis.fetch {
const base = this.transportFetch(site, defaults);
return ((input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
if (headers.has("user-agent")) return base(input, { ...init, headers });
const pick = typeof ua === "function" ? ua() : ua !== undefined ? Promise.resolve(ua) : this.defaultUserAgent();
return pick.then((v) => {
headers.set("user-agent", v);
return base(input, { ...init, headers });
});
}) as typeof globalThis.fetch;
}
/** 不加 UA 的底层 fetch(代理 / 测试注入) */
private transportFetch(site: Pick<SiteRow, "proxy">, defaults: SitesSettings): typeof globalThis.fetch {
if (this.overrides.fetch) return this.overrides.fetch;
if (!site.proxy || !defaults.proxyUrl) return globalThis.fetch;
const key = defaults.proxyUrl;
@@ -182,6 +203,56 @@ export class SourcesRuntime {
return ((input: RequestInfo | URL, init?: RequestInit) =>
globalThis.fetch(input, { ...init, client: c } as RequestInit)) as typeof globalThis.fetch;
}
// ---------- BR-4 站点请求 UA ----------
private browserUa: { at: number; ua: string | null } = { at: 0, ua: null };
/** 浏览器固定身份的 UA(60 秒缓存);读不到为 null(调用方退回常量) */
async browserUserAgent(): Promise<string | null> {
if (Date.now() - this.browserUa.at < 60_000) return this.browserUa.ua;
let ua: string | null = this.browserUa.ua;
try {
ua = (await readBrowserUserAgent(this.ctx)) ?? ua;
} catch (err) {
this.ctx.logger.debug({ err: { message: (err as Error).message } }, "读取浏览器身份 UA 失败,沿用上次的值");
}
this.browserUa = { at: Date.now(), ua };
return ua;
}
/** UA 解析用的全局部分:设置里的全局 UA + 浏览器 UA(已退回常量) */
async uaDefaults(): Promise<UserAgentDefaults> {
const [s, browser] = await Promise.all([this.siteDefaults(), this.browserUserAgent()]);
return { global: s.userAgent ?? "", browser: browser ?? "" };
}
/** 不挂账号的请求(通用 RSS 等)用的 UA:全局 UA 或跟随浏览器 */
async defaultUserAgent(): Promise<string> {
return resolveUserAgent(null, await this.uaDefaults()).ua;
}
/**
* 站点请求 UA:取与站点凭证相同的账号(第一个启用、未失效、非 Passkey 的账号;没有就第一个启用的),按账号解析。
* 不缓存:与 Cookie 同一时刻读库,账号切换 Cookie + UA 后下一条请求两者一起生效。
*/
async siteUserAgent(siteId: string): Promise<string> {
const d = await this.uaDefaults();
const rows = await this.ctx.db
.selectFrom("site_account")
.select(["user_agent", "ua_pending", "status", "auth_kind"])
.where("site_id", "=", siteId)
.where("enabled", "=", true)
.orderBy("created_at")
.execute();
const acc =
rows.find((r) => r.auth_kind !== "passkey" && (r.status === "unknown" || r.status === "active")) ??
rows.find((r) => r.auth_kind !== "passkey") ??
rows[0] ??
null;
return resolveUserAgent(acc, d).ua;
}
private readonly proxyClients = new Map<string, Deno.HttpClient>();
/**
@@ -232,7 +303,8 @@ export class SourcesRuntime {
secrets: this.siteSecrets(site.id),
limiter,
now: this.now,
...(this.overrides.fetch || proxy ? { fetch: this.siteFetch({ proxy }, defaults) } : {}),
// 每条请求都经 siteFetch:代理 + 按账号现场取 UA(BR-4)
fetch: this.siteFetch({ proxy }, defaults, () => this.siteUserAgent(site.id)),
...(baseUrl ? { baseUrl } : {}),
});
this.adapters.set(site.id, { sig, limiter, adapter });