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:
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 });
|
||||
|
||||
Reference in new issue
Block a user