/** * @file tools/dump_aistudio_dom.js * @brief 一次性工具:连接一个「已登录 Google 会话」的 CloakBrowser 环境,打开 * https://aistudio.google.com/api-keys ,抓取实现「AI Studio 一键组合」 * 功能所需的真实 DOM,用于确定语言无关的元素定位选择器。 * * 【为什么需要它】 * AI Studio 是 Angular Material 应用,界面按账号语言本地化(截图里英文/葡语 * 混用),靠文字匹配不可靠。要写出准确、不受语言影响的选择器,需要一次真实 * DOM 抓取。本脚本把列表页 / 创建对话框 / 项目下拉 / 某行 ⋮ 菜单等状态的关键 * DOM(按钮 outerHTML、role/aria、mat-icon 图标 ligature、mat-* 标签)dump * 成文件,供定稿选择器时参考。 * * 【默认只做非破坏性操作】 * - 打开 api-keys 列表页 → 抓工具栏按钮、表格结构、每行操作按钮、空态文案 * - 点「Create API key」打开创建对话框 → 抓 name 输入框 / 项目下拉 / 底部按钮 * - 展开项目下拉 → 抓下拉选项(Import project / Create project / 真实项目) * - 若列表存在已有 key:打开第一行 ⋮ 菜单 → 抓菜单项(Rename / Delete)后关闭 * 全程 **不确认创建、不删除任何 key/项目**。 * * 【可选 --create(有副作用,慎用)】 * 传入 --create 时,会在抓完上面内容后,真的点一次「Create key」,抓「API key * details」对话框里 4 个字段的真实 DOM(判断它们是 input 还是纯文本 span, * 影响读值方式)。这会在该账号下真实创建一枚 key,请只在可丢弃的测试账号上用。 * * 【用法】(在项目根目录执行) * node tools/dump_aistudio_dom.js * node tools/dump_aistudio_dom.js --create * * 【前置条件】 * 1. CloakBrowser Manager 已启动(默认本地接口 127.0.0.1:8722) * 2. 对应的窗口里 Google 已登录(AI Studio 复用 Google 登录态) * * 输出目录:tools/aistudio_dump/<时间戳>/ */ 'use strict'; const fs = require('fs'); const path = require('path'); const { chromium } = require('playwright'); const { openBrowserWithRetry, closeBrowser, getApi } = require('../src/core/browser_api'); const API_KEYS_URL = 'https://aistudio.google.com/api-keys'; const rawArg = process.argv[2]; const doCreate = process.argv.includes('--create'); // 允许不传 ID 或传 auto:自动探测当前唯一已打开的 CloakBrowser 环境。 let browserId = (rawArg && rawArg !== 'auto' && !rawArg.startsWith('--')) ? rawArg : null; /** * 通过 /browser/pids/all 拿到当前所有已打开窗口的 {id: pid} 映射。 * @returns {Promise} 已打开窗口 ID 数组 */ async function listOpenBrowserIds() { const api = getApi(); const resp = await api.getAllAliveBrowserPids().catch(() => null); if (!resp || !resp.success || !resp.data) return []; // 返回可能是 { id: pid } 映射,或数组;两种都兼容。 if (Array.isArray(resp.data)) { return resp.data.map(x => String((x && (x.id || x.browserId)) || x)).filter(Boolean); } return Object.keys(resp.data).filter(id => resp.data[id]); } async function resolveBrowserId() { if (browserId) return browserId; console.log('未指定环境 ID,正在探测当前已打开的 CloakBrowser 环境...'); const openIds = await listOpenBrowserIds(); if (openIds.length === 1) { console.log(` 探测到唯一已打开窗口: ${openIds[0]}`); return openIds[0]; } if (openIds.length === 0) { console.error('未探测到任何已打开的 CloakBrowser 环境。请先在 Manager 里打开目标环境,或显式传入 。'); process.exit(1); } console.error(`探测到多个已打开窗口,请显式指定其一:\n ${openIds.join('\n ')}`); console.error('用法: node tools/dump_aistudio_dom.js [--create]'); process.exit(1); } const OUT_DIR = path.join(__dirname, 'aistudio_dump', new Date().toISOString().replace(/[:.]/g, '-')); function ensureOutDir() { fs.mkdirSync(OUT_DIR, { recursive: true }); } function save(name, data) { const file = path.join(OUT_DIR, name); const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2); fs.writeFileSync(file, text, 'utf8'); console.log(` 已保存: ${path.relative(process.cwd(), file)}`); } function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } /** * 注入到页面里,把一批元素序列化成结构化信息,便于人工挑选择器。 * 记录:标签、role、aria-label、可见文本、class、data-* 属性、内部 mat-icon * 的图标 ligature 文本,以及裁剪后的 outerHTML。 */ const SERIALIZE_FN = `(selectorList) => { const isVisible = (el) => { if (!el) return false; const s = window.getComputedStyle(el); if (s.visibility === 'hidden' || s.display === 'none') return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; }; const dataAttrs = (el) => { const out = {}; for (const a of el.attributes) { if (a.name.startsWith('data-') || a.name.startsWith('aria-') || ['role', 'type', 'id', 'name', 'placeholder', 'disabled', 'mat-flat-button', 'mat-button', 'mattooltip'].includes(a.name)) { out[a.name] = a.value; } } return out; }; const icons = (el) => [...el.querySelectorAll('mat-icon, .material-icons, .material-symbols-outlined, [class*="mat-icon"]')] .map(i => (i.getAttribute('fonticon') || i.textContent || '').trim()) .filter(Boolean); const serialize = (el) => ({ tag: el.tagName.toLowerCase(), text: (el.innerText || el.textContent || '').trim().slice(0, 120), visible: isVisible(el), attrs: dataAttrs(el), icons: icons(el), outerHTML: el.outerHTML.slice(0, 1500), }); const result = {}; for (const sel of selectorList) { try { result[sel] = [...document.querySelectorAll(sel)].slice(0, 40).map(serialize); } catch (e) { result[sel] = [{ error: String(e) }]; } } return result; }`; // 覆盖列表页、对话框、下拉、菜单可能用到的候选选择器。抓下来后据此定稿。 const CANDIDATE_SELECTORS = [ // —— 工具栏 / 主区域按钮 —— 'button', '[role="button"]', 'a[role="button"]', // —— 表格 / 列表结构 —— 'table', 'tr', 'mat-row', '[role="row"]', 'mat-table', 'ms-api-key-row', 'ms-table-row', // —— 图标按钮(⋮ 更多、复制、删除等)—— 'mat-icon', '.material-icons', '.material-symbols-outlined', // —— 对话框 —— '[role="dialog"]', 'mat-dialog-container', '.cdk-overlay-container [role="dialog"]', // —— 对话框内输入框 / 下拉 —— '[role="dialog"] input', '[role="dialog"] textarea', '[role="dialog"] mat-select', '[role="dialog"] [role="combobox"]', '[role="dialog"] button', // —— 下拉展开后的选项浮层 —— '.cdk-overlay-container [role="listbox"]', '.cdk-overlay-container [role="option"]', 'mat-option', // —— ⋮ 菜单浮层 —— '.cdk-overlay-container [role="menu"]', '[role="menuitem"]', '.mat-mdc-menu-item', ]; async function dumpState(page, label) { console.log(`[抓取] ${label}`); const url = page.url(); const title = await page.title().catch(() => ''); const structured = await page.evaluate( ([fnBody, selectors]) => { // eslint-disable-next-line no-new-func const fn = new Function('return (' + fnBody + ')')(); return fn(selectors); }, [SERIALIZE_FN, CANDIDATE_SELECTORS], ).catch(e => ({ error: String(e) })); const bodyText = await page.evaluate(() => (document.body ? document.body.innerText : '')).catch(() => ''); // 完整页面 HTML(含 cdk-overlay-container 里的对话框/浮层)另存一份,便于精查。 const fullHtml = await page.content().catch(() => ''); save(`${label}.json`, { url, title, bodyTextPreview: String(bodyText).slice(0, 4000), structured }); save(`${label}.html`, fullHtml); } /** * 完整保存对话框(或整个 cdk 浮层容器)的 innerHTML,不做长度裁剪,便于精查 * 项目选择器 / 底部按钮 / 下拉浮层的真实结构。 * @param {import('playwright').Page} page * @param {string} name 输出文件名 * @param {boolean} [overlayFull=false] true 时导出整个 .cdk-overlay-container(含展开后的浮层) */ async function saveDialogInner(page, name, overlayFull = false) { const html = await page.evaluate((full) => { if (full) { const oc = document.querySelector('.cdk-overlay-container'); return oc ? oc.outerHTML : '(未找到 .cdk-overlay-container)'; } const dlg = document.querySelector('mat-dialog-container, .mat-mdc-dialog-container, [role="dialog"]'); return dlg ? dlg.outerHTML : '(未找到对话框容器)'; }, overlayFull).catch(e => `(导出失败: ${String(e)})`); save(name, html); } /** * 数 key 表格里的数据行数(不含表头)。用于删除探针前后比对,确认未发生删除。 * 数据行特征:tr[role=row] 内含 ⋮(more_vert) 按钮。 */ async function countKeyRows(page) { return page.evaluate(() => { const visible = element => { if (!element) return false; const style = window.getComputedStyle(element); if (style.visibility === 'hidden' || style.display === 'none') return false; const rect = element.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; }; const rows = [...document.querySelectorAll('tr[role="row"], mat-row, [role="row"]')].filter(visible); return rows.filter(row => row.querySelector('button[aria-haspopup="menu"]') || [...row.querySelectorAll('.material-symbols-outlined')].some(s => (s.textContent || '').trim() === 'more_vert')).length; }).catch(() => -1); } // 从一组候选选择器里返回第一个可见元素(供脚本自身点击用,非最终定位方案)。 async function firstVisible(page, selectors) { for (const sel of selectors) { const loc = page.locator(sel); const n = await loc.count().catch(() => 0); for (let i = 0; i < n; i++) { const el = loc.nth(i); if (await el.isVisible({ timeout: 500 }).catch(() => false)) return el; } } return null; } async function main() { ensureOutDir(); console.log(`输出目录: ${OUT_DIR}`); browserId = await resolveBrowserId(); console.log(`打开 CloakBrowser 环境: ${browserId} ...`); const openResult = await openBrowserWithRetry(browserId, { maxRetries: 3, logCallback: (m) => console.log(' [bit]', m), }); const wsEndpoint = openResult && openResult.data && openResult.data.ws; if (!wsEndpoint) { console.error('打开环境失败:无 ws 端点,请确认 CloakBrowser Manager 已启动、browserId 正确'); process.exit(1); } const browser = await chromium.connectOverCDP(wsEndpoint); const context = browser.contexts()[0]; const page = await context.newPage(); await page.bringToFront().catch(() => {}); try { console.log(`打开 ${API_KEYS_URL} ...`); await page.goto(API_KEYS_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }).catch(() => {}); // 等 Angular 首屏渲染 await sleep(6000); // 1) 列表页当前状态(有 key 或空态都照抓) await dumpState(page, '01_list'); // 2) 若存在已有 key:打开第一行 ⋮ 菜单抓菜单项(不点删除),随后关闭。 // 注意:图标是 .material-symbols-outlined 的 span(ligature=more_vert),无 mat-icon; // 且工具栏也有多个 aria-haspopup=menu 的按钮,必须限定在表格行内,避免误开 Settings 菜单。 const moreBtn = await firstVisible(page, [ '[role="row"] button[aria-haspopup="menu"]', 'tr[role="row"] button[aria-haspopup="menu"]', 'mat-row button[aria-haspopup="menu"]', 'tbody button[aria-haspopup="menu"]', ]); if (moreBtn) { await moreBtn.click({ timeout: 5000 }).catch(() => {}); await sleep(1200); await dumpState(page, '02_row_menu'); await page.keyboard.press('Escape').catch(() => {}); await sleep(600); } else { save('02_row_menu.txt', '列表当前无已存在的 key(未找到 ⋮ 菜单按钮)。'); } // 2b) 点「删除密钥」抓确认对话框结构,然后立刻取消 —— 不真正删除任何 key。 // 前后各数一次 key 行数,写入 02c_verify.txt 以便人工核对“没变”。 if (moreBtn) { const rowsBefore = await countKeyRows(page); await moreBtn.click({ timeout: 5000 }).catch(() => {}); await sleep(1200); // 删除项带稳定属性 data-test-delete-key(语言无关)。 const delItem = await firstVisible(page, [ '.cdk-overlay-container [role="menuitem"][data-test-delete-key]', '[role="menuitem"][data-test-delete-key]', '[data-test-delete-key]', ]); if (delItem) { await delItem.click({ timeout: 5000 }).catch(() => {}); await sleep(1800); await dumpState(page, '02c_delete_confirm'); await saveDialogInner(page, '02d_delete_confirm_inner.html'); } else { save('02c_delete_confirm.txt', '菜单中未找到 [data-test-delete-key] 删除项。'); } // 取消,绝不点主操作按钮:优先点对话框内的 cancel 类(borderless)按钮,再用 Esc 兜底。 const cancelled = await page.evaluate(() => { const visible = element => { if (!element) return false; const style = window.getComputedStyle(element); if (style.visibility === 'hidden' || style.display === 'none') return false; const rect = element.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; }; const dialogs = [...document.querySelectorAll('mat-dialog-container, .mat-mdc-dialog-container, [role="dialog"]')].filter(visible); if (!dialogs.length) return false; const dialog = dialogs[dialogs.length - 1]; const buttons = [...dialog.querySelectorAll('button')].filter(visible); // cancel 类按钮 = 非主操作(borderless/文字按钮),排除 ms-button-primary。 const cancels = buttons.filter(b => !b.classList.contains('ms-button-primary') && !b.classList.contains('mdc-button--unelevated')); // 排除右上角关闭按钮(matdialogclose,只有图标无文字)。 const texted = cancels.filter(b => (b.innerText || b.textContent || '').trim().length > 0); const target = texted.length ? texted[texted.length - 1] : cancels[cancels.length - 1]; if (!target) return false; target.click(); return true; }).catch(() => false); await sleep(1000); // Esc 兜底(若上一步未命中或对话框仍在)。 await page.keyboard.press('Escape').catch(() => {}); await sleep(800); const rowsAfter = await countKeyRows(page); save('02e_verify.txt', `删除探针为只读验证:key 行数 ${rowsBefore} -> ${rowsAfter}(应保持不变)。\n已点到取消按钮: ${cancelled ? '是' : '否(仅 Esc 兜底)'}`); } // 3) 打开「Create API key」创建对话框(不确认)。稳定 data-test-id 优先(语言无关)。 const createTopBtn = await firstVisible(page, [ 'button[data-test-id="create-api-key-button"]', 'button:has-text("Create API key")', 'button:has-text("创建 API 密钥")', 'button:has-text("Criar chave")', 'button:has-text("API key")', 'button:has-text("chave de API")', // 兜底:主区域里最靠右的醒目按钮,抓下来再看是否命中 'button.mdc-button--unelevated', 'button[mat-flat-button]', ]); if (createTopBtn) { await createTopBtn.click({ timeout: 5000 }).catch(() => {}); await sleep(2000); await dumpState(page, '03_create_dialog'); // 3b) 完整保存对话框容器 innerHTML(真实容器是 mat-dialog-container,不是 [role=dialog]) await saveDialogInner(page, '03b_dialog_inner.html'); // 4) 展开项目选择器。真实容器用 mat-dialog-container / .cdk-overlay-container 定位, // 候选控件放宽到 mat-select / combobox / ms-* 触发器 / 含展开箭头的按钮。 const projectSelect = await firstVisible(page, [ '.cdk-overlay-container mat-dialog-container mat-select', '.cdk-overlay-container mat-dialog-container [role="combobox"]', '.cdk-overlay-container mat-dialog-container .mat-mdc-select-trigger', 'mat-dialog-container mat-select', 'mat-dialog-container [role="combobox"]', 'mat-dialog-container ms-searchable-select', 'mat-dialog-container ms-select', 'mat-dialog-container [aria-haspopup="listbox"]', // 兜底:对话框内含 arrow_drop_down / expand_more 图标的可点控件 'mat-dialog-container button:has-text("project")', 'mat-dialog-container [class*="select"]', ]); if (projectSelect) { await projectSelect.click({ timeout: 5000 }).catch(() => {}); await sleep(1800); await dumpState(page, '04_project_dropdown'); await saveDialogInner(page, '04b_dropdown_overlay.html', true); await page.keyboard.press('Escape').catch(() => {}); await sleep(600); } else { save('04_project_dropdown.txt', '未找到项目下拉控件;请查看 03b_dialog_inner.html 里的项目选择器结构。'); } // 5) 可选:真实创建一枚 key,抓 API key details 对话框(有副作用!) if (doCreate) { console.log('[--create] 将真实创建一枚 key ...'); const createKeyBtn = await firstVisible(page, [ 'mat-dialog-container button:has-text("Create key")', 'mat-dialog-container button:has-text("Criar chave")', 'mat-dialog-container .ms-button-primary', ]); if (createKeyBtn) { await createKeyBtn.click({ timeout: 5000 }).catch(() => {}); await sleep(6000); await dumpState(page, '05_key_details'); await saveDialogInner(page, '05b_details_inner.html'); } else { save('05_key_details.txt', '未找到可点击的「Create key」按钮(可能因无项目而禁用)。'); } } else { // 未加 --create:关闭创建对话框,不产生任何 key await page.keyboard.press('Escape').catch(() => {}); } } else { save('03_create_dialog.txt', '未找到「Create API key」按钮(见 01_list 的 button 列表定位)。'); } console.log('\n抓取完成。请把以下目录发我或让我读取:'); console.log(` ${OUT_DIR}`); } catch (e) { console.error('抓取过程中出错:', e && e.message ? e.message : e); save('error.txt', (e && e.stack) ? e.stack : String(e)); } finally { await browser.close().catch(() => {}); // 保留窗口,方便复查;如需关闭可取消下一行注释 // await closeBrowser(browserId).catch(() => {}); } } main();