Files
go_bt/tools/dump_aistudio_dom.js
T
2026-09-24 10:57:31 +08:00

420 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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 <browserId>
* node tools/dump_aistudio_dom.js <browserId> --create
*
* 【前置条件】
* 1. CloakBrowser Manager 已启动(默认本地接口 127.0.0.1:8722)
* 2. <browserId> 对应的窗口里 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<string[]>} 已打开窗口 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 里打开目标环境,或显式传入 <browserId>。');
process.exit(1);
}
console.error(`探测到多个已打开窗口,请显式指定其一:\n ${openIds.join('\n ')}`);
console.error('用法: node tools/dump_aistudio_dom.js <browserId> [--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();