Files
UHD_Media_Tool/packages/adapters/src/browser/cookies.ts
T
rootandClaude Opus 5.5 ebd07c9230 feat(browser): fixed Windows identity and per-site egress in umt-cookies plugin (BR-4)
- browser:launching hook generates a Windows fingerprint once (camoufox-js,
  os=windows), persists it to /root/.camofox/umt-identity.json and reuses it
  on every launch; Firefox/Camoufox upgrades only bump UA/version fields
- Windows fontconfig with absolute font dir (upstream path was broken)
- GET /umt/identity (public UA/OS/version) and PUT /umt/sessions/:id/proxy
  (per-context proxy; Firefox no longer uses container HTTP_PROXY env)
- adapter identity()/setEgress(), splitBrowserOnlyCookies for cf_clearance/__cf_bm

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 03:17:06 +08:00

119 lines
4.8 KiB
TypeScript

import { BROWSER_ONLY_COOKIES, type BrowserCookie } from "@umt/contracts";
// BR-2 凭据统一:站点账号里存的是 HTTP `Cookie:` 请求头串("a=1; b=2"),浏览器 profile 里是带域名/路径/期限的 Cookie 对象。
// 这里只做两者互转与比较;值是凭据,不打印、不进错误信息。
/** 注入时 Cookie 头串里没有期限信息:给 30 天,站点响应的 Set-Cookie 会按站点自己的期限覆盖 */
export const INJECTED_COOKIE_TTL_SEC = 30 * 24 * 3600;
/** "a=1; b=2" → [[a, 1], [b, 2]](同名后者覆盖;去掉空段与首尾空白) */
export function parseCookieHeader(header: string | null | undefined): Map<string, string> {
const out = new Map<string, string>();
for (const part of (header ?? "").split(";")) {
const i = part.indexOf("=");
if (i <= 0) continue;
const name = part.slice(0, i).trim();
if (!name || /[\s,;]/.test(name)) continue;
out.set(name, part.slice(i + 1).trim());
}
return out;
}
/** Map → "a=1; b=2"(按名字排序,比较稳定) */
export function formatCookieHeader(jar: Map<string, string>): string {
return [...jar.entries()]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([k, v]) => `${k}=${v}`)
.join("; ");
}
/** 多个 Cookie 头串合并,靠后的优先(用户填的 + 自动登录得到的会话 Cookie → 会话 Cookie 覆盖同名项) */
export function mergeCookieHeaders(...headers: (string | null | undefined)[]): string | null {
const jar = new Map<string, string>();
for (const h of headers) for (const [k, v] of parseCookieHeader(h)) jar.set(k, v);
return jar.size ? formatCookieHeader(jar) : null;
}
/** 仅浏览器用的 Cookie(Cloudflare 人机验证):名字精确匹配,或 cf_chl_ 前缀(挑战过程中的临时 Cookie) */
export function isBrowserOnlyCookie(name: string): boolean {
return (BROWSER_ONLY_COOKIES as readonly string[]).includes(name) || name.startsWith("cf_chl_");
}
/**
* 浏览器 → HTTP 方向:剔除仅浏览器用的 Cookie(BR-4)。返回剩下的头串(没有剩余为 null)与被剔除的名字(不含值)。
*/
export function splitBrowserOnlyCookies(header: string | null | undefined): {
header: string | null;
browserOnly: string[];
} {
const jar = parseCookieHeader(header);
const browserOnly: string[] = [];
for (const name of [...jar.keys()])
if (isBrowserOnlyCookie(name)) {
jar.delete(name);
browserOnly.push(name);
}
return { header: jar.size ? formatCookieHeader(jar) : null, browserOnly: browserOnly.sort() };
}
/** 两个 Cookie 头串是否等价(忽略顺序与空白) */
export function sameCookieHeader(a: string | null | undefined, b: string | null | undefined): boolean {
const x = parseCookieHeader(a);
const y = parseCookieHeader(b);
if (x.size !== y.size) return false;
for (const [k, v] of x) if (y.get(k) !== v) return false;
return true;
}
/** 站点地址 → 注入用 Cookie 对象:域名 = 站点主机(host-only),路径 /,https 站点标 secure */
export function cookiesFromHeader(
header: string,
siteUrl: string,
nowSec = Math.floor(Date.now() / 1000),
): BrowserCookie[] {
const u = new URL(siteUrl);
const secure = u.protocol === "https:";
return [...parseCookieHeader(header)].map(([name, value]) => ({
name,
value,
domain: u.hostname,
path: "/",
expires: nowSec + INJECTED_COOKIE_TTL_SEC,
httpOnly: false,
secure,
sameSite: "Lax" as const,
}));
}
/** Cookie 的 domain 是否作用于该主机(host-only 精确匹配;.根域 匹配子域) */
export function cookieDomainMatches(cookieDomain: string, host: string): boolean {
const d = cookieDomain.toLowerCase().replace(/^\./, "");
const h = host.toLowerCase();
return h === d || h.endsWith(`.${d}`);
}
/**
* 浏览器读回的 Cookie → 站点 Cookie 头串。只取作用于站点主机、路径为根(或站点路径前缀)且未过期的,
* 其它域名(统计、CDN、第三方)的 Cookie 一律丢弃(回写只对该站域名)。
*/
export function headerFromCookies(
cookies: readonly BrowserCookie[],
siteUrl: string,
nowSec = Math.floor(Date.now() / 1000),
): string | null {
const u = new URL(siteUrl);
const jar = new Map<string, { v: string; specific: number }>();
for (const c of cookies) {
if (!cookieDomainMatches(c.domain, u.hostname)) continue;
if (c.expires !== undefined && c.expires > 0 && c.expires <= nowSec) continue;
const path = c.path || "/";
if (!u.pathname.startsWith(path)) continue;
// 同名多条:更具体的域名 / 路径优先(与浏览器发送顺序一致)
const specific = c.domain.replace(/^\./, "").length * 1000 + path.length;
const prev = jar.get(c.name);
if (!prev || specific >= prev.specific) jar.set(c.name, { v: c.value, specific });
}
if (!jar.size) return null;
return formatCookieHeader(new Map([...jar].map(([k, x]) => [k, x.v])));
}