420 lines
19 KiB
JavaScript
420 lines
19 KiB
JavaScript
/**
|
||
* @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();
|