/** # 脚本说明 * 存量 SERP Profile 浏览器外观随机化:标签含 SERP 的 Profile 主题随机浅色/深色(50/50)。 * 两步:① admin DB 批量 UPDATE browser_theme;② 逐个 PATCH 节点 * /profiles/:指纹/browser-env {theme}(下一次浏览器启动生效;节点侧 ID 统一为指纹)。 * 认证:节点侧 x-node-key = nodes.key;节点寻址 nodes.base_url。 * 幂等可重跑(每次重新随机)。 * 用法:在 admin 机上执行 bun randomize-themes.ts [并发=24] * (bun 自动加载 admin/.env 的 DATABASE_URL;本地默认 serpmax_admin@127.0.0.1:5432) */ import { SQL } from "bun"; /** # 成员变量 admin Postgres 连接(DATABASE_URL 覆盖;默认本机 serpmax_admin) **/ const sql = new SQL(process.env.DATABASE_URL ?? "postgres://postgres:postgres@127.0.0.1:5432/serpmax_admin"); /** # 成员变量 节点 PATCH 并发数 **/ const CONCURRENCY = Math.max(1, Number(process.argv[2] ?? 24)); /** # 函数 * 主流程:拉取 SERP Profile 与节点信息 → 每号随机主题 → 批量落 admin DB → 并发推节点。 */ async function main(): Promise { // 节点寻址与密钥(name → base_url / key) const nodes = await sql`SELECT name, base_url, key FROM nodes`; const nodeById = new Map(); for (const n of nodes) { nodeById.set(String(n.name), { baseUrl: String(n.base_url ?? "").replace(/\/$/, ""), key: String(n.key ?? "") }); } // SERP Profile 池(admin 侧 ID 为 UUID;指纹是节点侧 ID) const rows = await sql`SELECT id::text AS id, fingerprint, node, browser_theme FROM profiles WHERE tags @> ARRAY['SERP']::text[]`; console.log(`[randomize] SERP Profile 共 ${rows.length} 个`); if (!rows.length) return; // 逐号随机主题并按目标节点分组 type Item = { id: string; fingerprint: string; theme: string }; const byNode = new Map(); for (const r of rows) { const item: Item = { id: String(r.id), fingerprint: String(r.fingerprint ?? r.id), theme: Math.random() < 0.5 ? "light" : "dark", }; const list = byNode.get(String(r.node ?? "")) ?? []; list.push(item); byNode.set(String(r.node ?? ""), list); } // ① admin DB 批量落库(2000/批,UPDATE...FROM VALUES) let dbUpdated = 0; for (const items of byNode.values()) { for (let i = 0; i < items.length; i += 2000) { const chunk = items.slice(i, i + 2000); const params = chunk.flatMap((it) => [it.id, it.theme]); const ph = chunk.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`).join(","); await sql.unsafe(`UPDATE profiles AS p SET browser_theme = v.theme FROM (VALUES ${ph}) AS v(id, theme) WHERE p.id::text = v.id`, params); dbUpdated += chunk.length; } } console.log(`[randomize] admin DB 已更新 ${dbUpdated} 个`); // ② 并发推节点(失败不阻塞,最后汇总) let okCount = 0; let failCount = 0; const cursor = { n: 0 }; const all = [...byNode.entries()].flatMap(([node, items]) => items.map((it) => ({ node, ...it }))); async function worker(): Promise { while (cursor.n < all.length) { const it = all[cursor.n++]; const target = nodeById.get(it.node); if (!target?.baseUrl) { failCount++; continue; } try { const res = await fetch(`${target.baseUrl}/profiles/${encodeURIComponent(it.fingerprint)}/browser-env`, { method: "PATCH", headers: { "content-type": "application/json", "x-node-key": target.key }, body: JSON.stringify({ theme: it.theme }), signal: AbortSignal.timeout(8000), }); if (res.ok) okCount++; else failCount++; } catch { failCount++; } if ((okCount + failCount) % 1000 === 0) console.log(`[randomize] 节点推送 ${okCount + failCount}/${all.length}`); } } await Promise.all(Array.from({ length: CONCURRENCY }, () => worker())); console.log(`[randomize] 完成:节点推送成功 ${okCount},失败 ${failCount}`); } await main();