PENDING_PROJECT_MAX_AGE_MS) {
+ return { projectId: '', projectName: '', savedAt: 0, ageMs: 0 };
+ }
+ return { projectId, projectName, savedAt, ageMs };
+ } catch (_) {
+ return { projectId: '', projectName: '', savedAt: 0, ageMs: 0 };
+ }
+}
+
+function parsePendingProject(raw, now = Date.now()) {
+ return parsePendingProjectRecord(raw, now).projectId;
+}
+
+async function loadPendingProjectRecord(dbWrite, email) {
+ const raw = await dbWrite('getSetting', [pendingProjectSettingKey(email), '']);
+ const parsed = parsePendingProjectRecord(raw);
+ if (String(raw || '').trim() && !parsed.projectId) {
+ await dbWrite('setSetting', [
+ pendingProjectSettingKey(email),
+ '',
+ 'AI Studio 等待同步的 Google Cloud project ID(过期记录已清理)',
+ ]);
+ return { ...parsed, expiredCleared: true };
+ }
+ return parsed;
+}
+
+async function savePendingProjectId(dbWrite, email, projectId, projectName = '') {
+ const normalized = String(projectId || '').trim();
+ if (!normalized) return 0;
+ const now = Date.now();
+ // 同一个项目多次延迟/跨批次恢复时保留最初创建时间,不能每次都把 savedAt
+ // 重置为现在,否则一个实际不存在的旧 ID 会永远达不到同步保护期。
+ let savedAt = now;
+ let normalizedProjectName = String(projectName || '').trim();
+ try {
+ const currentRaw = await dbWrite('getSetting', [pendingProjectSettingKey(email), '']);
+ const current = parsePendingProjectRecord(currentRaw, now);
+ if (current.projectId === normalized && current.savedAt) {
+ savedAt = current.savedAt;
+ if (!normalizedProjectName) normalizedProjectName = current.projectName;
+ }
+ } catch (_) {}
+ await dbWrite('setSetting', [
+ pendingProjectSettingKey(email),
+ serializePendingProject(normalized, savedAt, normalizedProjectName),
+ 'AI Studio 等待同步的 Google Cloud project ID(10分钟内跨批次复用)',
+ ]);
+ return savedAt;
+}
+
+async function clearPendingProjectId(dbWrite, email) {
+ await dbWrite('setSetting', [
+ pendingProjectSettingKey(email),
+ '',
+ 'AI Studio 等待同步的 Google Cloud project ID(已完成或已过期)',
+ ]);
+}
+
+/**
+ * @param {import('../context')} ctx
+ */
+async function aistudioApiKeyStep(ctx) {
+ const { account, page, log, dbWrite, updateMessage } = ctx;
+ log('[步骤] 处理 AI Studio API Key...');
+ await updateMessage(account.email, '正在处理 AI Studio API Key...');
+
+ // 待同步 project ID 不能只存在本轮 Worker 内存中。跨批次或服务重启后若丢失,
+ // Import 尚未展示候选时会误以为账号没有项目,再创建一个重复项目。
+ if (!String(account._aistudioPreferredProjectId || '').trim()) {
+ try {
+ const persistedProject = await loadPendingProjectRecord(dbWrite, account.email);
+ if (persistedProject.projectId) {
+ account._aistudioPreferredProjectId = persistedProject.projectId;
+ account._aistudioPreferredProjectName = persistedProject.projectName;
+ account._aistudioPreferredProjectSavedAt = persistedProject.savedAt;
+ log(`[AIStudio] 已恢复本邮箱上轮待同步 Cloud 项目 ${persistedProject.projectName || persistedProject.projectId}(记录年龄 ${Math.round(persistedProject.ageMs / 1000)} 秒),本轮仅精确核验一次`);
+ } else if (persistedProject.expiredCleared) {
+ log('[AIStudio] 已清理本邮箱超过10分钟的待同步项目记录,本轮按当前页面实际项目状态处理');
+ }
+ } catch (dbError) {
+ log(`[AIStudio] ⚠️ 读取待同步项目记录失败:${dbError && dbError.message ? dbError.message : dbError}`);
+ }
+ }
+
+ let res;
+ try {
+ res = await processAistudioApiKey(page, account, log, {
+ projectName: 'Gemini Project',
+ keyName: 'Gemini API Key',
+ preferredProjectId: account._aistudioPreferredProjectId || '',
+ preferredProjectName: account._aistudioPreferredProjectName || '',
+ allowMissingPreferredProjectRecovery: !!String(account._aistudioPreferredProjectId || '').trim()
+ && !!Number(account._aistudioPreferredProjectSavedAt)
+ && (Date.now() - Number(account._aistudioPreferredProjectSavedAt)) >= PENDING_PROJECT_RECOVERY_MIN_AGE_MS,
+ });
+ } catch (error) {
+ res = { ok: false, data: null, msg: error && error.message ? error.message : 'AI Studio API Key 处理异常' };
+ }
+
+ // 服务层已用“推荐列表 + 精确搜索”确认旧项目不存在。必须立即清除跨批次
+ // 持久化记录和旧项目已消耗的等待次数;否则新项目创建失败/待同步后,下一轮
+ // 仍会回到不存在的旧 project ID,或沿用旧的4次额度直接判失败。
+ if (res && String(res.clearedPreferredProjectId || '').trim()) {
+ const clearedProjectId = String(res.clearedPreferredProjectId).trim();
+ try {
+ await clearPendingProjectId(dbWrite, account.email);
+ } catch (dbError) {
+ log(`[AIStudio] ⚠️ 清除失效项目记录失败:${dbError && dbError.message ? dbError.message : dbError}`);
+ }
+ account._aistudioPreferredProjectId = '';
+ account._aistudioPreferredProjectName = '';
+ account._aistudioPreferredProjectSavedAt = 0;
+ account._aistudioProjectSyncWaits = 0;
+ log(`[AIStudio] 已清除失效 Cloud 项目 ${clearedProjectId} 及旧的同步等待次数`);
+ }
+
+ if (res && res.ok && res.data && res.data.apiKey) {
+ try {
+ await dbWrite('upsertAistudioApiKey', [account.email, res.data]);
+ } catch (dbError) {
+ log(`[AIStudio] ⚠️ 写库失败:${dbError && dbError.message ? dbError.message : dbError}`);
+ }
+ ctx.results.aistudioApiKey = { success: true, data: res.data };
+ try {
+ await clearPendingProjectId(dbWrite, account.email);
+ } catch (dbError) {
+ log(`[AIStudio] ⚠️ 清除待同步项目记录失败:${dbError && dbError.message ? dbError.message : dbError}`);
+ }
+ await updateMessage(account.email, '✅ AI Studio API Key 已生成');
+ return;
+ }
+
+ // Cloud 项目创建成功后,Google 可能暂未把它同步到 AI Studio 的
+ // Import projects 列表。第一次遇到该状态时不判失败:交还浏览器、鼠标和
+ // AI Studio 高风险并发槽,由批量调度器延迟重新入队,且只补跑本步骤。
+ if (res && res.projectSyncPending) {
+ const waited = Math.max(0, Number(account._aistudioProjectSyncWaits) || 0);
+ if (res.deletedExisting) {
+ try {
+ await dbWrite('deleteAistudioApiKey', [account.email]);
+ log('[AIStudio] 已删除线上旧 key,项目同步等待期间已同步清除数据库旧 key');
+ res.deletedExisting = false;
+ } catch (dbError) {
+ log(`[AIStudio] ⚠️ 清除数据库旧 key 失败:${dbError && dbError.message ? dbError.message : dbError}`);
+ }
+ }
+ if (waited < MAX_PROJECT_SYNC_DEFERS) {
+ const delayMs = Math.max(1000, Number(res.retryAfterMs) || (30 * 1000));
+ const preferredProjectId = String(res.preferredProjectId || account._aistudioPreferredProjectId || '').trim();
+ const preferredProjectName = String(res.preferredProjectName || account._aistudioPreferredProjectName || '').trim();
+ try {
+ account._aistudioPreferredProjectSavedAt = await savePendingProjectId(
+ dbWrite,
+ account.email,
+ preferredProjectId,
+ preferredProjectName,
+ );
+ } catch (dbError) {
+ log(`[AIStudio] ⚠️ 保存待同步项目记录失败:${dbError && dbError.message ? dbError.message : dbError}`);
+ }
+ account._aistudioProjectSyncWaits = waited + 1;
+ account._aistudioPreferredProjectId = preferredProjectId;
+ account._aistudioPreferredProjectName = preferredProjectName;
+ ctx.defer = {
+ type: 'aistudio_project_sync',
+ delayMs,
+ preferredProjectId,
+ preferredProjectName,
+ preferredProjectSavedAt: account._aistudioPreferredProjectSavedAt,
+ waitCount: waited + 1,
+ };
+ ctx.results.aistudioApiKey = { success: false, deferred: true, msg: res.msg };
+ log(`[AIStudio] 账号已挂起 ${Math.round(delayMs / 1000)} 秒并释放队列,届时只重试项目导入和创建 Key`);
+ await updateMessage(account.email, 'Cloud 项目同步中,已挂起 30 秒;等待期间不占用并发队列');
+ return;
+ }
+ res.msg = `Cloud 项目经过 ${MAX_PROJECT_SYNC_DEFERS} 轮30秒非阻塞重试后仍无法导入或选择:${res.msg}`;
+ res.projectSyncPending = false;
+ }
+
+ const msg = (res && res.msg) || 'API Key 处理失败';
+ // 本次已删除线上旧 key 但重建失败 → 线上已无有效 key,同步清掉数据库里那条旧 key,
+ // 避免账号列表残留一枚早已失效的 key(用户要求:删除重建失败要同步删库)。
+ if (res && res.deletedExisting) {
+ try {
+ await dbWrite('deleteAistudioApiKey', [account.email]);
+ log('[AIStudio] 已删除线上旧 key 但重建失败,已同步清除数据库中的旧 key');
+ } catch (dbError) {
+ log(`[AIStudio] ⚠️ 清除数据库旧 key 失败:${dbError && dbError.message ? dbError.message : dbError}`);
+ }
+ }
+ ctx.results.aistudioApiKey = { success: false, msg };
+ ctx.failure = { step: 'aistudioApiKeyStep', msg, meta: null };
+ ctx.shouldStop = true;
+ await updateMessage(account.email, `AI Studio API Key 失败:${msg}`).catch(() => {});
+ try {
+ const sp = await saveErrorScreenshot(page, account.email, 'aistudio_api_key');
+ if (sp) await dbWrite('updateAccountScreenshot', [account.email, sp]);
+ } catch (_) {}
+}
+
+module.exports = aistudioApiKeyStep;
+module.exports._private = {
+ PENDING_PROJECT_MAX_AGE_MS,
+ PENDING_PROJECT_RECOVERY_MIN_AGE_MS,
+ MAX_PROJECT_SYNC_DEFERS,
+ pendingProjectSettingKey,
+ serializePendingProject,
+ parsePendingProject,
+ parsePendingProjectRecord,
+};
diff --git a/src/pipeline/steps/aistudio_sync_key.js b/src/pipeline/steps/aistudio_sync_key.js
new file mode 100644
index 0000000..a1fd131
--- /dev/null
+++ b/src/pipeline/steps/aistudio_sync_key.js
@@ -0,0 +1,48 @@
+/**
+ * @file pipeline/steps/aistudio_sync_key.js
+ * @brief 读取用户已在浏览器中人工创建的 AI Studio API Key,写入数据库并将流程日志改为成功。
+ */
+
+'use strict';
+
+const { syncExistingAistudioApiKey } = require('../../services/aistudio_api_key_service');
+const { saveErrorScreenshot } = require('../../utils/screenshot');
+
+async function aistudioSyncKeyStep(ctx) {
+ const { account, page, log, dbWrite, updateMessage } = ctx;
+ log('[步骤] 同步人工创建的 AI Studio API Key...');
+ await updateMessage(account.email, '正在同步人工创建的 AI Studio API Key...');
+
+ const result = await syncExistingAistudioApiKey(page, account, log, {
+ keyName: 'Gemini API Key',
+ });
+ if (result && result.ok && result.data && result.data.apiKey) {
+ await dbWrite('upsertAistudioApiKey', [account.email, result.data]);
+ await dbWrite('setSetting', [
+ `aistudio_pending_project:${String(account.email || '').trim().toLowerCase()}`,
+ '',
+ 'AI Studio 等待同步的 Google Cloud project ID(人工同步 Key 成功后清除)',
+ ]);
+ await dbWrite('addAistudioFlowLog', [
+ account.email,
+ 'success',
+ '',
+ '人工创建的 AI Studio API Key 已同步',
+ ]);
+ ctx.results.aistudioSyncKey = { success: true, data: result.data };
+ await updateMessage(account.email, '✅ 人工创建的 AI Studio API Key 已同步');
+ return;
+ }
+
+ const msg = (result && result.msg) || '同步人工创建的 API Key 失败';
+ ctx.results.aistudioSyncKey = { success: false, msg };
+ ctx.failure = { step: 'aistudioSyncKeyStep', msg, meta: null };
+ ctx.shouldStop = true;
+ await updateMessage(account.email, `同步人工 AI Studio Key 失败:${msg}`).catch(() => {});
+ try {
+ const screenshot = await saveErrorScreenshot(page, account.email, 'aistudio_sync_key');
+ if (screenshot) await dbWrite('updateAccountScreenshot', [account.email, screenshot]);
+ } catch (_) {}
+}
+
+module.exports = aistudioSyncKeyStep;
diff --git a/src/pipeline/steps/change_2fa.js b/src/pipeline/steps/change_2fa.js
index 80a07f9..85f9c70 100644
--- a/src/pipeline/steps/change_2fa.js
+++ b/src/pipeline/steps/change_2fa.js
@@ -31,9 +31,12 @@ async function publish2faSuccess(ctx, latestAccount, fallbackStatus, message) {
* @param {import('../context')} ctx
*/
async function change2faStep(ctx) {
- const skipCompleted = ctx.taskType === 'security_chatgpt'
+ // aistudio_combo:失败重跑时,若上次已成功改 2FA(fa_changed=1)则跳过,
+ // 只补跑后面失败的步骤。与改密码、16 位应用密码的"已完成即跳过"一致。
+ const skipCompleted = (ctx.taskType === 'security_chatgpt'
&& ctx.params
- && ctx.params.skipCompletedSecuritySteps === true;
+ && ctx.params.skipCompletedSecuritySteps === true)
+ || ctx.taskType === 'aistudio_combo';
if (skipCompleted) {
const latestAccount = await ctx.dbWrite('getAccount', [ctx.account.email]);
if (!latestAccount) {
diff --git a/src/pipeline/steps/change_language.js b/src/pipeline/steps/change_language.js
index 923688b..9467544 100644
--- a/src/pipeline/steps/change_language.js
+++ b/src/pipeline/steps/change_language.js
@@ -24,6 +24,15 @@ async function changeLanguageStep(ctx) {
if (ok) {
await ctx.updateMessage(ctx.account.email, `✅ ${msg}`);
} else {
+ // 切换成英文只用于提高后续页面识别稳定性,并不是 Google/AI Studio 的业务前置条件。
+ // 单独执行“改语言”任务时仍应如实失败;组合任务则记录警告并继续,让真正的业务步骤决定结果。
+ if (ctx.taskType !== 'change_language') {
+ ctx.results.changeLanguage.warning = true;
+ ctx.log(`[改语言] ⚠️ 切换失败但不是强制前置条件,继续后续流程: ${msg}`);
+ await ctx.updateMessage(ctx.account.email, `⚠️ 改语言未完成,继续处理: ${msg}`);
+ return;
+ }
+
const meta = classifyFailure({
url: ctx.page && ctx.page.url ? ctx.page.url() : '',
message: msg,
diff --git a/src/pipeline/steps/change_password.js b/src/pipeline/steps/change_password.js
index 3a203f3..feba49a 100644
--- a/src/pipeline/steps/change_password.js
+++ b/src/pipeline/steps/change_password.js
@@ -47,9 +47,12 @@ async function changePasswordStep(ctx) {
return;
}
- const skipCompleted = ctx.taskType === 'security_chatgpt'
+ // aistudio_combo:失败重跑时,若上次已成功改密(password_changed=1)则跳过,
+ // 只补跑后面失败的步骤,避免反复重置密码。与 16 位应用密码"已存在即跳过"同一套路。
+ const skipCompleted = (ctx.taskType === 'security_chatgpt'
&& ctx.params
- && ctx.params.skipCompletedSecuritySteps === true;
+ && ctx.params.skipCompletedSecuritySteps === true)
+ || ctx.taskType === 'aistudio_combo';
if (skipCompleted) {
const latestAccount = await ctx.dbWrite('getAccount', [ctx.account.email]);
if (!latestAccount) {
@@ -93,7 +96,7 @@ async function changePasswordStep(ctx) {
ctx,
latestAccount,
'改密成功',
- `✅ 密码更改成功,新密码:${newPassword}`,
+ '✅ 密码更改成功,新密码已安全保存',
);
// 让后续需要重新验证身份的流水线步骤使用新密码。
ctx.account.password = newPassword;
diff --git a/src/pipeline/steps/login.js b/src/pipeline/steps/login.js
index fbcc998..5d2a090 100644
--- a/src/pipeline/steps/login.js
+++ b/src/pipeline/steps/login.js
@@ -84,6 +84,38 @@ async function loginStep(ctx) {
await ctx.dbWrite('updateAccountFlag', [ctx.account.email, 'subscription_checked', 1]);
await ctx.updateStatus(ctx.account.email, subscribed ? '订阅成功' : '登录成功');
await ctx.updateMessage(ctx.account.email, subscribed ? '✅ 登录成功:已订阅 Google One' : `✅ 登录成功: ${msg}`);
+
+ // 检测账号是否有 Google AI Pro/Ultra 付费档,写入 is_pro(NULL 未检测 / 0 非 / 1 是)。
+ // 判据(语言无关、避开推广位):左侧导航(nav/[role=navigation])里存在指向 one.google.com 的订阅项,
+ // 且文本含 "Google AI Pro"/"Google AI Ultra"。升级推广("Get Google AI Pro")在主内容区、不在左侧导航,
+ // 普通 Google One 存储项文本是 "Google One" 也不会命中,故不会把非 Pro 误判为 Pro。
+ let isPro = false;
+ try {
+ isPro = await ctx.page.evaluate(() => {
+ const scope = document.querySelector('nav, [role="navigation"]') || document.body;
+ for (const a of [...scope.querySelectorAll('a[href]')]) {
+ const text = (a.innerText || a.textContent || '').replace(/\s+/g, ' ').trim();
+ const href = a.getAttribute('href') || '';
+ if (/Google AI (Pro|Ultra)/i.test(text) && /one\.google\.com/i.test(href)) return true;
+ }
+ return false;
+ }).catch(() => false);
+ } catch (_) {}
+ try { await ctx.dbWrite('updateAccountFlag', [ctx.account.email, 'is_pro', isPro ? 1 : 0]); } catch (_) {}
+ ctx.results.login.isPro = isPro;
+ ctx.log(`[Pro检测] is_pro=${isPro ? 1 : 0}`);
+ // 未判为 Pro 时补打左侧导航项,便于日后核对非 Pro 账号的真实结构(发现遗漏可据此校正)。
+ if (!isPro) {
+ try {
+ const navTexts = await ctx.page.evaluate(() => {
+ const scope = document.querySelector('nav, [role="navigation"]') || document.body;
+ return [...scope.querySelectorAll('a[href]')].slice(0, 20)
+ .map(a => `${(a.innerText || a.textContent || '').replace(/\s+/g, ' ').trim()} | ${(a.getAttribute('href') || '').slice(0, 60)}`)
+ .filter(Boolean);
+ }).catch(() => []);
+ ctx.log(`[Pro检测] 非Pro,左侧导航: ${JSON.stringify(navTexts)}`);
+ } catch (_) {}
+ }
}
module.exports = loginStep;
diff --git a/src/routes/operations.js b/src/routes/operations.js
index fb18595..57bb95b 100644
--- a/src/routes/operations.js
+++ b/src/routes/operations.js
@@ -25,6 +25,9 @@ const LOCAL_CREDENTIAL_TASKS = new Set([
'workspace_refund',
'google_account_ag_credential',
'gemini_cli_get_credential',
+ 'aistudio_combo',
+ 'aistudio_sync_key',
+ 'open_manual_aistudio',
]);
const SOFT_STOP_TASKS = new Set(['card_pay_recharge', 'workspace_refund']);
@@ -84,11 +87,15 @@ const DB_FUNCTIONS = {
updateAccountFlag: (args) => db.updateAccountFlag(...args),
getAccount: (args) => db.getAccount(...args),
getSetting: (args) => db.getSetting(...args),
+ setSetting: (args) => db.setSetting(...args),
assignStaticProxy: (args) => db.assignStaticProxy(...args),
updateAccountAgToken: (args) => db.updateAccountAgToken(...args),
updateAccountAgStatus: (args) => db.updateAccountAgStatus(...args),
upsertGmailAppPassword: (args) => db.upsertGmailAppPassword(...args),
hasGmailAppPassword: (args) => db.hasGmailAppPassword(...args),
+ upsertAistudioApiKey: (args) => db.upsertAistudioApiKey(...args),
+ deleteAistudioApiKey: (args) => db.deleteAistudioApiKey(...args),
+ addAistudioFlowLog: (args) => db.addAistudioFlowLog(...args),
updateAccountCliAcCredential: (args) => db.updateAccountCliAcCredential(...args),
updateAccountRecoveryEmail: (args) => db.updateAccountRecoveryEmail(...args),
updateAccountRestrictionStatus: (args) => db.updateAccountRestrictionStatus(...args),
@@ -137,6 +144,15 @@ router.post('/run', (req, res) => {
if (taskType === 'gemini_cli_get_credential' && !isLocalRequest(req)) {
return res.status(403).json({ success: false, msg: '获取 Cli 凭证任务仅允许在服务器本机执行' });
}
+ if (taskType === 'aistudio_combo' && !isLocalRequest(req)) {
+ return res.status(403).json({ success: false, msg: 'AI Studio 一键组合任务仅允许在服务器本机执行' });
+ }
+ if (taskType === 'aistudio_sync_key' && !isLocalRequest(req)) {
+ return res.status(403).json({ success: false, msg: '同步人工 AI Studio Key 仅允许在服务器本机执行' });
+ }
+ if (taskType === 'open_manual_aistudio' && !isLocalRequest(req)) {
+ return res.status(403).json({ success: false, msg: '打开浏览器并监听人工 AI Studio Key 仅允许在服务器本机执行' });
+ }
if (LOCAL_CREDENTIAL_TASKS.has(taskType)
&& !String(req.get('content-type') || '').toLowerCase().startsWith('application/json')) {
return res.status(415).json({ success: false, msg: '该任务仅接受 JSON 请求' });
@@ -242,6 +258,7 @@ router.post('/run', (req, res) => {
const worker = new Worker(TASK_RUNNER_PATH, {
workerData: { taskType, accounts, params: normalizedParams, taskId },
});
+ let doneReceived = false;
// Card keys are needed by the Worker only. Do not retain another copy in
// the long-lived main-thread task registry.
@@ -326,6 +343,34 @@ router.post('/run', (req, res) => {
gmail_app_password: saved ? saved.app_password : null,
});
}
+ if (!error && msg.fn === 'upsertAistudioApiKey' && msg.args && msg.args[0]) {
+ const saved = db.getAistudioApiKey(msg.args[0]);
+ sseBroadcast(taskId, {
+ type: 'account_update',
+ email: msg.args[0],
+ aistudio_api_key: saved ? saved.api_key : null,
+ aistudio_api_key_json: saved ? (saved.raw_json || null) : null,
+ });
+ }
+ if (!error && msg.fn === 'deleteAistudioApiKey' && msg.args && msg.args[0]) {
+ // 流程删除了线上 key 但重建失败 → 清库后同步把前端该单元格清空。
+ sseBroadcast(taskId, {
+ type: 'account_update',
+ email: msg.args[0],
+ aistudio_api_key: null,
+ aistudio_api_key_json: null,
+ });
+ }
+ if (!error && msg.fn === 'addAistudioFlowLog' && msg.args && msg.args[0]) {
+ sseBroadcast(taskId, {
+ type: 'account_update',
+ email: msg.args[0],
+ aistudio_flow_status: msg.args[1] || null,
+ aistudio_flow_failed_step: msg.args[2] || null,
+ aistudio_flow_detail: msg.args[3] || null,
+ aistudio_flow_at: new Date().toISOString(),
+ });
+ }
if (!error
&& (msg.fn === 'updateAccountOpenAIPlan' || msg.fn === 'updateAccountOpenAIPlanStatus')
&& msg.args && msg.args[0]) {
@@ -402,6 +447,16 @@ router.post('/run', (req, res) => {
});
}
}
+ if (!error && msg.fn === 'updateAccountFlag' && msg.args && msg.args[0] && msg.args[1] === 'is_pro') {
+ const account = db.getAccount(msg.args[0]);
+ if (account) {
+ sseBroadcast(taskId, {
+ type: 'account_update',
+ email: msg.args[0],
+ is_pro: account.is_pro,
+ });
+ }
+ }
if (!error && msg.fn === 'updateAccountSecurityDates' && msg.args && msg.args[0]) {
sseBroadcast(taskId, {
type: 'account_update',
@@ -454,6 +509,7 @@ router.post('/run', (req, res) => {
} else if (msg.type === 'progress') {
sseProgress(taskId, msg.current, msg.total);
} else if (msg.type === 'done') {
+ doneReceived = true;
const t = activeTasks.get(taskId);
if (msg.error && taskType === 'card_pay_recharge') {
markRechargeInterrupted(taskId, accounts);
@@ -461,6 +517,14 @@ router.post('/run', (req, res) => {
sseDone(taskId, { taskType: (t && t.taskType) ? t.taskType : taskType, success: !msg.error, error: msg.error });
activeTasks.delete(taskId);
refundLease?.release();
+ if (taskType === 'open_manual_aistudio') {
+ // 监听 Worker 保留 connectOverCDP 连接是为了不调用 browser.close(),
+ // 后者会关掉用户正在操作的比特浏览器。成功写库与 done
+ // 已全部送达后终止 Worker,仅断开 CDP 监听端,保留浏览器窗口。
+ setImmediate(() => {
+ Promise.resolve(worker.terminate()).catch(() => {});
+ });
+ }
}
});
@@ -478,7 +542,7 @@ router.post('/run', (req, res) => {
});
worker.on('exit', (code) => {
- if (code !== 0) {
+ if (code !== 0 && !doneReceived) {
if (taskType === 'card_pay_recharge') markRechargeInterrupted(taskId, accounts);
sseLog(taskId, `[任务] Worker 异常退出: ${code}`);
sseDone(taskId, { taskType, success: false, error: `Exit code ${code}` });
@@ -652,4 +716,32 @@ router.get('/tasks', (req, res) => {
tasks.sort((a, b) => String(b.startedAt || '').localeCompare(String(a.startedAt || '')));
res.json({ success: true, data: tasks });
});
+
+/**
+ * GET /api/operations/aistudio-api-keys?email=...
+ * 只读取指定邮箱的 AI Studio API Key(明文,供该账号行内 JSON 面板展示)。
+ */
+router.get('/aistudio-api-keys', (req, res) => {
+ try {
+ const email = String(req.query.email || '').trim();
+ if (!email) return res.status(400).json({ success: false, msg: '缺少邮箱' });
+ if (!db.getAccount(email)) return res.status(404).json({ success: false, msg: '账号不存在' });
+ return res.json({ success: true, data: db.getAistudioApiKey(email) });
+ } catch (e) {
+ return res.status(500).json({ success: false, msg: e && e.message ? e.message : '读取失败' });
+ }
+});
+
+/**
+ * GET /api/operations/aistudio-flow-logs
+ * 读取 AI Studio 一键组合流程日志(success / failed + 失败步骤名)
+ */
+router.get('/aistudio-flow-logs', (req, res) => {
+ try {
+ res.json({ success: true, data: db.getAistudioFlowLogs(Number(req.query.limit) || 200) });
+ } catch (e) {
+ res.status(500).json({ success: false, msg: e && e.message ? e.message : '读取失败' });
+ }
+});
+
module.exports = router;
diff --git a/src/services/aistudio_api_key_service.js b/src/services/aistudio_api_key_service.js
new file mode 100644
index 0000000..1ea6ad0
--- /dev/null
+++ b/src/services/aistudio_api_key_service.js
@@ -0,0 +1,3074 @@
+/**
+ * @file services/aistudio_api_key_service.js
+ * @brief 在已登录的 Google 会话中处理 Google AI Studio 的 API Key:
+ * 删除该邮箱下已有的全部 key,再新建一枚,读回 4 个字段。
+ *
+ * 【语言无关定位策略】
+ * AI Studio 是 Angular Material 应用,按账号语言本地化(英/葡混用)。
+ * 本模块禁止以文字匹配为主,改用:role/aria + Material 图标 ligature +
+ * 对话框内元素位置(Cancel 一般在左/首,危险或确认动作在右/末)+ 读值正则。
+ * 文字候选仅作为兜底。选择器已按方案第 5 节实现,待 DOM 抓取结果最终校验。
+ */
+
+'use strict';
+
+const { osClickElement, isWindows, dismissBrowserChromePopup } = require('../utils/os_mouse');
+const { redactText } = require('../utils/diagnostics');
+
+const API_KEYS_URL = 'https://aistudio.google.com/api-keys';
+const CLOUD_PROJECT_CREATE_URL = 'https://console.cloud.google.com/projectcreate';
+const DEFAULT_PROJECT_NAME = 'Gemini Project';
+const DEFAULT_KEY_NAME = 'Gemini API Key';
+const ADDITIONAL_CONSENT_TEXT_RE = /Google AI plans|Gemini API Additional Terms of Service|Go Further[,.]?\s*Build Smarter|Google Privacy Policy|AI 计划|附加服务条款/i;
+
+function isAdditionalConsentText(text) {
+ return ADDITIONAL_CONSENT_TEXT_RE.test(String(text || ''));
+}
+const DEFAULT_TIMEOUT_MS = 60000;
+const MAX_DELETE_LOOP = 50;
+const INITIAL_CONSENT_WAIT_MS = 2500;
+const MAX_AISTUDIO_MUTATIONS = 2;
+const PROJECT_SYNC_RETRY_DELAY_MS = 30 * 1000;
+
+/** Google Cloud 项目显示名:4-30 位英文、数字、空格、单引号、连字符或感叹号。 */
+function isValidCloudProjectDisplayName(value) {
+ return /^[A-Za-z0-9' !-]{4,30}$/.test(String(value || '').trim());
+}
+
+// AI Studio 的项目/Key RPC 在 4 个账号同时操作时会集中返回 403。只限制从鼠标
+// 校准到 Key 读取这一段高风险区;登录、改密、2FA、页面预加载仍可保持并发 4。
+let activeAistudioMutations = 0;
+const aistudioMutationWaiters = [];
+
+async function acquireAistudioMutationSlot(log = () => {}) {
+ if (activeAistudioMutations >= MAX_AISTUDIO_MUTATIONS) {
+ log(`[AIStudio] 高风险操作并发已满,排队等待(前方 ${aistudioMutationWaiters.length + 1} 个)...`);
+ await new Promise(resolve => aistudioMutationWaiters.push(resolve));
+ log('[AIStudio] 排队结束,开始项目/Key 操作');
+ }
+ activeAistudioMutations++;
+ let released = false;
+ return () => {
+ if (released) return;
+ released = true;
+ activeAistudioMutations--;
+ const next = aistudioMutationWaiters.shift();
+ if (next) next();
+ };
+}
+
+async function withAistudioMutationSlot(operation, log = () => {}) {
+ const release = await acquireAistudioMutationSlot(log);
+ try {
+ return await operation();
+ } finally {
+ release();
+ }
+}
+
+function sleep(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+function pendingCloudProjectSync(projectId, msg = '', projectName = '') {
+ const normalizedProjectId = String(projectId || '').trim();
+ return {
+ handled: true,
+ ok: false,
+ created: true,
+ projectSyncPending: true,
+ retryAfterMs: PROJECT_SYNC_RETRY_DELAY_MS,
+ preferredProjectId: normalizedProjectId,
+ preferredProjectName: String(projectName || '').trim(),
+ msg: msg || 'Cloud 项目已创建,等待 Google 同步到 AI Studio 导入列表',
+ };
+}
+
+/**
+ * 从一组候选选择器里返回第一个可见的 locator。
+ * @param {import('playwright').Page|import('playwright').Locator} scope
+ * @param {string[]} selectors
+ * @returns {Promise
}
+ */
+async function firstVisible(scope, selectors) {
+ for (const selector of selectors) {
+ const matches = scope.locator(selector);
+ const count = await matches.count().catch(() => 0);
+ for (let index = 0; index < count; index++) {
+ const locator = matches.nth(index);
+ if (await locator.isVisible({ timeout: 500 }).catch(() => false)) return locator;
+ }
+ }
+ return null;
+}
+
+/**
+ * 拟人点击:滚动到可见 → 分步移动鼠标到元素上 → 悬停片刻 → 带随机延迟的真实点击。
+ * 【为何需要】AI Studio 对自动化有风控:合成点击/太快的点击会被判为
+ * 「The request is suspicious. Please try again.」(HTTP 403),删除/创建随之失败;
+ * 而真人在同一窗口手点却成功。用真实鼠标轨迹 + 悬停 + 延迟让点击更像真人以绕过该检测。
+ * @param {import('playwright').Locator} target
+ * @param {import('playwright').Page} page
+ */
+async function humanClick(target, page) {
+ if (!target) return false;
+ try {
+ await target.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
+ const box = await target.boundingBox().catch(() => null);
+ if (box && page && page.mouse) {
+ const x = box.x + box.width / 2 + (Math.random() * 6 - 3);
+ const y = box.y + box.height / 2 + (Math.random() * 4 - 2);
+ await page.mouse.move(x, y, { steps: 12 }).catch(() => {});
+ await sleep(160 + Math.floor(Math.random() * 220));
+ }
+ await target.hover({ timeout: 3000 }).catch(() => {});
+ await sleep(120 + Math.floor(Math.random() * 160));
+ await target.click({ timeout: 5000, delay: 40 + Math.floor(Math.random() * 70) });
+ return true;
+ } catch (_) {
+ try { await target.click({ timeout: 5000 }); return true; } catch (e) { return false; }
+ }
+}
+
+/**
+ * 触发 botguard RPC 的“危险点击”(删除确认 DeleteCloudApiKey / 创建生成 GenerateCloudApiKey)专用点击。
+ * 优先用真实 OS 光标(osClickElement)通过反爬;非 Windows / 坐标未校准 / 出错时回退 humanClick。
+ * 一旦 page.__osDisabled === true(上层校准失败后置位),本次运行不再尝试 OS 点击,直接 humanClick。
+ * @returns {Promise<'os'|'human'|false>} 实际使用的点击方式,供上层做坐标校准判定。
+ */
+async function trustedClick(page, target, log = () => {}) {
+ if (!target) return false;
+ if (!page || page.__osDisabled !== true) {
+ const okOs = await osClickElement(page, target, log);
+ if (okOs) return 'os';
+ }
+ const okHuman = await humanClick(target, page);
+ return okHuman ? 'human' : false;
+}
+
+/**
+ * 拟人输入:先真实点击聚焦,清空原值,再逐字符敲键(trusted keydown/input/keyup)。
+ * 【创建 403 根因】此前用 fill() 一次性写入 key 名,输入框从无真实击键,AI Studio 对
+ * “生成凭证”风控极严,会把“表单提交但名字框无真实键入”判为机器人(The request is suspicious)。
+ * 删除流程无输入框故不受影响;创建有名字框故必须模拟真人逐字符输入。
+ */
+async function humanType(target, page, text) {
+ if (!target) return false;
+ const value = String(text == null ? '' : text);
+ try {
+ await humanClick(target, page);
+ await sleep(120 + Math.floor(Math.random() * 120));
+ await target.press('Control+a').catch(() => {});
+ await target.press('Delete').catch(() => {});
+ await sleep(80 + Math.floor(Math.random() * 80));
+ await target.pressSequentially(value, { delay: 60 + Math.floor(Math.random() * 90) });
+ return true;
+ } catch (_) {
+ try { await target.fill(value); return true; } catch (e) { return false; }
+ }
+}
+
+/**
+ * 点击高风控按钮(生成 API Key)前,累积一段真实鼠标活动/停留。
+ * AI Studio 对“生成凭证”按会话行为熵评分(botguard),仅“瞬移到按钮再点”行为熵过低会被判可疑(403);
+ * 先在页面内随机移动/停留几秒,喂给风控更像真人的连续 trusted mousemove 序列。
+ */
+async function behavioralWarmup(page) {
+ try {
+ if (!page || !page.mouse) return;
+ const vp = (page.viewportSize && page.viewportSize()) || null;
+ const width = (vp && vp.width) || 1200;
+ const height = (vp && vp.height) || 800;
+ const points = 4 + Math.floor(Math.random() * 3); // 4~6 段轨迹
+ for (let i = 0; i < points; i++) {
+ const x = 120 + Math.floor(Math.random() * Math.max(1, width - 240));
+ const y = 120 + Math.floor(Math.random() * Math.max(1, height - 240));
+ await page.mouse.move(x, y, { steps: 8 + Math.floor(Math.random() * 10) }).catch(() => {});
+ await sleep(180 + Math.floor(Math.random() * 320));
+ }
+ await sleep(400 + Math.floor(Math.random() * 500));
+ } catch (_) {}
+}
+
+/** 读取当前最上层对话框(若无对话框则读 body)的文本与只读值。 */
+async function pageSnapshot(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 dialogs = [...document.querySelectorAll('[role="dialog"], mat-dialog-container, [aria-modal="true"]')]
+ .filter(visible);
+ const root = dialogs.length ? dialogs[dialogs.length - 1] : document.body;
+ const readonlyValues = [...root.querySelectorAll('input, textarea')]
+ .filter(visible)
+ .map(element => element.value || '')
+ .filter(Boolean);
+ return {
+ dialogText: root ? (root.innerText || root.textContent || '') : '',
+ bodyText: document.body ? (document.body.innerText || '') : '',
+ readonlyValues,
+ };
+ }).catch(() => ({ dialogText: '', bodyText: '', readonlyValues: [] }));
+}
+
+/**
+ * AI Studio 无权账号会被导向 Gemini API 文档的 Available regions 页,
+ * 该页没有 Key 列表和创建按钮。若继续按“首屏 403/按钮未渲染”处理,
+ * 会产生误导性的重载和创建重试。
+ */
+function classifyAistudioAccessBlock(url = '', text = '') {
+ const normalizedUrl = String(url || '');
+ const bodyText = String(text || '');
+ const redirectedToRegions = /(?:\/|%2F)available-regions(?:[/?#]|$)/i.test(normalizedUrl);
+ const regionsPage = /Available regions for Google AI Studio and Gemini API/i.test(bodyText)
+ || (/Regional restrictions/i.test(bodyText)
+ && /Age requirements/i.test(bodyText)
+ && /Account verification/i.test(bodyText));
+ if (!redirectedToRegions && !regionsPage) return null;
+ return {
+ code: 'AI_STUDIO_ACCESS_RESTRICTED',
+ msg: 'Google AI Studio 访问受限(地区、年龄或账号验证条件不满足)',
+ };
+}
+
+/** 统计列表页里“每行更多操作(⋮ more_vert)”按钮个数,等价于现存 key 行数。 */
+async function countRowMenuButtons(page) {
+ return page.evaluate(() => {
+ const isVisible = 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 iconText = element => (element.getAttribute('fonticon') || element.textContent || '').trim();
+ const buttons = [...document.querySelectorAll('button, [role="button"]')].filter(isVisible);
+ let count = 0;
+ for (const button of buttons) {
+ const icons = [...button.querySelectorAll('mat-icon, .material-icons, .material-symbols-outlined, [fonticon]')];
+ const row = button.closest('tr, mat-row, [role="row"], ms-api-key-row, ms-table-row');
+ if (!row || !row.querySelector('td, mat-cell, [role="cell"]')) continue;
+ if (icons.some(icon => iconText(icon) === 'more_vert')) count++;
+ else if ((button.getAttribute('aria-haspopup') || '') === 'menu') count++;
+ }
+ // 新版列表的末列“更多”按钮有时是 SVG 图标,既没有 more_vert 文本也没有
+ // aria-haspopup。直接数真实数据行作为兜底,避免列表数据晚到后仍被误判为“无旧 Key”。
+ const rows = [...document.querySelectorAll('tbody tr, mat-row, [role="row"], ms-api-key-row, ms-table-row')]
+ .filter(isVisible)
+ .filter(row => {
+ const hasCell = !!row.querySelector('td, mat-cell, [role="cell"]');
+ if (!hasCell) return false; // 排除表头
+ return !!row.querySelector([
+ 'ms-api-key-key-string',
+ '.key-string-link',
+ '.xap-copy-to-clipboard',
+ '.cdk-column-Key',
+ '.mat-column-Key',
+ ].join(', '));
+ });
+ return Math.max(count, rows.length);
+ }).catch(() => 0);
+}
+
+/** 等 Angular 首屏渲染完成:出现“Create API key”按钮,或列表行,或空态文案。 */
+async function waitForApiKeysReady(page, timeoutMs) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ // 必须先读取访问受限页:该文档顶部的 Dismiss 也使用
+ // mdc-button--unelevated 通用样式,若先用宽泛按钮选择器,会被误当成
+ // Create API key,从而跳过 Available regions 检测。
+ const snapshot = await pageSnapshot(page);
+ const accessBlock = classifyAistudioAccessBlock(
+ String(page && page.url ? page.url() : ''),
+ snapshot.bodyText,
+ );
+ if (accessBlock) {
+ page.__aistudioAccessBlock = accessBlock;
+ return false;
+ }
+ const createBtn = await findCreateApiKeyButton(page);
+ if (createBtn) return true;
+ const rows = await countRowMenuButtons(page);
+ if (rows > 0) return true;
+ // 空态文案(英/葡),命中任一即视为就绪。
+ if (/Can't find your API keys|Cannot find your API keys|não encontr|nenhuma chave/i.test(snapshot.bodyText)) {
+ return true;
+ }
+ await sleep(500);
+ }
+ return false;
+}
+
+/** 定位工具栏“Create API key”主按钮。稳定 data-test-id 优先,文字/图标兜底。 */
+async function findCreateApiKeyButton(page) {
+ return firstVisible(page, [
+ // 真实 DOM 确认:工具栏主按钮带稳定 data-test-id(语言无关)。
+ 'button[data-test-id="create-api-key-button"]',
+ 'button:has-text("Create API key")',
+ 'button:has-text("Criar chave de API")',
+ 'button:has-text("Criar chave")',
+ '[role="button"]:has-text("Create API key")',
+ '[role="button"]:has-text("Criar chave")',
+ // 图标兜底:主区域里带 add 图标且不在对话框内的醒目按钮。
+ 'button:has(mat-icon:has-text("add"))',
+ 'button.mdc-button--unelevated',
+ 'button[mat-flat-button]',
+ ]);
+}
+
+/** 打开列表页并等待就绪。 */
+function apiKeysUrlForProject(projectId = '') {
+ const normalized = String(projectId || '').trim();
+ return normalized ? `${API_KEYS_URL}?project=${encodeURIComponent(normalized)}` : API_KEYS_URL;
+}
+
+async function gotoApiKeys(page, timeoutMs, projectId = '') {
+ page.__aistudioAccessBlock = null;
+ try {
+ await page.goto(apiKeysUrlForProject(projectId), { waitUntil: 'domcontentloaded', timeout: timeoutMs });
+ } catch (error) {
+ const currentUrl = String(page.url ? page.url() : '');
+ if (!/aistudio\.google\.com/i.test(currentUrl)) throw error;
+ }
+ await page.waitForLoadState('domcontentloaded', { timeout: 5000 }).catch(() => {});
+ const ready = await waitForApiKeysReady(page, timeoutMs);
+ return ready;
+}
+
+function extractCloudProjectId(url, text = '') {
+ try {
+ const parsed = new URL(String(url || ''));
+ const fromQuery = String(parsed.searchParams.get('project') || '').trim();
+ if (/^[a-z][a-z0-9-]{4,61}[a-z0-9]$/.test(fromQuery)) return fromQuery;
+ } catch (_) {}
+ const match = String(text || '').match(/(?:Project\s*ID|项目\s*ID|ID\s+do\s+projeto)\s*:?\s*([a-z][a-z0-9-]{4,61}[a-z0-9])/i);
+ return match ? match[1] : '';
+}
+
+function needsCloudConsoleProjectFallback(message) {
+ return /CreateCloudProject\s+HTTP\s*[4-5]\d\d|Unable to create API key|unable to create.+Cloud project|无法创建.+(?:API|Cloud).+项目|新建项目(?:对话框)?.*(?:持续加载|卡住|超时)|AI Studio 无法完成项目创建/i
+ .test(String(message || ''));
+}
+
+/**
+ * Google Cloud Console 首次使用条款。截图实测:进入 /projectcreate 后会出现 Welcome 弹窗,
+ * 需要确认国家、勾选条款并点最后的主按钮,之后才允许提交项目创建表单。
+ */
+async function acceptCloudConsoleTerms(page, log = () => {}, timeoutMs = 20000) {
+ let deadline = Date.now() + timeoutMs;
+ let sawTermsDialog = false;
+ let agreementFallbackLogged = false;
+ let confirmAttempts = 0;
+ let recoveryReloaded = false;
+ while (Date.now() < deadline) {
+ // 条款正文链接可能被坐标兜底误触并在当前标签打开 /terms。
+ // 这里不是接受条款的交互页,立即回到项目创建表单,禁止继续空等。
+ if (/console\.cloud\.google\.com\/terms(?:[/?#]|$)/i.test(String(page.url ? page.url() : ''))) {
+ log('[AIStudio] 检测到误入 Google Cloud 条款说明页,立即返回项目创建页...');
+ await page.goto(CLOUD_PROJECT_CREATE_URL, {
+ waitUntil: 'domcontentloaded',
+ timeout: 30000,
+ }).catch(() => {});
+ await page.waitForLoadState('domcontentloaded', { timeout: 5000 }).catch(() => {});
+ deadline = Math.max(deadline, Date.now() + 10000);
+ continue;
+ }
+ const state = 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;
+ };
+ document.querySelectorAll('[data-cloud-terms-dialog], [data-cloud-terms-checkbox], [data-cloud-terms-confirm]')
+ .forEach(element => {
+ element.removeAttribute('data-cloud-terms-dialog');
+ element.removeAttribute('data-cloud-terms-checkbox');
+ element.removeAttribute('data-cloud-terms-confirm');
+ });
+ const termsPattern = /terms of service|agree and continue|welcome|服务条款|同意并继续|termos de serviço/i;
+ const confirmPattern = /agree and continue|同意并继续|concordar.+continuar/i;
+ const dialogs = [...document.querySelectorAll([
+ '[role="dialog"]',
+ '[aria-modal="true"]',
+ 'mat-dialog-container',
+ '.mat-mdc-dialog-container',
+ '.mat-mdc-dialog-surface',
+ '.mdc-dialog',
+ '.mdc-dialog__surface',
+ 'cfc-dialog',
+ 'cfc-modal',
+ '[class*="dialog-container"]',
+ '[class*="modal-container"]',
+ ].join(','))]
+ .filter(visible);
+ const allButtons = [...document.querySelectorAll('button, [role="button"]')].filter(visible);
+ const globalConfirm = allButtons.find(button => confirmPattern
+ .test(button.innerText || button.textContent || ''));
+ let dialog = dialogs.find(element => termsPattern.test(element.innerText || element.textContent || ''));
+ // Google Cloud 会灰度更换弹窗容器;部分版本没有 role=dialog/cfc-dialog。
+ // 用“Agree and continue”反向寻找同时包含条款文案和复选框的可见祖先,
+ // 避免小屏幕下新版 mat-mdc/cfc 容器未命中时把底层项目表单当成可操作。
+ if (!dialog && globalConfirm) {
+ let ancestor = globalConfirm.parentElement;
+ while (ancestor && ancestor !== document.body) {
+ const text = ancestor.innerText || ancestor.textContent || '';
+ const hasCheckbox = !!ancestor.querySelector(
+ 'input[type="checkbox"], [role="checkbox"], mat-checkbox, .mat-mdc-checkbox, .mdc-checkbox, cfc-checkbox, [class*="checkbox"]',
+ );
+ if (visible(ancestor) && hasCheckbox && termsPattern.test(text)) {
+ dialog = ancestor;
+ break;
+ }
+ ancestor = ancestor.parentElement;
+ }
+ }
+ const formInput = [...document.querySelectorAll('input')].find(element => {
+ if (!visible(element)) return false;
+ let context = `${element.id || ''} ${element.name || ''} ${element.getAttribute('aria-label') || ''}`;
+ let ancestor = element.parentElement;
+ for (let level = 0; ancestor && level < 5; level++, ancestor = ancestor.parentElement) {
+ context += ` ${ancestor.innerText || ''}`;
+ if (/project name|项目名称|nome do projeto/i.test(context)) return true;
+ }
+ return /project.?name|项目名称|nome.+projeto/i.test(context);
+ });
+ if (!dialog) return { hasTerms: false, hasProjectForm: !!formInput };
+ dialog.setAttribute('data-cloud-terms-dialog', '1');
+ const checkboxCandidates = [...dialog.querySelectorAll([
+ 'input[type="checkbox"]',
+ '[role="checkbox"]',
+ 'mat-checkbox',
+ '.mat-mdc-checkbox',
+ '.mdc-checkbox',
+ 'cfc-checkbox',
+ '[class*="checkbox"]',
+ ].join(','))];
+ let checkbox = checkboxCandidates.find(visible);
+ if (!checkbox) {
+ const native = checkboxCandidates.find(element => element.matches && element.matches('input[type="checkbox"]'));
+ if (native) {
+ checkbox = native.closest('label, [role="checkbox"], mat-checkbox, .mat-mdc-checkbox, .mdc-checkbox, cfc-checkbox')
+ || native;
+ }
+ }
+ if (checkbox) checkbox.setAttribute('data-cloud-terms-checkbox', '1');
+ const buttons = [...dialog.querySelectorAll('button, [role="button"]')].filter(visible);
+ const preferred = buttons.find(button => confirmPattern
+ .test(button.innerText || button.textContent || ''));
+ const confirm = preferred || globalConfirm || buttons[buttons.length - 1];
+ if (confirm) confirm.setAttribute('data-cloud-terms-confirm', '1');
+ const nativeCheckbox = checkbox && (checkbox.matches && checkbox.matches('input[type="checkbox"]')
+ ? checkbox
+ : checkbox.querySelector && checkbox.querySelector('input[type="checkbox"]'));
+ // 某些 Cloud Console 版本把真正的复选框画在封装组件/Shadow DOM 中,
+ // document.querySelector 无法取得可点击节点。用“I agree...”首行文字的
+ // Range 坐标反推左侧复选框中心,作为真实页面坐标兜底。
+ let agreementPoint = null;
+ const walker = document.createTreeWalker(dialog, NodeFilter.SHOW_TEXT);
+ let textNode = walker.nextNode();
+ while (textNode) {
+ if (/I agree to the|我同意|Concordo com/i.test(String(textNode.nodeValue || ''))) {
+ const range = document.createRange();
+ range.selectNodeContents(textNode);
+ const rect = [...range.getClientRects()].find(item => item.width > 0 && item.height > 0);
+ if (rect) {
+ agreementPoint = {
+ x: Math.max(2, rect.left - 18),
+ y: Math.max(2, rect.top + Math.min(rect.height / 2, 10)),
+ };
+ break;
+ }
+ }
+ textNode = walker.nextNode();
+ }
+ return {
+ hasTerms: true,
+ hasProjectForm: !!formInput,
+ checked: !!(checkbox && (
+ (nativeCheckbox && nativeCheckbox.checked)
+ || checkbox.checked
+ || checkbox.getAttribute('aria-checked') === 'true'
+ || /checked|selected/.test(String(checkbox.className || ''))
+ )),
+ agreementPoint,
+ };
+ }).catch(() => ({ hasTerms: false, hasProjectForm: false }));
+
+ if (!state.hasTerms) {
+ if (state.hasProjectForm) return { ok: true, handled: false, msg: '' };
+ await sleep(500);
+ continue;
+ }
+ sawTermsDialog = true;
+
+ if (!state.checked) {
+ const checkbox = await firstVisible(page, ['[data-cloud-terms-checkbox="1"]']);
+ if (checkbox) {
+ await checkbox.scrollIntoViewIfNeeded().catch(() => {});
+ const tag = await checkbox.evaluate(element => element.tagName.toLowerCase()).catch(() => '');
+ if (tag === 'input' && typeof checkbox.check === 'function') {
+ await checkbox.check({ timeout: 5000 }).catch(() => humanClick(checkbox, page));
+ } else {
+ await humanClick(checkbox, page);
+ }
+ await sleep(400);
+ }
+ }
+
+ const confirm = await firstVisible(page, ['[data-cloud-terms-confirm="1"]']);
+ if (!confirm) return { ok: false, handled: true, msg: 'Google Cloud 首次条款弹窗中未找到确认按钮' };
+ await confirm.scrollIntoViewIfNeeded().catch(() => {});
+ let disabled = await confirm.evaluate(element => element.disabled || element.getAttribute('aria-disabled') === 'true')
+ .catch(() => false);
+ if (disabled && state.agreementPoint && page && page.mouse) {
+ if (!agreementFallbackLogged) {
+ log('[AIStudio] 条款复选框封装节点未响应,按可见条款文字左侧坐标重试...');
+ agreementFallbackLogged = true;
+ }
+ await page.mouse.move(state.agreementPoint.x, state.agreementPoint.y, { steps: 8 }).catch(() => {});
+ await page.mouse.click(state.agreementPoint.x, state.agreementPoint.y, { delay: 80 }).catch(() => {});
+ await sleep(500);
+ disabled = await confirm.evaluate(element => element.disabled || element.getAttribute('aria-disabled') === 'true')
+ .catch(() => false);
+ }
+ if (disabled) {
+ await sleep(500);
+ continue;
+ }
+ const clicked = await humanClick(confirm, page);
+ if (!clicked) {
+ await sleep(500);
+ continue;
+ }
+ confirmAttempts++;
+ // DOM/Shadow DOM 定位与坐标兜底可能已经耗尽入口的等待预算。
+ // 一旦真实点击已发出,就给确认结果和第二次点击独立的时间窗口。
+ deadline = Math.max(deadline, Date.now() + 15000);
+ if (log) log(`[AIStudio] Google Cloud 首次条款已提交确认(第 ${confirmAttempts}/2 次)`);
+ // 真实页面偶发出现点击已提交、SPA 弹层却没有及时消失的状态。先短等,
+ // 未关闭时重新定位按钮再点一次;不能像旧逻辑一样固定等12秒后直接失败。
+ const goneDeadline = Math.min(deadline, Date.now() + 2500);
+ while (Date.now() < goneDeadline) {
+ const visible = await firstVisible(page, ['[data-cloud-terms-dialog="1"]']);
+ if (!visible) return { ok: true, handled: true, msg: '' };
+ await sleep(400);
+ }
+ if (confirmAttempts < 2) {
+ log('[AIStudio] 条款确认后弹窗仍在,重新定位确认按钮再试一次...');
+ continue;
+ }
+
+ // 两次确认后仍显示时,可能只是 Cloud Console 前端弹层卡住,服务端实际上已
+ // 保存条款。刷新项目创建页重新读取状态:刷新后若直接出现项目表单即可继续。
+ const currentUrl = String(page.url ? page.url() : '');
+ if (!recoveryReloaded && /console\.cloud\.google\.com\/projectcreate/i.test(currentUrl)) {
+ recoveryReloaded = true;
+ confirmAttempts = 0;
+ deadline = Math.max(deadline, Date.now() + 15000);
+ log('[AIStudio] 条款弹窗未及时关闭,刷新项目创建页核验确认结果...');
+ const remainingMs = Math.max(1000, deadline - Date.now());
+ await page.reload({
+ waitUntil: 'domcontentloaded',
+ timeout: Math.min(15000, remainingMs),
+ }).catch(() => {});
+ await page.waitForLoadState('domcontentloaded', { timeout: 5000 }).catch(() => {});
+ continue;
+ }
+ return { ok: false, handled: true, msg: 'Google Cloud 首次条款重复确认并刷新后弹窗仍未关闭' };
+ }
+ if (sawTermsDialog) {
+ // 最后一次确认可能已成功关闭弹窗,只是关闭发生在旧截止时间之后。
+ // 以当前 DOM 的项目表单为准,避免把已完成的条款确认误报成失败。
+ const visibleDialog = await firstVisible(page, ['[data-cloud-terms-dialog="1"]']);
+ if (!visibleDialog) {
+ const form = await markCloudProjectCreateForm(page);
+ if (form.ready) return { ok: true, handled: true, msg: '' };
+ }
+ return { ok: false, handled: true, msg: 'Google Cloud 首次条款弹窗在超时前未能完成确认' };
+ }
+ // 已使用过 Cloud Console 的账号不会出现条款框;表单渲染较慢时交给下一阶段继续等。
+ return { ok: true, handled: false, msg: '' };
+}
+
+/** 项目表单可能已经在首次条款弹窗后面渲染,不能仅凭表单存在就跳过条款。 */
+async function hasVisibleCloudConsoleTermsDialog(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 termsPattern = /terms of service|agree and continue|welcome|服务条款|同意并继续|termos de serviço/i;
+ const confirmPattern = /agree and continue|同意并继续|concordar.+continuar/i;
+ const dialogs = [...document.querySelectorAll([
+ '[role="dialog"]',
+ '[aria-modal="true"]',
+ 'mat-dialog-container',
+ '.mat-mdc-dialog-container',
+ '.mat-mdc-dialog-surface',
+ '.mdc-dialog',
+ '.mdc-dialog__surface',
+ 'cfc-dialog',
+ 'cfc-modal',
+ '[class*="dialog-container"]',
+ '[class*="modal-container"]',
+ ].join(','))].filter(visible);
+ if (dialogs.some(element => termsPattern.test(element.innerText || element.textContent || ''))) return true;
+
+ const confirm = [...document.querySelectorAll('button, [role="button"]')]
+ .filter(visible)
+ .find(button => confirmPattern.test(button.innerText || button.textContent || ''));
+ if (!confirm) return false;
+ let ancestor = confirm.parentElement;
+ while (ancestor && ancestor !== document.body) {
+ const text = ancestor.innerText || ancestor.textContent || '';
+ const hasCheckbox = !!ancestor.querySelector(
+ 'input[type="checkbox"], [role="checkbox"], mat-checkbox, .mat-mdc-checkbox, .mdc-checkbox, cfc-checkbox, [class*="checkbox"]',
+ );
+ if (visible(ancestor) && hasCheckbox && termsPattern.test(text)) return true;
+ ancestor = ancestor.parentElement;
+ }
+ return false;
+ }).catch(() => false);
+}
+
+/** 在 Cloud Console 的项目创建表单里标记项目名输入框和 Create 主按钮。 */
+async function markCloudProjectCreateForm(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;
+ };
+ document.querySelectorAll('[data-cloud-project-name], [data-cloud-project-create]')
+ .forEach(element => {
+ element.removeAttribute('data-cloud-project-name');
+ element.removeAttribute('data-cloud-project-create');
+ });
+ const inputs = [...document.querySelectorAll('input[type="text"], input:not([type])')].filter(visible);
+ let input = inputs.find(element => /project.?name|项目名称|nome.+projeto/i
+ .test(`${element.id || ''} ${element.name || ''} ${element.getAttribute('aria-label') || ''}`));
+ if (!input) {
+ input = inputs.find(element => {
+ let ancestor = element.parentElement;
+ let context = '';
+ for (let level = 0; ancestor && level < 5; level++, ancestor = ancestor.parentElement) {
+ context += ` ${ancestor.innerText || ''}`;
+ if (/project name|项目名称|nome do projeto/i.test(context)) return true;
+ }
+ return false;
+ });
+ }
+ if (!input) return { ready: false, value: '', projectId: '' };
+ input.setAttribute('data-cloud-project-name', '1');
+ const scope = input.closest('form') || document.body;
+ const buttons = [...scope.querySelectorAll('button, [role="button"]')].filter(visible);
+ const create = buttons.find(button => /^(create|创建|criar)$/i.test((button.innerText || button.textContent || '').trim()))
+ || buttons.find(button => button.type === 'submit');
+ if (create) create.setAttribute('data-cloud-project-create', '1');
+ const bodyText = document.body ? (document.body.innerText || '') : '';
+ const match = bodyText.match(/(?:Project\s*ID|项目\s*ID|ID\s+do\s+projeto)\s*:?\s*([a-z][a-z0-9-]{4,61}[a-z0-9])/i);
+ return {
+ ready: !!create,
+ value: input.value || '',
+ projectId: match ? match[1] : '',
+ };
+ }).catch(() => ({ ready: false, value: '', projectId: '' }));
+}
+
+/**
+ * AI Studio 内部 CreateCloudProject 被拒绝时,复刻人工成功路径:
+ * Cloud Console /projectcreate → 首次条款 → 创建默认项目 → 带 project 参数返回 AI Studio。
+ */
+async function createProjectViaCloudConsole(page, log = () => {}, options = {}) {
+ const timeoutMs = Math.max(45000, Number(options.timeoutMs) || 90000);
+ const projectName = String(options.projectName || DEFAULT_PROJECT_NAME).trim() || DEFAULT_PROJECT_NAME;
+ try {
+ log('[AIStudio] 内部代建项目被拒绝,改用 Google Cloud Console 创建项目...');
+ // 密码验证后 Chrome 可能弹出原生“要保存密码吗”。它不属于网页 DOM,
+ // 会夺取焦点并遮挡后续真实鼠标操作;导航前用系统级 Escape 关闭。
+ await dismissBrowserChromePopup(page, log);
+ await sleep(150);
+ await page.goto(CLOUD_PROJECT_CREATE_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
+ await page.waitForLoadState('domcontentloaded', { timeout: 5000 }).catch(() => {});
+ if (/accounts\.google\.com/i.test(String(page.url ? page.url() : ''))) {
+ return { ok: false, projectId: '', msg: 'Google Cloud Console 要求重新登录' };
+ }
+
+ // 已经出现 Project name + Create 的账号不需要再处理 /terms,直接提交即可。
+ // 同时记录调用前已有标签,只关闭本次误点条款链接新开的辅助页,不碰用户原标签。
+ let form = await markCloudProjectCreateForm(page);
+ const termsDialogVisible = await hasVisibleCloudConsoleTermsDialog(page);
+ if (form.ready && !termsDialogVisible) {
+ log('[AIStudio] Google Cloud 项目创建表单已就绪,跳过条款页并直接点击 Create');
+ } else {
+ const context = page && typeof page.context === 'function' ? page.context() : null;
+ const pagesBeforeTerms = new Set(context && typeof context.pages === 'function' ? context.pages() : []);
+ const terms = await acceptCloudConsoleTerms(page, log, 35000);
+ if (context && typeof context.pages === 'function') {
+ for (const extraPage of context.pages()) {
+ if (extraPage === page || pagesBeforeTerms.has(extraPage)) continue;
+ if (/console\.cloud\.google\.com\/terms(?:[/?#]|$)/i.test(String(extraPage.url ? extraPage.url() : ''))) {
+ await extraPage.close().catch(() => {});
+ log('[AIStudio] 已关闭误打开的 Google Cloud 条款说明标签页');
+ }
+ }
+ }
+ if (!terms.ok) return { ok: false, projectId: '', msg: terms.msg };
+ }
+
+ const formDeadline = Date.now() + 30000;
+ while (Date.now() < formDeadline) {
+ form = await markCloudProjectCreateForm(page);
+ if (form.ready) break;
+ await sleep(500);
+ }
+ if (!form.ready) return { ok: false, projectId: '', msg: 'Google Cloud 项目创建表单未就绪' };
+
+ const nameInput = await firstVisible(page, ['[data-cloud-project-name="1"]']);
+ if (!nameInput) return { ok: false, projectId: '', msg: 'Google Cloud 项目名输入框未找到' };
+ // 与人工成功操作一致:网页已有随机默认项目名时保持不动;仅在空值时补名。
+ if (!form.value) {
+ await humanType(nameInput, page, projectName);
+ await sleep(500);
+ form = await markCloudProjectCreateForm(page);
+ }
+ const createdProjectName = String(form.value || projectName).trim() || projectName;
+ let projectId = form.projectId || extractCloudProjectId('', await page.locator('body').innerText().catch(() => ''));
+ const createButton = await firstVisible(page, ['[data-cloud-project-create="1"]']);
+ if (!createButton) return { ok: false, projectId: '', msg: 'Google Cloud 项目创建按钮未找到' };
+ const disabled = await createButton.evaluate(element => element.disabled || element.getAttribute('aria-disabled') === 'true')
+ .catch(() => false);
+ if (disabled) return { ok: false, projectId: '', msg: 'Google Cloud 项目创建按钮不可用' };
+
+ await humanClick(createButton, page);
+ log('[AIStudio] 已在 Google Cloud Console 提交创建项目,等待项目就绪...');
+ // 提交前若已经从表单取得确定 project ID,就不需要在 Cloud Console 前台
+ // 独占浏览器/高风险槽等待90秒。短等15秒接收快速成功反馈,之后回 AI Studio
+ // 走30秒非阻塞同步队列;没有取得 ID 时仍保留完整超时,避免丢失项目身份。
+ const projectCompletionWaitMs = projectId ? Math.min(timeoutMs, 15000) : timeoutMs;
+ const createdDeadline = Date.now() + projectCompletionWaitMs;
+ while (Date.now() < createdDeadline) {
+ const currentUrl = String(page.url ? page.url() : '');
+ const snapshot = await pageSnapshot(page);
+ projectId = extractCloudProjectId(currentUrl, snapshot.bodyText) || projectId;
+ const leftCreatePage = !/\/projectcreate(?:[/?#]|$)/i.test(currentUrl);
+ const successText = /Create Project:\s*.+(?:Just now|刚刚)|project (?:was )?created|项目已创建|projeto (?:foi )?criado/i
+ .test(snapshot.bodyText);
+ if (projectId && (leftCreatePage || successText)) {
+ log(`[AIStudio] Google Cloud 项目创建成功(${projectId})`);
+ const ready = await gotoApiKeys(page, 30000, projectId);
+ page.__aistudioProjectCreateResponse = null;
+ page.__aistudioGenerateKeyResponse = null;
+ if (!ready) log('[AIStudio] ⚠️ 带新项目返回 API Keys 页面后尚未完全就绪,继续导入流程');
+ return { ok: true, projectId, projectName: createdProjectName, msg: '' };
+ }
+ if (/project creation failed|failed to create project|创建项目失败|quota.+exceeded|配额.+超出/i.test(snapshot.bodyText)) {
+ return { ok: false, projectId: '', msg: 'Google Cloud Console 返回项目创建失败或配额不足' };
+ }
+ await sleep(800);
+ }
+ // Create 已经提交且页面给出了确定的 project ID 时,“超时”只能说明
+ // Cloud Console 的跳转/通知没能返回,不能说明项目创建失败。实测在
+ // ERR_CONNECTION_RESET 后项目仍会在后台完成,稍后出现在 AI Studio 的
+ // Import projects 中。此处立即回 AI Studio 按已存在项目的导入路径继续,
+ // 避免下一轮又提交一个新项目。
+ if (projectId) {
+ log(`[AIStudio] Google Cloud 项目提交后等待超时(${projectId}),改为返回 AI Studio 检查并导入已提交项目...`);
+ const ready = await gotoApiKeys(page, 30000, projectId).catch(() => false);
+ page.__aistudioProjectCreateResponse = null;
+ page.__aistudioGenerateKeyResponse = null;
+ if (!ready) log('[AIStudio] ⚠️ 返回 API Keys 页面尚未完全就绪,仍继续检查 Import projects');
+ return { ok: true, projectId, projectName: createdProjectName, submittedPending: true, msg: '' };
+ }
+ return { ok: false, projectId: '', msg: 'Google Cloud 项目创建超时(未取得项目 ID)' };
+ } catch (error) {
+ return { ok: false, projectId: '', msg: `Google Cloud 项目创建异常:${error && error.message ? error.message : error}` };
+ }
+}
+
+/** 在打开的浮层菜单里点“删除”项。稳定 data-test-delete-key 优先,图标/文字兜底。 */
+async function clickDeleteMenuItem(page) {
+ // 真实 DOM 确认:删除菜单项带稳定属性 data-test-delete-key(语言无关,中文 UI 也命中)。
+ const byTestId = await firstVisible(page, [
+ '.cdk-overlay-container [role="menuitem"][data-test-delete-key]',
+ '[role="menuitem"][data-test-delete-key]',
+ '[data-test-delete-key]',
+ ]);
+ if (byTestId) {
+ await humanClick(byTestId, page);
+ return true;
+ }
+ // 图标兜底:菜单项内含 delete / delete_outline ligature。
+ const byIcon = await page.evaluate(() => {
+ const iconText = element => (element.getAttribute('fonticon') || element.textContent || '').trim();
+ const items = [...document.querySelectorAll('.cdk-overlay-container [role="menuitem"], .cdk-overlay-container .mat-mdc-menu-item, [role="menuitem"]')];
+ for (let i = 0; i < items.length; i++) {
+ const icons = [...items[i].querySelectorAll('mat-icon, .material-icons, .material-symbols-outlined, [fonticon]')];
+ if (icons.some(icon => /^delete/.test(iconText(icon)))) {
+ items[i].setAttribute('data-aistudio-delete', '1');
+ return true;
+ }
+ }
+ return false;
+ }).catch(() => false);
+ if (byIcon) {
+ const marked = await firstVisible(page, ['[data-aistudio-delete="1"]']);
+ if (marked) {
+ await humanClick(marked, page);
+ return true;
+ }
+ }
+ // 文字兜底(英/葡)。
+ const byText = await firstVisible(page, [
+ '.cdk-overlay-container [role="menuitem"]:has-text("Delete")',
+ '.cdk-overlay-container [role="menuitem"]:has-text("Excluir")',
+ '[role="menuitem"]:has-text("Delete")',
+ '[role="menuitem"]:has-text("Excluir")',
+ ]);
+ if (byText) {
+ await humanClick(byText, page);
+ return true;
+ }
+ return false;
+}
+
+/**
+ * 在确认对话框点危险动作按钮。
+ * 【真实 DOM 实证】(1) 删除确认对话框:Cancel = button.ms-button-borderless[matdialogclose],
+ * Delete = button.ms-button-primary(图标 keyboard_return);(2) 创建对话框:取消在前、
+ * 主操作「创建密钥」= .ms-button-primary 在后。
+ * 两处一致规律:主操作带 .ms-button-primary,取消/关闭是 borderless —— 故只取 primary,
+ * 且取最后一个(对话框内可能有多个 primary 时以最靠后的主操作为准)。
+ */
+async function clickDialogDangerButton(page, log = () => {}) {
+ // 【403 根因】DeleteCloudApiKey / GenerateCloudApiKey 由 botguard 把关,对合成点击返回“可疑请求”403。
+ // 页面内只“定位并打标记”,再用 trustedClick 触发:优先真实 OS 光标(与真人无法区分),
+ // 回退 humanClick;按钮带 ↵ 时用回车兜底。
+ const marked = await page.evaluate(() => {
+ const isVisible = 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;
+ };
+ document.querySelectorAll('[data-aistudio-danger]').forEach(el => el.removeAttribute('data-aistudio-danger'));
+ const dialogs = [...document.querySelectorAll('mat-dialog-container, .mat-mdc-dialog-container, [role="dialog"], [aria-modal="true"]')]
+ .filter(isVisible);
+ if (!dialogs.length) return false;
+ const dialog = dialogs[dialogs.length - 1];
+ const buttons = [...dialog.querySelectorAll('button, [role="button"]')].filter(isVisible);
+ if (!buttons.length) return false;
+ // 主操作按钮 = .ms-button-primary(已验证:删除=Delete、创建=创建密钥)。
+ // 排除右上角关闭按钮(matdialogclose),避免误点。
+ const primaries = buttons.filter(button => (button.classList.contains('ms-button-primary')
+ || button.classList.contains('mdc-button--unelevated')
+ || button.classList.contains('mat-mdc-unelevated-button'))
+ && !button.hasAttribute('matdialogclose'));
+ const target = primaries.length ? primaries[primaries.length - 1] : buttons[buttons.length - 1];
+ target.setAttribute('data-aistudio-danger', '1');
+ return true;
+ }).catch(() => false);
+ if (!marked) return false;
+
+ // 真实 OS 光标点击(优先)→ humanClick 兜底 → 回车兜底(按钮带 ↵,原生支持回车)。
+ const btn = await firstVisible(page, ['[data-aistudio-danger="1"]']);
+ if (btn) {
+ // 尺寸护栏:表单加载中可能误标到 spinner/小图标(如 18×18)。真实主按钮通常 ≥40×16。
+ const box = await btn.boundingBox().catch(() => null);
+ if (box && (box.width < 40 || box.height < 16)) {
+ if (log) log(`[AIStudio] 危险按钮疑似误标到小元素(${Math.round(box.width)}x${Math.round(box.height)}),放弃点击`);
+ return false;
+ }
+ const method = await trustedClick(page, btn, log);
+ if (!method) {
+ await btn.focus().catch(() => {});
+ await page.keyboard.press('Enter').catch(() => {});
+ }
+ return true;
+ }
+ await page.keyboard.press('Enter').catch(() => {});
+ return true;
+}
+
+/** 检测页面上是否出现删除失败 toast(英/葡):「Failed to delete API key. Please retry.」 */
+async function deleteFailureToastVisible(page) {
+ return page.evaluate(() => {
+ const text = document.body ? (document.body.innerText || '') : '';
+ return /Failed to delete API key|Please retry|Falha ao excluir|tente novamente/i.test(text);
+ }).catch(() => false);
+}
+
+/**
+ * 删除该邮箱下全部已有 key。返回 { ok, deleted, msg }。
+ * 【403「Please retry」根因与修复】人工新开页面删除必成功,而自动化在页面停留过久后
+ * 删除偶发 HTTP 403 + 红色 toast「Failed to delete API key. Please retry.」。根因是长时间
+ * 停留导致会话/XSRF 令牌变旧被服务端拒绝。故每次重试前先整页重载(gotoApiKeys)刷新令牌,
+ * 复刻人工「新开页面」的成功路径;同时监听失败 toast 以便尽快进入下一次重试。
+ */
+async function deleteAllKeys(page, log, timeoutMs = 30000) {
+ let deleted = 0;
+ for (let loop = 0; loop < MAX_DELETE_LOOP; loop++) {
+ const before = await countRowMenuButtons(page);
+ if (before === 0) break;
+
+ let decreased = false;
+ let lastMsg = '';
+ const MAX_ATTEMPT = 4;
+ for (let attempt = 0; attempt < MAX_ATTEMPT && !decreased; attempt++) {
+ if (attempt > 0) {
+ // 关键修复:重试前整页重载,刷新会话/XSRF 令牌(等价人工「新开页面」)后再删。
+ if (log) log(`[AIStudio] 删除未生效,重载页面后重试第 ${attempt} 次...`);
+ await page.keyboard.press('Escape').catch(() => {});
+ await sleep(500);
+ await gotoApiKeys(page, timeoutMs).catch(() => {});
+ await sleep(1200 + attempt * 800); // 递增退避,给服务端缓过来
+ const cur = await countRowMenuButtons(page);
+ // 上一次点击可能其实已异步删成功,重载后行数已减少则直接算成功。
+ if (cur < before || cur === 0) { decreased = true; break; }
+ }
+
+ // 真实 DOM 确认:行内 ⋮ 按钮在 tr[role=row]/mat-row 内,带 aria-haspopup=menu,
+ // 图标是 .material-symbols-outlined(ligature=more_vert)。必须限定在表格行内,
+ // 否则会误命中工具栏的 Settings 等同样 aria-haspopup=menu 的按钮。
+ let 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"]',
+ '[role="row"] button:has(.material-symbols-outlined:text-is("more_vert"))',
+ ]);
+ if (!moreBtn) {
+ // 新版末列使用无文字 SVG 的 ⋮ 按钮。只在含 Key 单元格的真实数据行里取最后一个
+ // 小图标按钮,避免误点工具栏设置/筛选。
+ const marked = await page.evaluate(() => {
+ const visible = element => {
+ if (!element) return false;
+ const style = window.getComputedStyle(element);
+ const rect = element.getBoundingClientRect();
+ return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
+ };
+ document.querySelectorAll('[data-aistudio-row-menu]').forEach(element => element.removeAttribute('data-aistudio-row-menu'));
+ const rows = [...document.querySelectorAll('tbody tr, mat-row, [role="row"], ms-api-key-row, ms-table-row')]
+ .filter(visible)
+ .filter(row => row.querySelector('td, mat-cell, [role="cell"]')
+ && row.querySelector('ms-api-key-key-string, .key-string-link, .xap-copy-to-clipboard, .cdk-column-Key, .mat-column-Key'));
+ const row = rows[0];
+ if (!row) return false;
+ const buttons = [...row.querySelectorAll('button, [role="button"]')].filter(visible);
+ let target = buttons.find(button => /more|options|更多|菜单/i.test(button.getAttribute('aria-label') || ''));
+ if (!target) target = buttons[buttons.length - 1];
+ if (!target) return false;
+ target.setAttribute('data-aistudio-row-menu', '1');
+ return true;
+ }).catch(() => false);
+ if (marked) moreBtn = await firstVisible(page, ['[data-aistudio-row-menu="1"]']);
+ }
+ if (!moreBtn) { lastMsg = '未找到行内更多操作按钮'; continue; }
+ await humanClick(moreBtn, page);
+ await sleep(800);
+
+ // 菜单项「Delete key」(参考图2)。
+ const menuClicked = await clickDeleteMenuItem(page);
+ if (!menuClicked) {
+ await page.keyboard.press('Escape').catch(() => {});
+ lastMsg = '菜单中未找到删除项';
+ continue;
+ }
+ await sleep(800);
+
+ // 确认框「Delete API key?」主按钮「Delete ↵」(参考图3)。
+ const confirmed = await clickDialogDangerButton(page, log);
+ if (!confirmed) { lastMsg = '未找到删除确认按钮'; continue; }
+
+ // 等结果:行数减少=成功(参考图4:toast「Deleting API key...」后行消失);
+ // 若出现「Please retry」失败 toast 则立即跳出、进入下一次(会重载页面)重试。
+ const waitDeadline = Date.now() + 12000;
+ while (Date.now() < waitDeadline) {
+ await sleep(600);
+ const now = await countRowMenuButtons(page);
+ if (now < before) { decreased = true; break; }
+ if (await deleteFailureToastVisible(page)) {
+ lastMsg = '服务端返回 Failed to delete API key(Please retry/403)';
+ break;
+ }
+ }
+ if (!decreased && !lastMsg) lastMsg = '删除后行数未减少';
+ }
+
+ if (!decreased) return { ok: false, deleted, msg: lastMsg || '删除失败' };
+ deleted++;
+ if (log) log(`[AIStudio] 已删除 1 枚 key(累计 ${deleted})`);
+ }
+ const remain = await countRowMenuButtons(page);
+ if (remain > 0) return { ok: false, deleted, msg: `仍残留 ${remain} 枚 key` };
+ return { ok: true, deleted, msg: '' };
+}
+
+/**
+ * 只标记真正的“创建 API Key”对话框。
+ * 页面同时可能存在欢迎条款、附加条款、新建项目和提示卡片;过去用最后一个 role=dialog
+ * 会把新建项目加载框右上角 32x32 的关闭按钮误当成 Create key。
+ */
+async function markCreationDialog(page) {
+ return page.evaluate(() => {
+ const isVisible = 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;
+ };
+ document.querySelectorAll('[data-aistudio-creation-dialog]')
+ .forEach(element => element.removeAttribute('data-aistudio-creation-dialog'));
+ const explicit = [...document.querySelectorAll('ms-api-key-creation-dialog')].filter(isVisible).pop();
+ let root = explicit || null;
+ if (!root) {
+ const dialogs = [...document.querySelectorAll('mat-dialog-container, .mat-mdc-dialog-container, [role="dialog"], [aria-modal="true"]')]
+ .filter(isVisible);
+ root = dialogs.reverse().find(dialog => {
+ const text = dialog.innerText || dialog.textContent || '';
+ const hasCreationControl = !!dialog.querySelector('#api-key-name-input, mat-select, [role="combobox"]');
+ return hasCreationControl && /create\s+(?:an?\s+)?api\s*key|create\s+key|criar\s+chave|创建.*(?:API\s*)?密钥|生成.*密钥/i.test(text);
+ }) || null;
+ }
+ if (!root) return false;
+ root.setAttribute('data-aistudio-creation-dialog', '1');
+ return true;
+ }).catch(() => false);
+}
+
+async function findCreationDialog(page) {
+ if (!(await markCreationDialog(page))) return null;
+ return firstVisible(page, ['[data-aistudio-creation-dialog="1"]']);
+}
+
+/** “Create a new project”子弹窗仍在时,底下的创建 Key 对话框不可操作。 */
+async function hasBlockingProjectCreationDialog(page) {
+ return page.evaluate(() => {
+ const visible = element => {
+ if (!element) return false;
+ const style = window.getComputedStyle(element);
+ const rect = element.getBoundingClientRect();
+ return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
+ };
+ return [...document.querySelectorAll('[data-aistudio-project-dialog], mat-dialog-container, .mat-mdc-dialog-container, [role="dialog"], [aria-modal="true"]')]
+ .filter(visible)
+ .some(dialog => /create\s+a\s+new\s+project|criar\s+um\s+novo\s+projeto|创建新项目/i
+ .test(dialog.innerText || dialog.textContent || ''));
+ }).catch(() => false);
+}
+
+/** 判断创建对话框内的“Create key”按钮是否 disabled。 */
+async function isCreateKeyDisabled(page) {
+ if (await hasBlockingProjectCreationDialog(page)) return true;
+ if (!(await markCreationDialog(page))) return true;
+ return page.evaluate(() => {
+ const isVisible = 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 dialog = document.querySelector('[data-aistudio-creation-dialog="1"]');
+ if (!dialog || !isVisible(dialog)) return true;
+ const buttons = [...dialog.querySelectorAll('button')].filter(isVisible)
+ .filter(button => !button.hasAttribute('matdialogclose')
+ && !/close|关闭|fechar/i.test(button.getAttribute('aria-label') || ''));
+ if (!buttons.length) return true;
+ const target = buttons.find(button => /^(create\s+(?:api\s*)?key|criar\s+chave(?:\s+de\s+api)?|创建(?:\s*API)?\s*密钥|生成密钥)$/i
+ .test((button.innerText || button.textContent || '').trim()))
+ || buttons.filter(button => button.classList.contains('ms-button-primary')
+ || button.classList.contains('mdc-button--unelevated')
+ || button.classList.contains('mat-mdc-unelevated-button')).pop();
+ if (!target) return true;
+ // 真实 DOM 确认:未选项目时按钮是 aria-disabled="false" 但 data-disabled="true",
+ // 必须一并判定为不可点,否则会过早点击导致无效/报错。
+ return target.disabled || target.getAttribute('aria-disabled') === 'true'
+ || target.getAttribute('data-disabled') === 'true'
+ || target.classList.contains('mat-mdc-button-disabled');
+ }).catch(() => true);
+}
+
+/** 只点击创建 Key 对话框里的主操作按钮,绝不回退到其它弹窗的最后一个按钮。 */
+async function clickCreateKeyButton(page, log = () => {}) {
+ if (await hasBlockingProjectCreationDialog(page)) {
+ if (log) log('[AIStudio] 新建项目弹窗仍在加载,暂不点击 Create key');
+ return false;
+ }
+ if (!(await markCreationDialog(page))) return false;
+ const marked = await page.evaluate(() => {
+ const isVisible = 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;
+ };
+ document.querySelectorAll('[data-aistudio-create-key-submit]')
+ .forEach(element => element.removeAttribute('data-aistudio-create-key-submit'));
+ const dialog = document.querySelector('[data-aistudio-creation-dialog="1"]');
+ if (!dialog || !isVisible(dialog)) return false;
+ const buttons = [...dialog.querySelectorAll('button, [role="button"]')].filter(isVisible)
+ .filter(button => !button.hasAttribute('matdialogclose')
+ && !/close|关闭|fechar/i.test(button.getAttribute('aria-label') || ''));
+ let target = buttons.find(button => /^(create\s+(?:api\s*)?key|criar\s+chave(?:\s+de\s+api)?|创建(?:\s*API)?\s*密钥|生成密钥)$/i
+ .test((button.innerText || button.textContent || '').trim()));
+ if (!target) {
+ target = buttons.filter(button => button.classList.contains('ms-button-primary')
+ || button.classList.contains('mdc-button--unelevated')
+ || button.classList.contains('mat-mdc-unelevated-button')).pop();
+ }
+ if (!target) return false;
+ const rect = target.getBoundingClientRect();
+ if (rect.width < 40 || rect.height < 16) return false;
+ target.setAttribute('data-aistudio-create-key-submit', '1');
+ return true;
+ }).catch(() => false);
+ if (!marked) return false;
+ const button = await firstVisible(page, ['[data-aistudio-create-key-submit="1"]']);
+ if (!button) return false;
+ const method = await trustedClick(page, button, log);
+ if (method) return true;
+ await button.focus().catch(() => {});
+ await page.keyboard.press('Enter').catch(() => {});
+ return true;
+}
+
+/** 点击创建对话框内的 Create key 主按钮。 */
+async function clickDialogLastButton(page, log = () => {}) {
+ return clickCreateKeyButton(page, log);
+}
+
+/** 判断创建对话框内的项目下拉当前是否已选好一个真实项目(排除占位/动作项)。 */
+async function hasRealProjectSelected(page) {
+ if (await hasBlockingProjectCreationDialog(page)) return false;
+ if (!(await markCreationDialog(page))) return false;
+ return page.evaluate(() => {
+ const dialog = document.querySelector('[data-aistudio-creation-dialog="1"]');
+ if (!dialog) return false;
+ const select = dialog.querySelector('mat-select, [role="combobox"]');
+ if (!select) return false;
+ const trigger = select.querySelector('.mat-mdc-select-value-text, .mat-mdc-select-value, .mat-select-value');
+ const text = (trigger ? (trigger.innerText || trigger.textContent || '') : '').trim();
+ if (!text) return false;
+ // 占位/动作项不算已选:Import/导入、Choose/Create、No Cloud Projects 等。
+ if (/import|importar|导入|choose|selecione|no cloud|nenhum|create project|criar projeto|新建项目/i.test(text)) return false;
+ return true;
+ }).catch(() => false);
+}
+
+/**
+ * 在新版创建对话框里通过项目下拉(mat-select #dashboard-select-input)选定一个项目。
+ * 真实 DOM 确认:下拉选项为 mat-option[role=option];真实项目不带 .action-item,
+ * 而「导入/Create project」等动作项带 .action-item;真实项目的子文本是 project-。
+ * @returns {Promise<{handled:boolean, ok:boolean, created:boolean, msg:string}>}
+ * handled=false 表示对话框内没有可识别的项目下拉,交给旧版逻辑处理。
+ */
+async function selectProjectInNewUi(
+ page,
+ log,
+ projectName,
+ preferredProjectId = '',
+ allowMissingPreferredProjectRecovery = false,
+ preferredProjectName = '',
+) {
+ const dialog = await findCreationDialog(page);
+ if (!dialog) return { handled: false, ok: false, created: false, msg: '' };
+
+ const projectSelect = await firstVisible(dialog, [
+ 'mat-select[role="combobox"]',
+ 'mat-select',
+ '[role="combobox"]',
+ ]);
+ if (!projectSelect) return { handled: false, ok: false, created: false, msg: '' };
+
+ // 已经选好真实项目:直接返回。
+ if (await hasRealProjectSelected(page)) {
+ return { handled: true, ok: true, created: false, msg: '' };
+ }
+
+ // 展开下拉并分类选项。下拉刚点开可能还在「Loading...」,须等选项真正出现再分类,
+ // 否则抓不到 Import/Create/真实项目就误判为空(这是之前走不到「建项目」的时序隐患)。
+ await projectSelect.click({ timeout: 5000 }).catch(() => {});
+ await sleep(1200);
+ let classified = await classifyProjectOptions(page, preferredProjectId);
+ for (let i = 0; i < 8 && classified.total === 0; i++) {
+ await sleep(1000);
+ classified = await classifyProjectOptions(page, preferredProjectId);
+ }
+ // 下拉一直空(Loading 卡住):收起再重开一次,给它重新加载的机会。
+ if (classified.total === 0) {
+ await page.keyboard.press('Escape').catch(() => {});
+ await sleep(800);
+ await projectSelect.click({ timeout: 5000 }).catch(() => {});
+ await sleep(1500);
+ classified = await classifyProjectOptions(page, preferredProjectId);
+ }
+ if (log) log(`[AIStudio] 项目下拉选项:真实=${classified.realIndex} 创建=${classified.createIndex} 导入=${classified.importIndex} 共${classified.total}`);
+
+ // 优先选一个真实项目(排除 .action-item 动作项)。
+ const selectedRealIndex = classified.preferredRealIndex >= 0 ? classified.preferredRealIndex : classified.realIndex;
+ if (selectedRealIndex >= 0) {
+ const opt = await firstVisible(page, [`[data-aistudio-opt="${selectedRealIndex}"]`]);
+ if (opt) {
+ await opt.click({ timeout: 5000 }).catch(() => {});
+ await sleep(1200);
+ if (log) log('[AIStudio] 已选中现有项目');
+ // 只要下拉已经显示真实项目就继续。按钮的 data-disabled 在新版页面可能残留为
+ // true,但按钮实际可点击;最终是否成功由点击后的 Key 详情严格校验。
+ const selected = await hasRealProjectSelected(page);
+ const ok = selected || await waitCreateKeyEnabled(page, 15000);
+ return { handled: true, ok, created: false, msg: ok ? '' : '选中项目后仍无法确认项目状态' };
+ }
+ }
+
+ // 流程文档的顺序是:下拉没有真实项目时先打开 Import projects;已有 Cloud 项目就
+ // 直接导入,确认 No projects found 后才回到下拉创建项目。这样不会给已有项目的账号
+ // 重复建项目。Cloud Console 兜底返回时仍携带 project ID/名称做精确搜索。
+ if (classified.importIndex >= 0) {
+ const opt = await firstVisible(page, [`[data-aistudio-opt="${classified.importIndex}"]`]);
+ if (opt) {
+ await opt.click({ timeout: 5000 }).catch(() => {});
+ const imported = await importCloudProjectFromDrawer(
+ page,
+ log,
+ preferredProjectId,
+ preferredProjectId ? 25000 : 8000,
+ preferredProjectName,
+ );
+ if (imported.ok) {
+ const importedProjectId = String(imported.projectId || preferredProjectId || '').trim();
+ // 大多数账号的创建框会立刻刷新项目列表,先短等并复用当前页面;若 8 秒内仍是
+ // No Cloud Projects Available,再做一次精确刷新。这样正常路径没有整页重载,
+ // 同时避免在 Angular 旧缓存里空等两轮 30 秒。
+ let ok = await waitCreateReadyAfterProject(page, log, 8000, importedProjectId);
+ if (!ok) {
+ if (log) log('[AIStudio] Imported 后项目尚未出现在创建列表,精确刷新一次...');
+ const refreshed = await gotoApiKeys(page, 30000, importedProjectId);
+ if (refreshed) ok = await waitCreateReadyAfterProject(page, log, 20000, importedProjectId);
+ }
+ if (!ok) {
+ return pendingCloudProjectSync(
+ importedProjectId,
+ '项目导入已完成,但 Google 尚未将其同步到创建列表',
+ preferredProjectName,
+ );
+ }
+ return {
+ handled: true,
+ ok,
+ created: true,
+ msg: ok ? '' : '项目导入已完成,但 Google 尚未将其同步到创建列表',
+ };
+ }
+ if (!imported.noProjects) {
+ return { handled: true, ok: false, created: false, msg: imported.msg };
+ }
+ // 推荐列表和 project ID 精确搜索都明确为空,说明持久化的旧 ID 已失效
+ // (项目可能被删、创建未真正落地,或属于其它账号)。把这个状态交给外层,
+ // 清掉本轮 preferredProjectId 后重新进入无项目分支创建新项目。
+ if (preferredProjectId && imported.exactSearchMiss) {
+ // 本轮刚创建、或保存时间尚不足 30 秒的项目也会暂时搜索不到。
+ // 这类项目必须挂起等待,只有调度层确认已等满同步窗口后才允许判失效重建。
+ if (!allowMissingPreferredProjectRecovery) {
+ if (log) log(`[AIStudio] Cloud 项目 ${preferredProjectName || preferredProjectId} 刚创建或仍在同步,挂起 30 秒后重试`);
+ return pendingCloudProjectSync(preferredProjectId, '', preferredProjectName);
+ }
+ return {
+ handled: true,
+ ok: false,
+ created: false,
+ preferredProjectMissing: true,
+ preferredProjectId: String(preferredProjectId).trim(),
+ preferredProjectName: String(preferredProjectName || '').trim(),
+ msg: imported.msg || `Cloud 项目 ${preferredProjectId} 不存在,需要重新创建`,
+ };
+ }
+ if (preferredProjectId) {
+ // Cloud Console 刚创建的项目可能暂时不会出现在 AI Studio 导入列表。
+ // 此时绝不能再点 Create project 生成重复项目;把“等待”交给批量调度器,
+ // 让当前账号释放浏览器、鼠标及 AI Studio 高风险并发槽后再回来导入。
+ if (log) log(`[AIStudio] Cloud 项目 ${preferredProjectName || preferredProjectId} 尚未同步到导入列表,挂起 30 秒后重试;等待期间继续处理其它账号`);
+ return pendingCloudProjectSync(preferredProjectId, '', preferredProjectName);
+ }
+
+ // 首次检查明确没有任何可导入项目:抽屉已经关闭,必须重新展开项目下拉并重新
+ // 分类 DOM,不能复用打开 Import 前留下的 option 标记(旧节点已经被销毁)。
+ if (log) log('[AIStudio] Import projects 确认没有可用项目,返回创建框新建项目...');
+ const refreshedDialog = await findCreationDialog(page);
+ const refreshedSelect = refreshedDialog && await firstVisible(refreshedDialog, [
+ 'mat-select[role="combobox"]',
+ 'mat-select',
+ '[role="combobox"]',
+ ]);
+ if (!refreshedSelect) {
+ return { handled: true, ok: false, created: false, msg: '导入列表为空后未能重新找到项目选择控件' };
+ }
+ await refreshedSelect.click({ timeout: 5000 }).catch(() => {});
+ await sleep(1000);
+ classified = await classifyProjectOptions(page, '');
+ for (let i = 0; i < 6 && classified.total === 0; i++) {
+ await sleep(700);
+ classified = await classifyProjectOptions(page, '');
+ }
+ }
+ }
+
+ // 已有明确的 Cloud 项目 ID 时,“没有真实项目/导入入口”只表示同步尚未完成,
+ // 不代表账号没有项目。延迟重试,避免重复创建项目。
+ if (preferredProjectId) {
+ return pendingCloudProjectSync(
+ preferredProjectId,
+ 'Cloud 项目已创建,但项目下拉暂时无法选择该项目',
+ preferredProjectName,
+ );
+ }
+
+ // Import projects 已确认无项目:点「Create project」在当前位置新建。
+ // 不依赖 URL 跳转;项目弹窗关闭后等待它自动选中,必要时重开下拉主动选中新项目。
+ if (classified.createIndex >= 0) {
+ const opt = await firstVisible(page, [`[data-aistudio-opt="${classified.createIndex}"]`]);
+ if (opt) {
+ await opt.click({ timeout: 5000 }).catch(() => {});
+ await sleep(1500);
+ const created = await createProject(page, log, projectName);
+ if (!created.ok) return { handled: true, ok: false, created: false, msg: created.msg };
+ if (log) log('[AIStudio] 该账号无项目,已新建项目,等待自动选中...');
+ // Google 可能留在当前 URL,也可能带 ?project= 重渲染;两种情况都只按
+ // “项目弹窗关闭 + 新项目已选中/创建按钮可用”判断,不要求网址发生变化。
+ const ok = await waitCreateReadyAfterProject(page, log, 45000, preferredProjectId);
+ return { handled: true, ok, created: true, msg: ok ? '' : '新建项目后仍无法确认项目已选中' };
+ }
+ }
+
+ await page.keyboard.press('Escape').catch(() => {});
+ return { handled: true, ok: false, created: false, msg: '项目下拉中无可用项目' };
+}
+
+/**
+ * 给下拉选项打标记并分类:真实项目 vs「创建项目」动作 vs「导入项目」动作。
+ * 参考 images/点击Criar chave de API对话框截图-无项目:无项目账号点开下拉有
+ * "Import project" 和 "+ Create project" 两个动作项——须区分。没有真实项目时先查 Import,
+ * 确认导入列表为空后才创建,避免给已有 Cloud 项目的账号重复创建项目。
+ */
+async function classifyProjectOptions(page, preferredProjectId = '') {
+ return page.evaluate(preferredId => {
+ const isVisible = 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 iconText = el => (el.getAttribute('fonticon') || el.textContent || '').trim();
+ const options = [...document.querySelectorAll('.cdk-overlay-container [role="option"], [role="option"], mat-option')]
+ .filter(isVisible);
+ let realIndex = -1;
+ let preferredRealIndex = -1;
+ let createIndex = -1;
+ let importIndex = -1;
+ options.forEach((option, index) => {
+ option.setAttribute('data-aistudio-opt', String(index));
+ const text = (option.innerText || option.textContent || '').trim();
+ const icons = [...option.querySelectorAll('mat-icon, .material-icons, .material-symbols-outlined, [fonticon]')];
+ const hasAddIcon = icons.some(i => iconText(i) === 'add');
+ const isCreate = hasAddIcon || /create project|criar projeto|创建项目|新建项目/i.test(text);
+ const isImport = /\bimport\b|importar|导入/i.test(text)
+ || icons.some(i => /drive|upload|import/i.test(iconText(i)));
+ const isPlaceholder = /no cloud projects|nenhum projeto|no .*available|无.*项目/i.test(text);
+ if (isCreate) {
+ if (createIndex === -1) createIndex = index;
+ } else if (isImport) {
+ if (importIndex === -1) importIndex = index;
+ } else if (!isPlaceholder && text && realIndex === -1) {
+ realIndex = index;
+ }
+ if (!isCreate && !isImport && !isPlaceholder && preferredId && text.includes(preferredId)) {
+ preferredRealIndex = index;
+ }
+ });
+ return { realIndex, preferredRealIndex, createIndex, importIndex, total: options.length };
+ }, String(preferredProjectId || '')).catch(() => ({ realIndex: -1, preferredRealIndex: -1, createIndex: -1, importIndex: -1, total: 0 }));
+}
+
+/**
+ * 在 AI Studio 的 Import projects 侧栏导入可用 Cloud 项目。
+ * 真实页面会自动给出“Projects you may want to import”并预选项目;勾选仅表示待导入,
+ * 必须再点右侧 Import 主按钮才算完成。Cloud Console 新建项目后优先使用项目名称搜索;
+ * 旧持久化记录没有项目名时才以 project ID 兼容兜底。
+ */
+async function importCloudProjectFromDrawer(page, log, projectId, timeoutMs = 30000, projectName = '') {
+ const normalizedId = String(projectId || '').trim();
+ const normalizedProjectName = String(projectName || '').trim();
+ const searchTerm = normalizedProjectName || normalizedId;
+ // timeoutMs 只约束“找到并操作项目”的初始阶段。每次产生有效进度后续期;
+ // Import 主按钮一旦点击,必须另给服务端/Angular 独立的完成确认窗口,不能沿用
+ // 已经快耗尽的初始 deadline(真实页面可能在最后一秒才完成勾选和提交)。
+ let deadline = Date.now() + timeoutMs;
+ let postSubmitDeadline = 0;
+ let submitAttempts = 0;
+ let lastSubmitAt = 0;
+ let selectionAttempts = 0;
+ let lastSelectionAt = 0;
+ let emptyStateSince = 0;
+ let searchAttempted = false;
+ let sawChoice = false;
+ let selectedProjectId = normalizedId;
+ const closeDrawer = async () => {
+ const close = await firstVisible(page, ['[data-cloud-import-close="1"]']);
+ if (close) await humanClick(close, page).catch(() => {});
+ else await page.keyboard.press('Escape').catch(() => {});
+ await sleep(400);
+ };
+ while (Date.now() < deadline || (submitAttempts > 0 && Date.now() < postSubmitDeadline)) {
+ const state = await page.evaluate((preferredId) => {
+ 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 checked = element => {
+ const input = element.matches && element.matches('input[type="checkbox"]')
+ ? element
+ : element.querySelector && element.querySelector('input[type="checkbox"]');
+ return !!((input && input.checked)
+ || element.getAttribute('aria-checked') === 'true'
+ || element.classList.contains('mat-mdc-checkbox-checked')
+ || element.classList.contains('mat-checkbox-checked'));
+ };
+ document.querySelectorAll('[data-cloud-import-submit], [data-cloud-import-choice], [data-cloud-import-close]')
+ .forEach(element => {
+ element.removeAttribute('data-cloud-import-submit');
+ element.removeAttribute('data-cloud-import-choice');
+ element.removeAttribute('data-cloud-import-close');
+ });
+ const structuralRoots = [...document.querySelectorAll([
+ 'ms-import-projects-drawer',
+ 'ms-import-project-drawer',
+ '[class*="import-project"][class*="drawer"]',
+ 'aside',
+ 'mat-drawer',
+ '.mat-drawer',
+ '.cdk-overlay-pane',
+ '[role="dialog"]',
+ ].join(', '))];
+ // 新版侧栏有时不是 mat-drawer/aside。以“Import projects”标题为锚点向上找
+ // 同时包含搜索框、复选框和按钮的最小容器,禁止直接用整个 body 判定 Imported。
+ const headingRoots = [...document.querySelectorAll('h1, h2, h3, [role="heading"]')]
+ .filter(visible)
+ .filter(element => /^(?:Import projects|导入项目|Importar projetos)$/i
+ .test((element.innerText || element.textContent || '').trim()))
+ .map(heading => {
+ let node = heading.parentElement;
+ while (node && node !== document.body) {
+ if (node.querySelector('input[placeholder*="project" i], input[type="search"], input[type="text"]')
+ && node.querySelector('input[type="checkbox"], [role="checkbox"], mat-checkbox')
+ && node.querySelector('button, [role="button"]')) return node;
+ node = node.parentElement;
+ }
+ return null;
+ })
+ .filter(Boolean);
+ const roots = [...new Set([...headingRoots, ...structuralRoots])].filter(visible)
+ .filter(element => /Import projects|导入项目|Importar projetos/i.test(element.innerText || element.textContent || ''))
+ .filter(element => element.querySelector('button, input[type="checkbox"], [role="checkbox"], mat-checkbox'))
+ .sort((a, b) => {
+ const ar = a.getBoundingClientRect();
+ const br = b.getBoundingClientRect();
+ return (ar.width * ar.height) - (br.width * br.height);
+ });
+ const bodyText = document.body ? (document.body.innerText || document.body.textContent || '') : '';
+ const bodyFallback = visible(document.body)
+ && /Import projects|导入项目|Importar projetos/i.test(bodyText)
+ && document.body.querySelector('button, input[type="checkbox"], [role="checkbox"], mat-checkbox')
+ ? document.body
+ : null;
+ const importSuccessToast = /Projects? imported successfully|项目导入成功|Projetos? importados? com sucesso/i
+ .test(bodyText);
+ const drawer = roots[0] || bodyFallback;
+ if (!drawer) return {
+ drawerVisible: false,
+ imported: false,
+ importSuccessToast,
+ noProjects: false,
+ selectedCount: 0,
+ hasChoice: false,
+ submitEnabled: false,
+ };
+ const drawerText = drawer.innerText || drawer.textContent || '';
+ const imported = /\bImported\b|已导入|Importado/i.test(drawerText);
+ const noProjects = /No (?:matching |Cloud )?projects?(?: found| available| to import)?|没有可导入.*项目|未找到.*项目|Nenhum projeto/i.test(drawerText);
+ const searchInput = [...drawer.querySelectorAll('input[placeholder*="project" i], input[type="search"]')]
+ .find(visible) || null;
+ if (searchInput) searchInput.setAttribute('data-cloud-import-search', '1');
+ // Angular Material 的原生 input 通常不可见,真正可点击的是外层 mat-checkbox。
+ // 先归一化为可见控件并去重,再排除“Select all”,避免误点全选或点到隐藏 input。
+ const controls = [...new Set(
+ [...drawer.querySelectorAll('mat-checkbox, [role="checkbox"], input[type="checkbox"]')]
+ .map(element => {
+ if (visible(element)) return element;
+ const outer = element.closest('mat-checkbox, [role="checkbox"], label');
+ return visible(outer) ? outer : null;
+ })
+ .filter(Boolean),
+ )];
+ const controlText = control => {
+ const own = `${control.getAttribute('aria-label') || ''} ${control.innerText || control.textContent || ''}`.trim();
+ if (own) return own;
+ const row = control.closest('label, li, tr, mat-list-option, [role="option"]');
+ return row ? (row.innerText || row.textContent || '') : '';
+ };
+ const choices = controls.filter(control => !/^(?:un)?select all\b|全选|取消全选|selecionar tudo/i
+ .test(controlText(control).trim()));
+ const selectedCount = choices.filter(checked).length;
+ const preferredChoice = preferredId
+ ? choices.find(control => controlText(control).includes(preferredId))
+ : null;
+ const choice = preferredChoice || choices.find(control => !checked(control)) || choices[0] || null;
+ const choiceText = choice ? controlText(choice) : '';
+ const choiceProjectId = (choiceText.match(/\b[a-z][a-z0-9-]{4,61}[a-z0-9]\b/gi) || [])
+ .find(value => value.includes('-') && /\d/.test(value)) || '';
+ if (choice) choice.setAttribute('data-cloud-import-choice', '1');
+ const buttons = [...drawer.querySelectorAll('button, [role="button"]')].filter(visible);
+ const close = buttons.find(button => {
+ const ariaLabel = (button.getAttribute('aria-label') || '').trim();
+ const title = (button.getAttribute('title') || '').trim();
+ const text = (button.innerText || button.textContent || '').trim();
+ const icon = button.querySelector('mat-icon, .material-icons, .material-symbols-outlined');
+ const iconText = icon ? (icon.getAttribute('fonticon') || icon.textContent || '').trim() : '';
+ return /^(?:close|dismiss|关闭|fechar)$/i.test(ariaLabel)
+ || /^(?:close|dismiss|关闭|fechar)$/i.test(title)
+ || /^(?:close|clear)$/i.test(iconText)
+ || /^(?:×|✕|✖)$/i.test(text);
+ });
+ if (close) close.setAttribute('data-cloud-import-close', '1');
+ const submit = buttons.find(button => {
+ const text = (button.innerText || button.textContent || '').replace(/[↵\n\r]+/g, ' ').trim();
+ return /^(?:Import|导入|Importar)(?:\s|$)/i.test(text)
+ && !/projects|项目|projetos/i.test(text.replace(/^(?:Import|导入|Importar)\s*/i, ''));
+ });
+ if (submit) submit.setAttribute('data-cloud-import-submit', '1');
+ const submitEnabled = !!(submit && !(submit.disabled
+ || submit.getAttribute('aria-disabled') === 'true'
+ || submit.getAttribute('data-disabled') === 'true'));
+ return {
+ drawerVisible: true,
+ imported: imported && !submitEnabled,
+ importSuccessToast,
+ noProjects,
+ selectedCount,
+ hasChoice: !!choice,
+ choiceProjectId,
+ searchAvailable: !!searchInput,
+ submitEnabled,
+ };
+ }, normalizedId).catch(() => ({ drawerVisible: false, imported: false, importSuccessToast: false, noProjects: false, selectedCount: 0, hasChoice: false, submitEnabled: false }));
+
+ if (state.hasChoice) sawChoice = true;
+ if (state.choiceProjectId) selectedProjectId = String(state.choiceProjectId).trim();
+
+ if (submitAttempts > 0 && (!state.drawerVisible || state.importSuccessToast)) {
+ if (log) log('[AIStudio] Cloud 项目导入完成');
+ return { ok: true, projectId: selectedProjectId, msg: '' };
+ }
+ // 未实际点击过 Import 主按钮前,页面其它节点或隐藏文案里的 Imported 不能作为成功依据。
+ if (state.imported && submitAttempts > 0) {
+ if (log) log('[AIStudio] Cloud 项目已显示为 Imported');
+ await closeDrawer();
+ return { ok: true, projectId: selectedProjectId, msg: '' };
+ }
+ if (state.drawerVisible && state.noProjects && state.selectedCount === 0 && !state.hasChoice) {
+ if (!emptyStateSince) emptyStateSince = Date.now();
+ // 抽屉会先短暂显示 No projects,随后才异步填入候选项目。已有明确 project ID
+ // 的同步重试必须用完整等待窗口持续轮询;首次检查也至少要求空状态稳定 2.5 秒,
+ // 避免候选刚到就被错误判空并关闭侧栏。
+ if (!normalizedId && Date.now() - emptyStateSince >= 2500) {
+ await closeDrawer();
+ return { ok: false, noProjects: true, msg: 'AI Studio Import projects 中没有可用 Cloud 项目' };
+ }
+ // 推荐列表持续为空但我们持有上轮确切 project ID 时,再使用页面提供的搜索兜底。
+ // 已经可见的候选永远不搜索,保持用户确认过的“直接勾选并 Import”路径。
+ if (normalizedId && !searchAttempted && state.searchAvailable
+ && Date.now() - emptyStateSince >= 2500) {
+ const search = await firstVisible(page, ['[data-cloud-import-search="1"]']);
+ if (search) {
+ await search.fill(searchTerm).catch(() => {});
+ // 部分新版抽屉只有按 Enter 才真正发起服务端搜索;仅 fill 会一直保留
+ // “No projects found”旧状态,从而误判刚创建的项目不存在。
+ await search.press('Enter').catch(() => {});
+ searchAttempted = true;
+ emptyStateSince = 0;
+ deadline = Math.max(deadline, Date.now() + 12000);
+ if (log) log(`[AIStudio] 推荐列表无指定项目,按${normalizedProjectName ? '项目名称' : ' project ID'} ${searchTerm} 精确搜索...`);
+ await sleep(1200);
+ continue;
+ }
+ }
+ } else {
+ emptyStateSince = 0;
+ }
+ if (state.selectedCount > 0 && state.submitEnabled
+ && (submitAttempts === 0 || (submitAttempts < 2 && Date.now() - lastSubmitAt >= 6000))) {
+ const submit = await firstVisible(page, ['[data-cloud-import-submit="1"]']);
+ if (submit && await humanClick(submit, page)) {
+ submitAttempts++;
+ lastSubmitAt = Date.now();
+ postSubmitDeadline = Date.now() + 12000;
+ if (log) log(`[AIStudio] 已点击 Import 主按钮${normalizedId ? `(Cloud 项目 ${normalizedId})` : ''},等待导入完成...`);
+ await sleep(1000);
+ continue;
+ }
+ }
+ if (state.selectedCount === 0 && state.hasChoice
+ && (selectionAttempts === 0 || (selectionAttempts < 3 && Date.now() - lastSelectionAt >= 1200))) {
+ const choice = await firstVisible(page, ['[data-cloud-import-choice="1"]']);
+ if (choice && await humanClick(choice, page)) {
+ selectionAttempts++;
+ lastSelectionAt = Date.now();
+ deadline = Math.max(deadline, Date.now() + 12000);
+ if (log) log(`[AIStudio] 已点击待导入项目复选框(第 ${selectionAttempts}/3 次),等待选中状态...`);
+ await sleep(700);
+ continue;
+ }
+ }
+ await sleep(600);
+ }
+ if (!normalizedId && !sawChoice && submitAttempts === 0) {
+ await closeDrawer();
+ return { ok: false, noProjects: true, msg: 'AI Studio Import projects 中没有可用 Cloud 项目' };
+ }
+ if (normalizedId && searchAttempted && !sawChoice && submitAttempts === 0) {
+ await closeDrawer();
+ return {
+ ok: false,
+ noProjects: true,
+ exactSearchMiss: true,
+ projectId: normalizedId,
+ projectName: normalizedProjectName,
+ msg: `AI Studio 中未找到 Cloud 项目 ${searchTerm}`,
+ };
+ }
+ return { ok: false, noProjects: false, msg: 'AI Studio Import projects 中未能完成项目导入' };
+}
+
+/** 轮询等待创建密钥按钮变为可点。 */
+async function waitCreateKeyEnabled(page, timeoutMs) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ if (!(await isCreateKeyDisabled(page))) return true;
+ await sleep(600);
+ }
+ return false;
+}
+
+/**
+ * lateExisting 恢复专用:API Keys 顶部按钮会早于表格数据出现,刷新后立即读一次 0
+ * 不能证明线上没有 Key。短时轮询到行出现或窗口结束,避免 0→1 抖动再次误进创建框。
+ */
+async function waitForLateExistingRows(page, timeoutMs = 5000) {
+ const deadline = Date.now() + Math.max(500, Number(timeoutMs) || 5000);
+ while (Date.now() < deadline) {
+ const count = await countRowMenuButtons(page);
+ if (count > 0) return count;
+ await sleep(500);
+ }
+ return 0;
+}
+
+/**
+ * 新建项目后等待“创建 key”就绪。建项目会带 ?project= 跳转 + 重渲染创建框,
+ * 单看按钮 disabled 不够:框回来但没自动选中新项目 → 主动打开下拉选中它;
+ * 框跳没了 → 重新打开;最终等 Create key 变可点。
+ */
+async function waitCreateReadyAfterProject(page, log, timeoutMs = 45000, preferredProjectId = '') {
+ const deadline = Date.now() + timeoutMs;
+ let reopened = false;
+ let reselected = false;
+ while (Date.now() < deadline) {
+ // 新版页面会在视觉和交互均已就绪时仍保留 data-disabled="true"。项目文本已经
+ // 真实选中就是足够的前置条件,后续以 Key 是否生成作为最终校验。
+ if (await hasRealProjectSelected(page)) {
+ if (log) log('[AIStudio] 新建项目已确认选中,继续创建 Key');
+ return true;
+ }
+ if (!(await isCreateKeyDisabled(page))) return true; // 按钮明确可点=就绪
+ const dlg = await findCreationDialog(page);
+ if (!dlg) {
+ // 创建框跳转后没自动回来:重开一次(此时项目已存在)。
+ if (!reopened) {
+ const btn = await findCreateApiKeyButton(page);
+ if (btn) { await trustedClick(page, btn, log); reopened = true; await sleep(1200); }
+ }
+ await sleep(800);
+ continue;
+ }
+ // 框在但没选中真实项目:打开下拉选中刚建好的项目。
+ if (!reselected && !(await hasRealProjectSelected(page))) {
+ const sel = await firstVisible(dlg, ['mat-select[role="combobox"]', 'mat-select', '[role="combobox"]']);
+ if (sel) {
+ await sel.click({ timeout: 5000 }).catch(() => {});
+ await sleep(1200);
+ let c = await classifyProjectOptions(page, preferredProjectId);
+ for (let i = 0; i < 6 && c.total === 0; i++) { await sleep(1000); c = await classifyProjectOptions(page, preferredProjectId); }
+ const realIndex = c.preferredRealIndex >= 0 ? c.preferredRealIndex : c.realIndex;
+ if (realIndex >= 0) {
+ const opt = await firstVisible(page, [`[data-aistudio-opt="${realIndex}"]`]);
+ if (opt) {
+ await opt.click({ timeout: 5000 }).catch(() => {});
+ await sleep(1200);
+ reselected = true;
+ if (log) log('[AIStudio] 已选中新建的项目');
+ if (await hasRealProjectSelected(page)) return true;
+ }
+ } else {
+ await page.keyboard.press('Escape').catch(() => {});
+ }
+ }
+ }
+ await sleep(800);
+ }
+ return false;
+}
+
+/**
+ * 在创建对话框内选中一个可用项目。
+ * (A) 新版:对话框内 mat-select 项目下拉(选项 mat-option;真实项目 vs .action-item 动作项)。
+ * (B) 旧版兜底:其它 mat-select 结构(选项含真实项目或「Create project」动作)。
+ * @returns {Promise<{ok:boolean, created:boolean, msg:string}>}
+ */
+async function ensureProjectSelected(
+ page,
+ log,
+ projectName,
+ preferredProjectId = '',
+ allowMissingPreferredProjectRecovery = false,
+ preferredProjectName = '',
+) {
+ // —— 先走新版 UI:对话框内项目下拉 —— //
+ const newUi = await selectProjectInNewUi(
+ page,
+ log,
+ projectName,
+ preferredProjectId,
+ allowMissingPreferredProjectRecovery,
+ preferredProjectName,
+ );
+ // 必须原样保留 projectSyncPending / retryAfterMs / preferredProjectId。
+ // 若这里只挑选 ok/created/msg,外层就会把“挂起 30 秒”误当普通失败,
+ // 账号不会进入非阻塞延迟队列,并且会立刻写入 failed 流程日志。
+ if (newUi.handled) return newUi;
+
+ // —— 兜底:旧版 mat-select 下拉 —— //
+ const projectSelect = await firstVisible(page, [
+ 'mat-dialog-container mat-select',
+ 'mat-dialog-container [role="combobox"]',
+ 'mat-dialog-container .mat-mdc-select-trigger',
+ '[role="dialog"] mat-select',
+ '[role="dialog"] [role="combobox"]',
+ '[role="dialog"] .mat-mdc-select-trigger',
+ ]);
+ if (!projectSelect) return { ok: false, created: false, msg: '未找到项目选择控件' };
+ await projectSelect.click({ timeout: 5000 }).catch(() => {});
+ await sleep(1200);
+
+ // 分类下拉选项:真实项目 vs 动作行(Create project / Import)vs 占位。
+ const classified = await page.evaluate(() => {
+ const iconText = element => (element.getAttribute('fonticon') || element.textContent || '').trim();
+ const isVisible = 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 options = [...document.querySelectorAll('.cdk-overlay-container [role="option"], .cdk-overlay-container mat-option, mat-option')]
+ .filter(isVisible);
+ let realIndex = -1;
+ let createIndex = -1;
+ options.forEach((option, index) => {
+ const icons = [...option.querySelectorAll('mat-icon, .material-icons, .material-symbols-outlined, [fonticon]')];
+ const hasAdd = icons.some(icon => iconText(icon) === 'add');
+ const text = (option.innerText || option.textContent || '').trim();
+ const isPlaceholder = /No Cloud Projects|Nenhum projeto/i.test(text);
+ const isImport = /Import/i.test(text) && icons.some(icon => /import|drive/i.test(iconText(icon)));
+ option.setAttribute('data-aistudio-opt', String(index));
+ if (hasAdd || /Create project|Criar projeto/i.test(text)) {
+ if (createIndex === -1) createIndex = index;
+ } else if (!isPlaceholder && !isImport && text) {
+ if (realIndex === -1) realIndex = index;
+ }
+ });
+ return { realIndex, createIndex, total: options.length };
+ }).catch(() => ({ realIndex: -1, createIndex: -1, total: 0 }));
+
+ if (classified.realIndex >= 0) {
+ const opt = await firstVisible(page, [`[data-aistudio-opt="${classified.realIndex}"]`]);
+ if (opt) {
+ await opt.click({ timeout: 5000 }).catch(() => {});
+ await sleep(1000);
+ if (log) log('[AIStudio] 已选中现有项目');
+ return { ok: true, created: false, msg: '' };
+ }
+ }
+
+ if (classified.createIndex >= 0) {
+ const opt = await firstVisible(page, [`[data-aistudio-opt="${classified.createIndex}"]`]);
+ if (opt) {
+ await opt.click({ timeout: 5000 }).catch(() => {});
+ await sleep(1500);
+ const created = await createProject(page, log, projectName);
+ if (!created.ok) return { ok: false, created: false, msg: created.msg };
+ // 回到创建 key 对话框,等待项目自动选中、Create key 可点。
+ const waitDeadline = Date.now() + 30000;
+ while (Date.now() < waitDeadline) {
+ await sleep(800);
+ const disabled = await isCreateKeyDisabled(page);
+ if (!disabled) return { ok: true, created: true, msg: '' };
+ }
+ return { ok: true, created: true, msg: '' };
+ }
+ }
+
+ // 下拉里既无真实项目也无“创建项目”动作:关闭下拉,交由上层判断。
+ await page.keyboard.press('Escape').catch(() => {});
+ return { ok: false, created: false, msg: '下拉中无可用项目也无创建入口' };
+}
+
+/** 在“Create a new project”对话框填名并确认。 */
+async function createProject(page, log, projectName, settleTimeoutMs = 15000) {
+ // 该对话框是新的最上层 dialog;只在“最上层对话框”里找输入框,
+ // 避免抓到底下“创建 key”框的名字输入框(两个框都有 input)。
+ const deadline = Date.now() + 15000;
+ let input = null;
+ while (Date.now() < deadline) {
+ const marked = await page.evaluate(() => {
+ 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;
+ };
+ document.querySelectorAll('[data-aistudio-proj-input], [data-aistudio-project-dialog]')
+ .forEach(element => {
+ element.removeAttribute('data-aistudio-proj-input');
+ element.removeAttribute('data-aistudio-project-dialog');
+ });
+ const dialogs = [...document.querySelectorAll('mat-dialog-container, .mat-mdc-dialog-container, [role="dialog"], [aria-modal="true"]')]
+ .filter(isVisible);
+ if (!dialogs.length) return false;
+ const top = dialogs[dialogs.length - 1];
+ const inp = top.querySelector('input[type="text"], input');
+ if (!inp) return false;
+ top.setAttribute('data-aistudio-project-dialog', '1');
+ inp.setAttribute('data-aistudio-proj-input', '1');
+ return true;
+ }).catch(() => false);
+ if (marked) input = await firstVisible(page, ['[data-aistudio-proj-input="1"]']);
+ if (input) break;
+ await sleep(500);
+ }
+ if (!input) return { ok: false, msg: '未找到新建项目输入框' };
+ const configuredName = String(projectName || DEFAULT_PROJECT_NAME).trim() || DEFAULT_PROJECT_NAME;
+ const name = isValidCloudProjectDisplayName(configuredName) ? configuredName : DEFAULT_PROJECT_NAME;
+ // 文档截图中非英文/不合规默认项目名会让 Create project 不可用;已有合法英文默认名保持
+ // 不动,空值或不合规值才替换成稳定英文名。
+ const existing = await input.inputValue().catch(() => '');
+ if (!isValidCloudProjectDisplayName(existing)) {
+ await humanType(input, page, name);
+ await sleep(300);
+ }
+ await sleep(400);
+ page.__aistudioProjectCreateResponse = null;
+ const submittedAt = Date.now();
+ const confirmed = await clickDialogDangerButton(page, log);
+ if (!confirmed) return { ok: false, msg: '未找到新建项目确认按钮' };
+ if (log) log('[AIStudio] 已提交创建项目');
+ // 直接等 CreateCloudProject RPC 或项目弹窗关闭。过去 6 秒内没捕获到 RPC 就直接返回
+ // success,导致仍在转圈的“Create a new project”弹窗被当成创建完成,随后把右上角 32x32
+ // 关闭按钮误认成 Create key。现在必须等到明确响应或弹窗真正关闭。
+ const responseDeadline = Date.now() + Math.max(500, Number(settleTimeoutMs) || 15000);
+ let promptChecks = 0;
+ while (Date.now() < responseDeadline) {
+ const response = page.__aistudioProjectCreateResponse;
+ if (response && response.at >= submittedAt) {
+ if (response.status >= 400) {
+ return { ok: false, msg: `新建项目请求被拒绝(CreateCloudProject HTTP ${response.status})` };
+ }
+ }
+ const projectDialogVisible = await page.evaluate(() => {
+ const dialog = document.querySelector('[data-aistudio-project-dialog="1"]');
+ if (!dialog) return false;
+ const style = window.getComputedStyle(dialog);
+ const rect = dialog.getBoundingClientRect();
+ return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
+ }).catch(() => false);
+ if (!projectDialogVisible) return { ok: true, msg: '' };
+ // 某些失败只渲染明确的引导弹窗,响应监听可能因页面重渲染而未捕获。
+ // 截图实测文案:Unable to create API key ... create a project in the Google Cloud Console.
+ if ((promptChecks++ % 4) === 0) {
+ const prompted = await page.evaluate(() => /Unable to create API key[\s\S]{0,300}(?:Google Cloud project|Google Cloud Console)/i
+ .test(document.body ? (document.body.innerText || '') : '')).catch(() => false);
+ if (prompted) return { ok: false, msg: 'Unable to create API key:AI Studio 无法代建 Google Cloud 项目' };
+ }
+ await sleep(250);
+ }
+ return { ok: false, msg: '新建项目对话框持续加载超时,AI Studio 无法完成项目创建' };
+}
+
+/**
+ * 点“Create API key”→ 填 name → 处理项目 → 点“Create key”。
+ * @returns {Promise<{ok:boolean, msg:string}>}
+ */
+/** 等创建对话框内的表单加载完(loading spinner 消失 + 出现 name 输入框/项目下拉)。 */
+/** 等待创建对话框出现(真实容器 mat-dialog-container / 组件 ms-api-key-creation-dialog)。 */
+async function hasBlockingConsentDialog(page) {
+ return page.evaluate((additionalConsentPattern) => {
+ const additionalConsentRe = new RegExp(additionalConsentPattern, 'i');
+ const visible = element => {
+ if (!element) return false;
+ const style = window.getComputedStyle(element);
+ const rect = element.getBoundingClientRect();
+ return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
+ };
+ return [...document.querySelectorAll(
+ 'mat-dialog-container, .mat-mdc-dialog-container, .cdk-overlay-pane, [role="dialog"], [aria-modal="true"]',
+ )]
+ .filter(visible)
+ .some(dialog => !dialog.querySelector('ms-api-key-creation-dialog, #api-key-name-input')
+ && (additionalConsentRe.test(dialog.innerText || dialog.textContent || '')
+ || !!dialog.querySelector('mat-checkbox, input[type="checkbox"], [role="checkbox"]')));
+ }, ADDITIONAL_CONSENT_TEXT_RE.source).catch(() => false);
+}
+
+/** 同时等待创建框或延迟条款;条款一出现就返回,避免把它当作“点击无响应”空等。 */
+async function waitForCreationDialogOrConsent(page, timeoutMs = 15000) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ if (await hasBlockingConsentDialog(page)) return { dialog: null, consent: true };
+ const dialog = await findCreationDialog(page);
+ if (dialog) return { dialog, consent: false };
+ await sleep(400);
+ }
+ return { dialog: null, consent: false };
+}
+
+async function waitForCreationDialog(page, timeoutMs = 15000) {
+ const state = await waitForCreationDialogOrConsent(page, timeoutMs);
+ return state.dialog;
+}
+
+async function waitCreationFormReady(page, timeoutMs = 15000) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ const projectResponse = page.__aistudioProjectCreateResponse;
+ if (projectResponse && projectResponse.status >= 400) return 'project_rejected';
+ // API Keys 列表可能晚于顶部按钮渲染;若创建框打开后旧 Key 行才出现,立即交给
+ // 上层回到“先删旧 Key”路径,避免重复创建或在表单里白等 15 秒。
+ if (await countRowMenuButtons(page) > 0) return 'late_existing';
+ const consentBlocking = await hasBlockingConsentDialog(page);
+ if (consentBlocking) return 'consent_required';
+ await markCreationDialog(page);
+ const state = await page.evaluate(() => {
+ 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 dialog = document.querySelector('[data-aistudio-creation-dialog="1"]');
+ if (!dialog) return { hasDialog: false, spinning: false, hasForm: false };
+ const spinner = dialog.querySelector('.spinner-container, ms-gradient-spinner');
+ const spinning = isVisible(spinner);
+ const hasForm = !!dialog.querySelector('#api-key-name-input input, input[type="text"], mat-select, [role="combobox"]');
+ return { hasDialog: true, spinning, hasForm };
+ }).catch(() => ({ hasDialog: false, spinning: false, hasForm: false }));
+ if (state.hasDialog && !state.spinning && state.hasForm) return 'ready';
+ await sleep(400);
+ }
+ return 'timeout';
+}
+
+/** 点击创建后,校验是否真的生成了 key(对话框/只读框里出现 AQ. / AIza 完整值)。 */
+async function waitKeyGenerated(page, timeoutMs = 20000) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ const generateResponse = page.__aistudioGenerateKeyResponse;
+ if (generateResponse && generateResponse.status >= 400) return false;
+ // 服务端 403 后 toast 通常几秒内就会出现;立即返回交给重试逻辑,不再空等满 20 秒。
+ if (await createFailureToastVisible(page)) return false;
+ const snap = await pageSnapshot(page);
+ const text = `${snap.dialogText}\n${(snap.readonlyValues || []).join('\n')}`;
+ if (/AQ\.[A-Za-z0-9_\-]{16,}/.test(text) || /AIza[A-Za-z0-9_\-]{20,}/.test(text)) return true;
+ for (const value of (snap.readonlyValues || [])) {
+ if (/^AQ\./.test(value) || /^AIza/.test(value)) return true;
+ }
+ await sleep(500);
+ }
+ return false;
+}
+
+/** 检测“创建失败/可疑请求”toast(英/葡):「The request is suspicious / Failed to generate API key」。 */
+async function createFailureToastVisible(page) {
+ return page.evaluate(() => {
+ const t = document.body ? (document.body.innerText || '') : '';
+ return /request is suspicious|Failed to generate API key|Please try again|suspeita|tente novamente/i.test(t);
+ }).catch(() => false);
+}
+
+/** 关掉残留的创建对话框和 toast,为下一次重试腾出干净页面。 */
+async function dismissCreateArtifacts(page) {
+ await page.keyboard.press('Escape').catch(() => {});
+ await sleep(300);
+ await page.keyboard.press('Escape').catch(() => {});
+ await sleep(300);
+ const closeToast = await firstVisible(page, [
+ '.cdk-overlay-container [aria-label="Close"]',
+ '.mat-mdc-snack-bar-container button',
+ '[role="alert"] button',
+ ]);
+ if (closeToast) await closeToast.click({ timeout: 2000 }).catch(() => {});
+}
+
+/**
+ * Cloud Console 建完项目返回 AI Studio 时,人工流程会先 Close“Unable to create
+ * API key”,再 Cancel 底下残留的“Create a new project”,最后从创建 Key 对话框
+ * 重新选择 Import project。本函数只处理这两层明确弹窗,不会关闭创建 Key 对话框。
+ */
+async function dismissCloudProjectFallbackDialogs(page, log = () => {}) {
+ let handled = false;
+ for (let step = 0; step < 3; step++) {
+ const kind = await page.evaluate(() => {
+ const visible = element => {
+ if (!element) return false;
+ const style = window.getComputedStyle(element);
+ const rect = element.getBoundingClientRect();
+ return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
+ };
+ document.querySelectorAll('[data-aistudio-cloud-return-action]')
+ .forEach(element => element.removeAttribute('data-aistudio-cloud-return-action'));
+ const dialogs = [...document.querySelectorAll(
+ 'mat-dialog-container, .mat-mdc-dialog-container, .cdk-dialog-container, [role="dialog"], [aria-modal="true"]',
+ )].filter(visible).reverse();
+ const markButton = (dialog, pattern, kindName) => {
+ const buttons = [...dialog.querySelectorAll('button, [role="button"]')].filter(visible);
+ const button = buttons.find(candidate => pattern.test((candidate.innerText || candidate.textContent || '').trim()))
+ || buttons.find(candidate => pattern.test((candidate.getAttribute('aria-label') || '').trim()));
+ if (!button) return '';
+ button.setAttribute('data-aistudio-cloud-return-action', '1');
+ return kindName;
+ };
+ const failure = dialogs.find(dialog => /Unable to create API key|无法创建\s*(?:API)?\s*密钥|Não foi possível criar/i
+ .test(dialog.innerText || dialog.textContent || ''));
+ if (failure) return markButton(failure, /^(?:Close|关闭|Fechar)$/i, 'failure');
+ const project = dialogs.find(dialog => /Create a new project|创建新项目|Criar um novo projeto/i
+ .test(dialog.innerText || dialog.textContent || ''));
+ if (project) return markButton(project, /^(?:Cancel|取消|Cancelar)$/i, 'project');
+ return '';
+ }).catch(() => '');
+ if (!kind) break;
+ const button = await firstVisible(page, ['[data-aistudio-cloud-return-action="1"]']);
+ if (!button || !(await humanClick(button, page))) break;
+ handled = true;
+ if (log) log(kind === 'failure'
+ ? '[AIStudio] 已关闭项目创建失败提示'
+ : '[AIStudio] 已取消残留的新建项目弹窗,准备导入 Cloud 项目');
+ await sleep(500);
+ }
+ return handled;
+}
+
+/**
+ * 创建新 key(带自动重试)。AI Studio 对“生成 key”风控极严,偶发
+ * 「The request is suspicious. Please try again.」——提示本身即表示可重试,真人再点一次多能成功。
+ * 故失败且检测到该 toast(或未生成 key)时,关掉残留框 + 退避 + 重载页面后重开创建流程再试,最多 3 次。
+ */
+async function createKey(page, log, opts) {
+ const MAX = 3;
+ const runtimeOpts = { ...opts };
+ let lastMsg = '';
+ let retryMode = 'suspicious';
+ let projectFailureCount = 0;
+ let clearedPreferredProjectId = '';
+ const withRecoveryState = result => (clearedPreferredProjectId
+ ? { ...result, clearedPreferredProjectId }
+ : result);
+ // 已携带 Cloud 项目 ID 说明上一次已完成创建,本轮只等同步/导入,严禁再次建项目。
+ let cloudConsoleFallbackTried = !!String(runtimeOpts.preferredProjectId || '').trim();
+ for (let attempt = 0; attempt < MAX; attempt++) {
+ if (attempt > 0) {
+ if (retryMode === 'cloud_console') {
+ log('[AIStudio] Cloud 项目已创建,返回 AI Studio 导入并继续创建 Key...');
+ page.__aistudioProjectCreateResponse = null;
+ page.__aistudioGenerateKeyResponse = null;
+ await sleep(1200);
+ // 若浏览器保留了原 AI Studio 标签页/弹窗,严格按流程文档 Close → Cancel;
+ // 当前实现使用同页导航返回时通常已是干净页面,因此此操作也是幂等的。
+ await dismissCloudProjectFallbackDialogs(page, log);
+ } else {
+ await dismissCreateArtifacts(page);
+ }
+ if (retryMode === 'project') {
+ log(`[AIStudio] 项目列表/选择状态异常,刷新页面后重试第 ${attempt} 次...`);
+ await sleep(1000 + attempt * 700);
+ page.__aistudioProjectCreateResponse = null;
+ const ready = await gotoApiKeys(page, 30000, runtimeOpts.preferredProjectId);
+ if (!ready) log('[AIStudio] ⚠️ 刷新后页面尚未完全就绪,继续尝试创建流程');
+ } else if (retryMode !== 'cloud_console') {
+ log(`[AIStudio] 创建被拦(suspicious/未生成),退避后重试第 ${attempt} 次...`);
+ // 同页直接重开创建框通常可恢复,保留递增退避给 botguard 降温。
+ await sleep(2500 + attempt * 1500);
+ }
+ }
+ const r = await createKeyOnce(page, log, runtimeOpts);
+ if (r.ok) {
+ if (attempt > 0) log(`[AIStudio] 重试第 ${attempt} 次创建成功`);
+ return withRecoveryState(r);
+ }
+ if (r.projectSyncPending) {
+ return withRecoveryState({
+ ...r,
+ retryAfterMs: Number(r.retryAfterMs) || PROJECT_SYNC_RETRY_DELAY_MS,
+ preferredProjectId: String(r.preferredProjectId || runtimeOpts.preferredProjectId || '').trim(),
+ preferredProjectName: String(r.preferredProjectName || runtimeOpts.preferredProjectName || '').trim(),
+ });
+ }
+ // 已保存的旧项目 ID 经推荐列表和精确搜索双重确认均不存在。
+ // 仅清除本次运行的项目限制,让下一轮按“无项目”正常创建;新项目若仍需
+ // Google 同步,会返回新的 preferredProjectId 并由调度层覆盖持久化记录。
+ if (r.preferredProjectMissing && String(runtimeOpts.preferredProjectId || '').trim()) {
+ const missingProjectId = String(runtimeOpts.preferredProjectId).trim();
+ log(`[AIStudio] 旧 Cloud 项目 ${missingProjectId} 已不存在,改为创建新项目...`);
+ clearedPreferredProjectId = clearedPreferredProjectId || missingProjectId;
+ runtimeOpts.preferredProjectId = '';
+ runtimeOpts.preferredProjectName = '';
+ runtimeOpts.allowMissingPreferredProjectRecovery = false;
+ cloudConsoleFallbackTried = false;
+ projectFailureCount = 0;
+ retryMode = 'project';
+ lastMsg = '';
+ continue;
+ }
+ // 创建流程开始后列表才出现 Key:交给外层直接读取,严禁再次删除重建。
+ if (r.lateExisting) return withRecoveryState(r);
+ lastMsg = r.msg;
+ // AI Studio 内部 MakerSuiteService/CreateCloudProject 返回 403 时,刷新同一页面没有意义。
+ // 复刻本轮人工成功路径:去 Cloud Console 建项目,再回 AI Studio 导入并继续。
+ if (needsCloudConsoleProjectFallback(r.msg) && !cloudConsoleFallbackTried) {
+ cloudConsoleFallbackTried = true;
+ const fallback = await createProjectViaCloudConsole(page, log, {
+ projectName: runtimeOpts.projectName,
+ timeoutMs: 90000,
+ });
+ if (!fallback.ok) {
+ return withRecoveryState({ ok: false, msg: `${r.msg};Cloud Console 兜底失败:${fallback.msg}` });
+ }
+ runtimeOpts.preferredProjectId = fallback.projectId;
+ runtimeOpts.preferredProjectName = fallback.projectName;
+ // Cloud Console 本轮刚创建的项目尚未出现在 Import projects 很正常,
+ // 精确搜索为空时必须挂起,不能再把它判成旧项目而重复创建。
+ runtimeOpts.allowMissingPreferredProjectRecovery = false;
+ retryMode = 'cloud_console';
+ lastMsg = '';
+ continue;
+ }
+ // 项目列表空/状态未确认多数是瞬时 403 或重渲染未完成,刷新页面可恢复;真正的
+ // DOM 结构性错误(找不到顶部按钮等)仍立即返回。
+ const projectRetryable = /项目下拉中无可用项目|无法确认项目|项目未就绪|项目列表加载失败|选中项目|CreateCloudProject|新建项目请求被拒绝|新建项目对话框|创建表单加载|未找到项目选择控件|导入列表|导入 Cloud 项目/.test(r.msg || '');
+ const retryable = projectRetryable || await createFailureToastVisible(page)
+ || /未生成新 key|表单加载|附加条款|重新打开创建表单/.test(r.msg || '');
+ if (!retryable) return withRecoveryState(r);
+ if (projectRetryable) {
+ projectFailureCount++;
+ // Cloud Console 兜底后仍出现的项目列表/选择错误只保留一次刷新机会,
+ // 避免单个确定失败账号占用高风险槽数分钟。
+ if (projectFailureCount >= 2) {
+ return withRecoveryState({ ok: false, msg: `${r.msg}(刷新重试后仍失败,建议更换 IP/浏览器环境)` });
+ }
+ retryMode = 'project';
+ } else {
+ retryMode = 'suspicious';
+ }
+ }
+ const suffix = retryMode === 'project'
+ ? '(项目流程重试后仍失败,建议更换 IP/浏览器环境)'
+ : `(已尝试 ${MAX} 次仍被判为可疑请求 The request is suspicious)`;
+ return withRecoveryState({ ok: false, msg: `${lastMsg || '创建失败'}${suffix}` });
+}
+
+async function createKeyOnce(page, log, {
+ projectName,
+ keyName,
+ preferredProjectId = '',
+ allowMissingPreferredProjectRecovery = false,
+ preferredProjectName = '',
+}) {
+ const createTopBtn = await findCreateApiKeyButton(page);
+ if (!createTopBtn) return { ok: false, msg: '未找到「Create API key」按钮' };
+
+ // 鼠标校准成功时会保留已经打开的创建框,直接复用,避免关闭后再次打开并重复请求项目列表。
+ let dialog = await findCreationDialog(page);
+ if (dialog) log('[AIStudio] 复用鼠标校准时已打开的创建对话框');
+ if (!dialog) {
+ // 打开创建框(OS 光标优先,其行为受 page.__osDisabled 约束)。
+ await trustedClick(page, createTopBtn, log);
+ const opened = await waitForCreationDialogOrConsent(page, 7000);
+ if (opened.consent) {
+ await dismissWelcomeConsent(page, log);
+ return { ok: false, msg: 'AI Studio 延迟附加条款已处理,需要重新打开创建表单' };
+ }
+ dialog = opened.dialog;
+ }
+ if (!dialog) {
+ // 没弹出(点偏或页面未响应):humanClick 兜底重开一次。
+ await humanClick(createTopBtn, page).catch(() => {});
+ const reopened = await waitForCreationDialogOrConsent(page, 5000);
+ if (reopened.consent) {
+ await dismissWelcomeConsent(page, log);
+ return { ok: false, msg: 'AI Studio 延迟附加条款已处理,需要重新打开创建表单' };
+ }
+ dialog = reopened.dialog;
+ }
+ if (!dialog) return { ok: false, msg: '创建对话框未出现' };
+
+ // 关键修复:等表单 spinner 加载完再操作,避免在“转圈加载项目”时就点创建(导致空转/403)。
+ const formState = await waitCreationFormReady(page, 15000);
+ if (formState === 'project_rejected') {
+ const status = page.__aistudioProjectCreateResponse?.status || 403;
+ return { ok: false, msg: `项目初始化请求被拒绝(CreateCloudProject HTTP ${status})` };
+ }
+ if (formState === 'late_existing') {
+ await dismissCreateArtifacts(page);
+ return { ok: false, lateExisting: true, msg: 'API Key 列表数据晚到,检测到已有 Key,需先删除旧 Key' };
+ }
+ if (formState === 'consent_required') {
+ await dismissWelcomeConsent(page, log);
+ return { ok: false, msg: 'AI Studio 附加条款已处理,需要重新打开创建表单' };
+ }
+ if (formState !== 'ready') {
+ return { ok: false, msg: '创建表单加载失败(可能项目列表请求被限流/403)' };
+ }
+
+ // 填 name。真实 DOM 确认:name 输入框在 ms-input-field#api-key-name-input 内。默认值可能已填。
+ const nameInput = await firstVisible(page, [
+ '#api-key-name-input input',
+ 'ms-api-key-creation-dialog input[type="text"]',
+ 'mat-dialog-container input[type="text"]',
+ '[role="dialog"] input[type="text"]',
+ 'mat-dialog-container input',
+ ]);
+ if (nameInput) {
+ // 用户实测:默认名不动、直接点“创建”即可成功。故仅当名字框为空才补填,
+ // 不覆盖已有默认名,尽量贴近“只点一下创建”的手工成功路径,减少多余输入扰动。
+ const existingName = await nameInput.inputValue().catch(() => '');
+ if (!existingName) {
+ await humanType(nameInput, page, String(keyName || DEFAULT_KEY_NAME));
+ await sleep(300);
+ }
+ }
+
+ // 处理项目:仅当没有真实项目被选中时,才去选/建(无项目会自动新建,已有项目一般会自动选中)。
+ let projectReady = await hasRealProjectSelected(page);
+ if (!projectReady) {
+ const project = await ensureProjectSelected(
+ page,
+ log,
+ projectName,
+ preferredProjectId,
+ allowMissingPreferredProjectRecovery,
+ preferredProjectName,
+ );
+ if (!project.ok) {
+ return {
+ ok: false,
+ msg: `处理项目失败:${project.msg}`,
+ projectSyncPending: !!project.projectSyncPending,
+ preferredProjectMissing: !!project.preferredProjectMissing,
+ retryAfterMs: Number(project.retryAfterMs) || 0,
+ preferredProjectId: String(project.preferredProjectId || preferredProjectId || '').trim(),
+ preferredProjectName: String(project.preferredProjectName || preferredProjectName || '').trim(),
+ };
+ }
+ // 导入/新建项目会让 Angular 再次重渲染创建框。提交前必须再等一次
+ // spinner 消失和表单恢复,避免在“New project created!”后的转圈中
+ // 误报找不到 Create key。
+ const postProjectFormState = await waitCreationFormReady(page, 15000);
+ if (postProjectFormState === 'project_rejected') {
+ const status = page.__aistudioProjectCreateResponse?.status || 403;
+ return { ok: false, msg: `项目初始化请求被拒绝(CreateCloudProject HTTP ${status})` };
+ }
+ if (postProjectFormState === 'late_existing') {
+ await dismissCreateArtifacts(page);
+ return { ok: false, lateExisting: true, msg: 'API Key 列表数据晚到,检测到已有 Key,需先删除旧 Key' };
+ }
+ if (postProjectFormState === 'consent_required') {
+ await dismissWelcomeConsent(page, log);
+ return { ok: false, msg: 'AI Studio 附加条款已处理,需要重新打开创建表单' };
+ }
+ if (postProjectFormState !== 'ready') {
+ return { ok: false, msg: '项目创建后创建表单加载未完成,需要刷新后重试' };
+ }
+ // 选完项目 name 输入框可能重置,补填一次。
+ if (nameInput) {
+ const current = await nameInput.inputValue().catch(() => '');
+ if (!current) { await humanType(nameInput, page, String(keyName || DEFAULT_KEY_NAME)); }
+ }
+ projectReady = await hasRealProjectSelected(page);
+ }
+
+ // 【关键】已有/已选中真实项目 → 直接建 key,不卡 isCreateKeyDisabled——该启发式对“已就绪但
+ // 页面在抖动/限流”的对话框会误判为不可点,导致误报“项目未就绪”。点击有尺寸护栏兜底(不会误点
+ // spinner),最终由 waitKeyGenerated 校验是否真生成。仅在“无法确认项目”时才兜底等按钮可点。
+ if (!projectReady && !(await waitCreateKeyEnabled(page, 15000))) {
+ return { ok: false, msg: '项目未就绪、创建按钮不可点(项目列表加载失败或被限流/403)' };
+ }
+ await sleep(600);
+
+ // 点“生成”前先累积一段真实鼠标活动,给高价值操作的行为风控(botguard)喂更像人的行为熵。
+ await behavioralWarmup(page);
+
+ // 页面顶部和表格可能先后渲染。最终提交前再查一次,避免旧 Key 在创建框打开后才出现。
+ if (await countRowMenuButtons(page) > 0) {
+ await dismissCreateArtifacts(page);
+ return { ok: false, lateExisting: true, msg: 'API Key 列表数据晚到,检测到已有 Key,需先删除旧 Key' };
+ }
+
+ // 再次点 Create key(对话框内最后一个按钮)——真实 OS 光标优先。
+ page.__aistudioGenerateKeyResponse = null;
+ const clicked = await clickDialogLastButton(page, log);
+ if (!clicked) return { ok: false, msg: '未能点击「Create key」按钮' };
+
+ // 校验创建是否真的生效:出现完整 key 值才算成功。否则快速失败并给准确原因,
+ // 不再让 readKeyDetails 空转 60 秒后误报“详情对话框超时”。
+ const generated = await waitKeyGenerated(page, 20000);
+ if (!generated) {
+ return { ok: false, msg: '点击创建后未生成新 key(创建表单加载失败或被服务端限流/403)' };
+ }
+ return { ok: true, msg: '' };
+}
+
+/**
+ * 只读一次当前可见的“API key details”。
+ * 人工浏览器监听器会高频调用,因此这里不导航、不点击、不等待、不输出 Key。
+ */
+async function readVisibleKeyDetailsOnce(page, keyName = DEFAULT_KEY_NAME) {
+ const snapshot = await pageSnapshot(page);
+ const text = `${snapshot.dialogText}\n${(snapshot.readonlyValues || []).join('\n')}`;
+ let apiKey = (text.match(/AQ\.[A-Za-z0-9_\-]{16,}/) || [])[0]
+ || (text.match(/AIza[A-Za-z0-9_\-]{20,}/) || [])[0]
+ || '';
+ // 只读输入框里的完整 key 优先(正则可能截断 innerText 中带省略号的值)。
+ for (const value of (snapshot.readonlyValues || [])) {
+ if (/^AQ\./.test(value) || /^AIza/.test(value)) { apiKey = value; break; }
+ }
+ if (!apiKey) return { ok: false, data: null, snapshot, text };
+
+ const projectNameMatch = (text.match(/projects\/[A-Za-z0-9_\-]+/) || [])[0] || '';
+ let projectNumber = '';
+ const numInName = projectNameMatch.match(/\d{4,}/);
+ if (numInName) projectNumber = numInName[0];
+ else {
+ const anyNum = text.match(/\b\d{6,}\b/);
+ if (anyNum) projectNumber = anyNum[0];
+ }
+ return {
+ ok: true,
+ data: {
+ apiKey,
+ keyName: String(keyName || DEFAULT_KEY_NAME),
+ projectName: projectNameMatch,
+ projectNumber,
+ },
+ snapshot,
+ text,
+ };
+}
+
+/** 从“API key details”对话框读 4 个字段(语言无关正则)。 */
+async function readKeyDetails(page, keyName, timeoutMs, log = () => {}) {
+ const deadline = Date.now() + Math.min(timeoutMs, 40000);
+ let dumped = 0;
+ while (Date.now() < deadline) {
+ const once = await readVisibleKeyDetailsOnce(page, keyName);
+ const snapshot = once.snapshot;
+ const text = once.text;
+ if (once.ok) return { ok: true, data: once.data, msg: '' };
+
+ // 诊断:前 2 次轮询把当前对话框实况(文本片段/只读值/DOM)打进日志,
+ // 定位完整 key 到底在不在 DOM 里、还是只有打码值或需点复制。
+ if (!apiKey && dumped < 2) {
+ dumped++;
+ const dbg = await page.evaluate(() => {
+ const visible = 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 dialogs = [...document.querySelectorAll(
+ 'mat-dialog-container, .mat-mdc-dialog-container, .cdk-dialog-container, .cdk-overlay-pane, [role="dialog"], [aria-modal="true"]',
+ )].filter(visible);
+ const root = dialogs.length ? dialogs[dialogs.length - 1] : null;
+ return { hasDialog: !!root, html: root ? (root.outerHTML || '').slice(0, 1800) : '(无对话框)' };
+ }).catch(() => ({ hasDialog: false, html: '(读取失败)' }));
+ log(redactText(`[AIStudio诊断] 读key第${dumped}次 有对话框=${dbg.hasDialog} 只读值=[${(snapshot.readonlyValues || []).join(' | ')}] 文本=${text.replace(/\s+/g, ' ').slice(0, 220)}`));
+ log(redactText(`[AIStudio诊断] 读key第${dumped}次DOM=${dbg.html}`));
+ }
+
+ await sleep(600);
+ }
+ return { ok: false, data: null, msg: '未能读取到 API Key(详情对话框超时)' };
+}
+
+/** 标记 API Keys 列表里的第一条真实 Key 行,并读取该行的非敏感元数据。 */
+async function markExistingKeyRow(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;
+ };
+ document.querySelectorAll('[data-aistudio-sync-row], [data-aistudio-sync-open], [data-aistudio-sync-copy]')
+ .forEach(element => {
+ element.removeAttribute('data-aistudio-sync-row');
+ element.removeAttribute('data-aistudio-sync-open');
+ element.removeAttribute('data-aistudio-sync-copy');
+ });
+ const rows = [...document.querySelectorAll('tr[role="row"], mat-row, [role="row"]')]
+ .filter(row => visible(row) && row.querySelector('ms-api-key-key-string, .key-string-link, .xap-copy-to-clipboard'));
+ const row = rows[0];
+ if (!row) return { found: false, keyName: '', projectName: '', projectId: '' };
+ row.setAttribute('data-aistudio-sync-row', '1');
+ const openButton = row.querySelector('ms-api-key-key-string button, button.key-string-link, .key-string-link');
+ const copyButton = row.querySelector('button.xap-copy-to-clipboard, button[aria-label*="Copy API key" i], button[aria-label*="复制 API 密钥"]');
+ if (openButton) openButton.setAttribute('data-aistudio-sync-open', '1');
+ if (copyButton) copyButton.setAttribute('data-aistudio-sync-copy', '1');
+ const keyCell = row.querySelector('.cdk-column-Key, .mat-column-Key, td[role="cell"]');
+ const projectCell = row.querySelector('.cdk-column-Project, .mat-column-Project');
+ const keySubTexts = keyCell ? [...keyCell.querySelectorAll('.sub-text')] : [];
+ const projectButton = projectCell && projectCell.querySelector('button, a');
+ const projectSubTexts = projectCell ? [...projectCell.querySelectorAll('.sub-text')] : [];
+ const keyName = keySubTexts.map(element => (element.innerText || element.textContent || '').trim()).find(Boolean) || '';
+ const projectName = projectButton ? (projectButton.innerText || projectButton.textContent || '').trim() : '';
+ const projectId = projectSubTexts.map(element => (element.innerText || element.textContent || '').trim())
+ .find(text => /^[a-z][a-z0-9-]{4,61}[a-z0-9]$/i.test(text)) || '';
+ return { found: true, keyName, projectName, projectId };
+ }).catch(() => ({ found: false, keyName: '', projectName: '', projectId: '' }));
+}
+
+/** 从行内“复制 API 密钥”按钮安全读取完整 Key;只返回校验通过的 Key,不输出日志。 */
+async function readExistingKeyThroughClipboard(page) {
+ const copyButton = await firstVisible(page, ['[data-aistudio-sync-copy="1"]']);
+ if (!copyButton) return '';
+ try {
+ const context = page.context && page.context();
+ if (context && typeof context.grantPermissions === 'function') {
+ await context.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: 'https://aistudio.google.com' }).catch(() => {});
+ }
+ await humanClick(copyButton, page);
+ await sleep(400);
+ const value = await page.evaluate(() => navigator.clipboard.readText()).catch(() => '');
+ const normalized = String(value || '').trim();
+ return /^(?:AQ\.[A-Za-z0-9_\-]{16,}|AIza[A-Za-z0-9_\-]{20,})$/.test(normalized) ? normalized : '';
+ } catch (_) {
+ return '';
+ }
+}
+
+/**
+ * 只同步账号线上已经存在的 AI Studio Key,不删除、不创建。
+ * 用于用户在比特浏览器中手工完成创建后,将结果补写数据库并修正流程日志。
+ */
+async function syncExistingAistudioApiKey(page, account, log = () => {}, options = {}) {
+ const timeoutMs = Math.max(10000, Number(options.timeoutMs) || 40000);
+ const fallbackKeyName = String(options.keyName || DEFAULT_KEY_NAME).trim() || DEFAULT_KEY_NAME;
+ try {
+ log('[AIStudio] 正在读取该账号已存在的人工创建 Key(不会删除或重建)...');
+ const ready = await gotoApiKeys(page, timeoutMs);
+ if (!ready) {
+ if (/accounts\.google\.com/i.test(String(page.url ? page.url() : ''))) {
+ return { ok: false, data: null, msg: 'AI Studio 要求重新登录 Google' };
+ }
+ return { ok: false, data: null, msg: 'AI Studio API Keys 页面未就绪' };
+ }
+ const consentHandled = await dismissWelcomeConsent(page, log);
+ if (consentHandled) await waitForApiKeysReady(page, 15000);
+
+ let row = await markExistingKeyRow(page);
+ if (!row.found) {
+ await sleep(1500);
+ row = await markExistingKeyRow(page);
+ }
+ if (!row.found) return { ok: false, data: null, msg: '该账号的 AI Studio 列表中未找到可同步的 API Key' };
+
+ const openButton = await firstVisible(page, ['[data-aistudio-sync-open="1"]']);
+ if (openButton) {
+ await humanClick(openButton, page);
+ const details = await readKeyDetails(page, row.keyName || fallbackKeyName, 7000, log);
+ if (details.ok && details.data && details.data.apiKey) {
+ details.data.keyName = row.keyName || details.data.keyName || fallbackKeyName;
+ details.data.projectName = details.data.projectName || row.projectName || row.projectId;
+ log('[AIStudio] ✅ 已读取人工创建的 API Key');
+ return details;
+ }
+ await page.keyboard.press('Escape').catch(() => {});
+ await sleep(300);
+ row = await markExistingKeyRow(page);
+ }
+
+ const apiKey = await readExistingKeyThroughClipboard(page);
+ if (!apiKey) return { ok: false, data: null, msg: '已找到线上 Key,但未能读取完整值' };
+ log('[AIStudio] ✅ 已通过列表复制按钮读取人工创建的 API Key');
+ return {
+ ok: true,
+ data: {
+ apiKey,
+ keyName: row.keyName || fallbackKeyName,
+ projectName: row.projectName || row.projectId || '',
+ projectNumber: '',
+ },
+ msg: '人工创建的 API Key 已读取',
+ };
+ } catch (error) {
+ return { ok: false, data: null, msg: `同步人工 API Key 异常:${error && error.message ? error.message : error}` };
+ }
+}
+
+/**
+ * 点首次同意框的主按钮(Continue):优先 .ms-button-primary,兜底“框内最后一个可点、非关闭”按钮。
+ * 容器选择器与 dismissWelcomeConsent 保持一致(同意框可能挂在 cdk-overlay-pane 上、无 role=dialog),
+ * 并锁定“含复选框、非创建 key 对话框”的那个浮层,避免点错其它浮层里的按钮。
+ * @returns {Promise<'clicked'|'disabled'|'notfound'>}
+ */
+async function clickConsentContinueButton(page) {
+ return page.evaluate((additionalConsentPattern) => {
+ const additionalConsentRe = new RegExp(additionalConsentPattern, 'i');
+ const visible = 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 containers = [...document.querySelectorAll(
+ 'mat-dialog-container, .mat-mdc-dialog-container, .cdk-dialog-container, .cdk-overlay-pane, [role="dialog"], [aria-modal="true"]',
+ )].filter(visible);
+ let root = null;
+ for (let i = containers.length - 1; i >= 0; i--) {
+ const c = containers[i];
+ if (c.querySelector('ms-api-key-creation-dialog, #api-key-name-input')) continue;
+ if (c.querySelector('mat-checkbox, input[type="checkbox"], [role="checkbox"]')) { root = c; break; }
+ }
+ // 新版在复选框条款之后还会弹出第二层附加条款,仅有 Continue 按钮。
+ if (!root) {
+ for (let i = containers.length - 1; i >= 0; i--) {
+ const c = containers[i];
+ if (c.querySelector('ms-api-key-creation-dialog, #api-key-name-input')) continue;
+ const text = c.innerText || c.textContent || '';
+ const hasContinue = [...c.querySelectorAll('button, [role="button"]')]
+ .some(b => visible(b) && /^(continue|continuar|继续|同意)$/i.test((b.innerText || b.textContent || '').trim()));
+ if (additionalConsentRe.test(text) && hasContinue) { root = c; break; }
+ }
+ }
+ if (!root) return 'notfound';
+ const enabled = b => !(b.disabled || b.getAttribute('aria-disabled') === 'true'
+ || b.getAttribute('data-disabled') === 'true' || b.classList.contains('mat-mdc-button-disabled'));
+ const buttons = [...root.querySelectorAll('button, [role="button"]')].filter(visible)
+ .filter(b => !b.hasAttribute('matdialogclose') && !b.classList.contains('ms-button-borderless'));
+ // 无复选框的第二层弹窗只允许点击明确名为 Continue 的按钮,避免误触其它浮层。
+ const rootText = root.innerText || root.textContent || '';
+ let target = additionalConsentRe.test(rootText)
+ ? buttons.filter(b => /^(continue|continuar|继续|同意)$/i.test((b.innerText || b.textContent || '').trim()) && enabled(b)).pop()
+ : null;
+ // 复选框条款的主操作带 .ms-button-primary;兜底取最后一个可点按钮。
+ if (!target) target = buttons.filter(b => b.classList.contains('ms-button-primary') && enabled(b)).pop();
+ if (!target) target = buttons.filter(enabled).pop();
+ if (!target) return 'disabled'; // 有按钮但都不可点 → 多半复选框未真正勾上
+ target.click();
+ return 'clicked';
+ }, ADDITIONAL_CONSENT_TEXT_RE.source).catch(() => 'notfound');
+}
+
+/**
+ * 处理首次使用 AI Studio 弹出的「Welcome to AI Studio」服务条款同意框。
+ * 该框(mat-dialog-container,含 Agreements 必勾复选框 + Continue 主按钮)会挡在
+ * API Keys 页面之前;若不消掉,会被 createKey 误当成“创建对话框”,导致读 key 一直超时。
+ * 语言无关:靠“对话框内含复选框”这一结构特征判定(创建/删除框都不含复选框),
+ * 勾选全部复选框后点主按钮,不依赖 Continue/Continuar 等文字。
+ * @returns {Promise} 是否消掉了至少一个同意框
+ */
+async function dismissWelcomeConsent(page, log) {
+ let handledAny = false;
+ let announced = false;
+ let consentDomLogged = false;
+ // 首次同意框可能在页面加载后一两秒才弹出,必须轮询等待,而不是只查一次。
+ // gotoApiKeys 已经确认主页面就绪;首次同意框正常会很快出现。未出现时保留 2.5 秒
+ // 观察窗口,避免已同意账号每次固定空等;创建表单阶段仍有延迟条款二次检测兜底。
+ let deadline = Date.now() + INITIAL_CONSENT_WAIT_MS;
+ let lastHandledAt = 0;
+ while (Date.now() < deadline) {
+ const info = await page.evaluate((additionalConsentPattern) => {
+ const additionalConsentRe = new RegExp(additionalConsentPattern, 'i');
+ const visible = 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;
+ };
+ document.querySelectorAll('[data-aistudio-consent-box]').forEach(el => el.removeAttribute('data-aistudio-consent-box'));
+ document.querySelectorAll('[data-aistudio-consent-info]').forEach(el => el.removeAttribute('data-aistudio-consent-info'));
+ // 放宽浮层容器:Angular 同意框可能挂在 cdk-overlay-pane / cdk-dialog-container 上。
+ const containers = [...document.querySelectorAll(
+ 'mat-dialog-container, .mat-mdc-dialog-container, .cdk-dialog-container, .cdk-overlay-pane, [role="dialog"], [aria-modal="true"]',
+ )].filter(visible);
+ // 选“含复选框、且不是创建 key 对话框”的浮层作为同意框(菜单/下拉无复选框,创建框有 name 输入框)。
+ let root = null;
+ for (let i = containers.length - 1; i >= 0; i--) {
+ const c = containers[i];
+ if (c.querySelector('ms-api-key-creation-dialog, #api-key-name-input')) continue;
+ if (c.querySelector('mat-checkbox, input[type="checkbox"], [role="checkbox"]')) { root = c; break; }
+ }
+ // 第二层附加条款没有复选框,只显示说明文字和 Continue。
+ if (!root) {
+ for (let i = containers.length - 1; i >= 0; i--) {
+ const c = containers[i];
+ if (c.querySelector('ms-api-key-creation-dialog, #api-key-name-input')) continue;
+ const text = c.innerText || c.textContent || '';
+ const hasContinue = [...c.querySelectorAll('button, [role="button"]')]
+ .some(b => visible(b) && /^(continue|continuar|继续|同意)$/i.test((b.innerText || b.textContent || '').trim()));
+ if (additionalConsentRe.test(text) && hasContinue) {
+ c.setAttribute('data-aistudio-consent-info', '1');
+ return { present: true, unchecked: 0, infoOnly: true };
+ }
+ }
+ return { present: false };
+ }
+ const boxes = [...root.querySelectorAll('mat-checkbox, input[type="checkbox"], [role="checkbox"]')].filter(visible);
+ if (!boxes.length) return { present: false };
+ let unchecked = 0;
+ boxes.forEach((b, i) => {
+ const input = b.matches('input') ? b : b.querySelector('input[type="checkbox"]');
+ const checked = input ? input.checked
+ : (b.getAttribute('aria-checked') === 'true'
+ || b.classList.contains('mat-mdc-checkbox-checked')
+ || b.classList.contains('mat-checkbox-checked'));
+ b.setAttribute('data-aistudio-consent-box', checked ? 'done' : String(i));
+ if (!checked) unchecked++;
+ });
+ return { present: true, unchecked, infoOnly: false };
+ }, ADDITIONAL_CONSENT_TEXT_RE.source).catch(() => ({ present: false }));
+
+ if (!info.present) {
+ // 第一层复选框关闭后,第二层 Additional Terms 会延迟弹出。不能在两层之间短暂
+ // 无弹窗的空档立即 break;至少保留 5 秒安静期确认没有后续弹窗。
+ if (handledAny && Date.now() - lastHandledAt >= 3500) break;
+ await sleep(700);
+ continue;
+ }
+
+ handledAny = true;
+ if (!announced) {
+ log('[AIStudio] 检测到首次使用「服务条款/同意」框,勾选并继续...');
+ announced = true;
+ }
+ const boxes = page.locator('[data-aistudio-consent-box]:not([data-aistudio-consent-box="done"])');
+ const count = await boxes.count().catch(() => 0);
+ for (let i = 0; i < count; i++) {
+ await boxes.nth(i).click({ timeout: 3000 }).catch(() => {});
+ await sleep(250);
+ }
+ let result = await clickConsentContinueButton(page);
+ if (result === 'disabled') {
+ // 按钮还禁用 → 复选框没真正勾上。Angular Material(MDC) 复选框只认原生 input 的
+ // change 事件,普通 click/派发 click 都不生效,这里对原生 input 设 checked 并派发
+ // click+input+change 三件套强制勾选,再点一次继续。
+ await page.evaluate(() => {
+ const boxes = [...document.querySelectorAll('[data-aistudio-consent-box]:not([data-aistudio-consent-box="done"])')];
+ const fire = (el, type) => { try { el.dispatchEvent(new Event(type, { bubbles: true })); } catch (_) {} };
+ boxes.forEach(b => {
+ const input = b.matches('input[type="checkbox"]') ? b : b.querySelector('input[type="checkbox"]');
+ if (input) {
+ try { if (!input.checked) input.click(); } catch (_) {}
+ if (!input.checked) { try { input.checked = true; } catch (_) {} }
+ fire(input, 'input'); fire(input, 'change');
+ } else {
+ // 无原生 input(纯 role=checkbox):点元素本身并置 aria-checked。
+ try { b.click(); } catch (_) {}
+ try { b.setAttribute('aria-checked', 'true'); } catch (_) {}
+ fire(b, 'change');
+ }
+ });
+ }).catch(() => {});
+ await sleep(700);
+ result = await clickConsentContinueButton(page);
+ }
+ if (result !== 'clicked') {
+ log(`[AIStudio] ⚠️ 未能点到同意框的「继续」按钮(${result})`);
+ // 诊断:把同意框结构打进日志,便于定位复选框/按钮真实 DOM(一次即可)。
+ if (!consentDomLogged) {
+ consentDomLogged = true;
+ const html = await page.evaluate(() => {
+ const visible = 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 containers = [...document.querySelectorAll(
+ 'mat-dialog-container, .mat-mdc-dialog-container, .cdk-dialog-container, .cdk-overlay-pane, [role="dialog"], [aria-modal="true"]',
+ )].filter(visible);
+ for (let i = containers.length - 1; i >= 0; i--) {
+ const c = containers[i];
+ if (c.querySelector('ms-api-key-creation-dialog, #api-key-name-input')) continue;
+ if (c.querySelector('mat-checkbox, input[type="checkbox"], [role="checkbox"]')) {
+ return (c.outerHTML || '').slice(0, 2500);
+ }
+ }
+ return '(未找到同意框容器)';
+ }).catch(() => '(读取同意框 HTML 失败)');
+ log(redactText(`[AIStudio诊断] 同意框结构: ${html}`));
+ }
+ } else {
+ lastHandledAt = Date.now();
+ deadline = Math.max(deadline, lastHandledAt + 5000);
+ }
+ await sleep(result === 'clicked' ? 700 : 1500);
+ // 继续循环:可能有第二页同意,或等本框关闭后确认无残留。
+ }
+ return handledAny;
+}
+
+/**
+ * 处理一个账号的 AI Studio API Key:删旧建新读值。
+ * 每步 try/catch,失败返回 {ok:false,msg},不抛异常。
+ * @param {import('playwright').Page} page
+ * @param {object} account
+ * @param {Function} log
+ * @param {object} options - { projectName, keyName, timeoutMs }
+ * @returns {Promise<{ok:boolean, data:object|null, msg:string}>}
+ */
+/**
+ * 一次性校准 OS 鼠标坐标:用真实光标点顶部「Create API key」按钮,若创建框弹出则坐标可用,
+ * 随后 Escape 关掉校准用的创建框,回到干净列表页。校准结果写在 page 上:
+ * - 校准通过:page.__osDisabled 保持 undefined(后续删除/创建的危险点击走真实光标);
+ * - 校准失败/非 Windows:page.__osDisabled=true(后续全程回退 humanClick 合成点击)。
+ * 放在删除之前,避免坐标未校准时把本来能成功的删除也点偏。
+ */
+async function calibrateOsMouse(page, log, { keepDialogOpen = false } = {}) {
+ if (!isWindows()) {
+ page.__osDisabled = true;
+ log('[OS鼠标] 非 Windows 环境,本次使用合成点击');
+ return false;
+ }
+ const btn = await findCreateApiKeyButton(page);
+ if (!btn) {
+ page.__osDisabled = true;
+ log('[OS鼠标] 校准跳过:未找到「Create API key」按钮,本次使用合成点击');
+ return false;
+ }
+ log('[OS鼠标] 开始坐标校准:用真实光标点「Create API key」验证...');
+ let method = await trustedClick(page, btn, log);
+ let opened = await waitForCreationDialogOrConsent(page, 8000);
+ let dialog = opened.dialog;
+ if (opened.consent) {
+ log('[AIStudio] OS 鼠标校准时出现延迟条款,先处理条款再重试校准...');
+ await dismissWelcomeConsent(page, log);
+ const retryBtn = await findCreateApiKeyButton(page);
+ if (retryBtn) {
+ method = await trustedClick(page, retryBtn, log);
+ opened = await waitForCreationDialogOrConsent(page, 8000);
+ dialog = opened.dialog;
+ }
+ }
+ let keptOpen = false;
+ if (method === 'os' && dialog) {
+ log('[AIStudio] ✅ OS 鼠标坐标校准通过,删除/创建将用真实光标点击');
+ keptOpen = keepDialogOpen;
+ if (keptOpen) log('[AIStudio] 保留校准时打开的创建框,供本次创建直接复用');
+ } else if (method === 'os' && !dialog) {
+ page.__osDisabled = true;
+ log('[AIStudio] ⚠️ OS 鼠标坐标未校准(点后创建框未弹出):本次全程改用合成点击,请据上面 [OS鼠标] 坐标反馈以便校正');
+ } else {
+ // 非 os(osClickElement 直接失败回退了 humanClick):也禁用 OS,后续统一合成点击。
+ page.__osDisabled = true;
+ log('[AIStudio] OS 鼠标不可用,本次使用合成点击');
+ }
+ if (!keptOpen) {
+ // 删除旧 Key 前必须回到列表;校准失败也清掉可能残留的框。
+ await dismissCreateArtifacts(page);
+ await sleep(300);
+ }
+ return keptOpen;
+}
+
+async function processAistudioApiKey(page, account, log = () => {}, options = {}) {
+ const timeoutMs = Math.max(10000, Number(options.timeoutMs) || DEFAULT_TIMEOUT_MS);
+ const projectName = String(options.projectName || DEFAULT_PROJECT_NAME).trim() || DEFAULT_PROJECT_NAME;
+ const keyName = String(options.keyName || DEFAULT_KEY_NAME).trim() || DEFAULT_KEY_NAME;
+ const preferredProjectId = String(options.preferredProjectId || '').trim();
+ const preferredProjectName = String(options.preferredProjectName || '').trim();
+ const allowMissingPreferredProjectRecovery = !!options.allowMissingPreferredProjectRecovery;
+ // 必须放在 try 外层:一旦线上旧 key 已被删除,后续任何意外异常也要把该状态
+ // 传给流水线,由上层同步清理数据库里的失效旧 key。
+ let deletedExisting = false;
+ let clearedPreferredProjectId = '';
+ let releaseMutationSlot = null;
+ let rpcResponseListener = null;
+
+ try {
+ page.__aistudioProjectCreateResponse = null;
+ page.__aistudioGenerateKeyResponse = null;
+ if (page && typeof page.on === 'function') {
+ rpcResponseListener = response => {
+ try {
+ const url = String(response.url ? response.url() : '');
+ const status = Number(response.status ? response.status() : 0);
+ if (url.includes('MakerSuiteService/CreateCloudProject')) {
+ page.__aistudioProjectCreateResponse = { status, at: Date.now() };
+ } else if (url.includes('MakerSuiteService/GenerateCloudApiKey')) {
+ page.__aistudioGenerateKeyResponse = { status, at: Date.now() };
+ }
+ } catch (_) {}
+ };
+ page.on('response', rpcResponseListener);
+ }
+
+ log('[AIStudio] 打开 API Keys 页面...');
+ let ready = await gotoApiKeys(page, timeoutMs, preferredProjectId);
+ // 页面初始数据偶发被 botguard 403 → 页面空、Create 按钮不渲染(手工重开却正常)。
+ // 重载重试几次,复刻“手工重新打开页面”的恢复路径。
+ for (let i = 0; i < 3 && !ready && !page.__aistudioAccessBlock; i++) {
+ if (/accounts\.google\.com/i.test(String(page.url ? page.url() : ''))) break; // 掉登录页则不重试
+ log(`[AIStudio] API Keys 页面未就绪(可能被 403),重载重试第 ${i + 1} 次...`);
+ await sleep(2500 + i * 2500);
+ ready = await gotoApiKeys(page, timeoutMs, preferredProjectId);
+ }
+ if (page.__aistudioAccessBlock) {
+ const blocked = page.__aistudioAccessBlock;
+ log(`[AIStudio] ❌ ${blocked.msg}`);
+ return {
+ ok: false,
+ data: null,
+ code: blocked.code,
+ msg: blocked.msg,
+ externalBlock: true,
+ };
+ }
+ if (!ready) {
+ const snapshot = await pageSnapshot(page);
+ if (/accounts\.google\.com/i.test(String(page.url ? page.url() : ''))) {
+ return { ok: false, data: null, msg: 'AI Studio 要求重新登录 Google' };
+ }
+ if (!snapshot.bodyText) return { ok: false, data: null, msg: 'API Keys 页面加载失败' };
+ // 有内容但未识别到就绪标志,继续尝试后续步骤。
+ }
+
+ // 首次使用会弹「Welcome to AI Studio」服务条款同意框,挡住整个页面。
+ // 必须先勾选并 Continue,否则会被误当成创建对话框,导致读 key 超时。
+ const consentHandled = await dismissWelcomeConsent(page, log);
+ if (consentHandled) {
+ // 同意后页面会重渲染,重新确认 API Keys 就绪再继续。
+ await waitForApiKeysReady(page, Math.min(timeoutMs, 15000));
+ }
+
+ // 首次使用条款提交后,Google 可能才把账号重定向到 Available regions
+ // 限制页。必须在进入删除/创建流程前再次识别,不能误报“找不到创建按钮”。
+ const postConsentSnapshot = await pageSnapshot(page);
+ const postConsentAccessBlock = page.__aistudioAccessBlock || classifyAistudioAccessBlock(
+ String(page && page.url ? page.url() : ''),
+ postConsentSnapshot.bodyText,
+ );
+ if (postConsentAccessBlock) {
+ page.__aistudioAccessBlock = postConsentAccessBlock;
+ log(`[AIStudio] ❌ ${postConsentAccessBlock.msg}`);
+ return {
+ ok: false,
+ data: null,
+ code: postConsentAccessBlock.code,
+ msg: postConsentAccessBlock.msg,
+ externalBlock: true,
+ };
+ }
+
+ releaseMutationSlot = await acquireAistudioMutationSlot(log);
+
+ // 无旧 Key 时保留校准打开的创建框,后续直接复用;有旧 Key 时校准后关框再删除。
+ // 为防列表数据晚到,校准后会再检查一次行数。
+ let existing = await countRowMenuButtons(page);
+ const keptCreationDialog = await calibrateOsMouse(page, log, { keepDialogOpen: existing === 0 });
+ if (keptCreationDialog) {
+ const lateExisting = await countRowMenuButtons(page);
+ if (lateExisting > 0) {
+ existing = lateExisting;
+ await dismissCreateArtifacts(page);
+ }
+ }
+
+ // deletedExisting:本次是否真的删除过线上旧 key。若删过又重建失败,则线上已无有效 key,
+ // 上层需据此把数据库里那条旧 key 一并清掉,避免残留失效 key(用户要求 1)。
+ if (existing > 0) {
+ log(`[AIStudio] 检测到已有 ${existing} 枚 key,先全部删除...`);
+ const del = await deleteAllKeys(page, log, timeoutMs);
+ if (del.deleted > 0) deletedExisting = true;
+ if (!del.ok) { log(`[AIStudio] ❌ 删除失败:${del.msg}`); return { ok: false, data: null, msg: `删除旧 key 失败:${del.msg}`, deletedExisting }; }
+ }
+
+ log('[AIStudio] 正在创建新 key...');
+ let created = await createKey(page, log, {
+ projectName,
+ keyName,
+ preferredProjectId,
+ preferredProjectName,
+ allowMissingPreferredProjectRecovery,
+ });
+ clearedPreferredProjectId = String(created && created.clearedPreferredProjectId || '').trim();
+ // 列表可能连续发生 0→1 抖动:首次创建发现晚到行,刷新瞬间又读到 0,第二次
+ // 创建时行再次出现。最多恢复两轮,每轮先观察 5 秒再决定是否删除,避免无限循环。
+ for (let lateRecovery = 1;
+ !created.ok && created.lateExisting && lateRecovery <= 2;
+ lateRecovery++) {
+ log(`[AIStudio] 列表数据晚到,切回列表稳定检查并恢复(第 ${lateRecovery}/2 轮)...`);
+ await gotoApiKeys(page, timeoutMs, preferredProjectId).catch(() => false);
+ const lateCount = await waitForLateExistingRows(page, 5000);
+ if (lateCount > 0) {
+ // 任务入口已经给旧 Key 一次稳定检测和删除机会。进入创建流程后才出现的
+ // Key 可能正是刚创建成功但详情/列表异步晚到的结果,绝不能再次删除重建。
+ log(`[AIStudio] 创建流程中出现 ${lateCount} 枚 Key,直接读取并保存,不再删除重建...`);
+ const recovered = await syncExistingAistudioApiKey(page, account, log, {
+ keyName,
+ timeoutMs,
+ });
+ if (recovered.ok && recovered.data && recovered.data.apiKey) {
+ return { ...recovered, deletedExisting, recoveredLateKey: true, clearedPreferredProjectId };
+ }
+ return {
+ ok: false,
+ data: null,
+ msg: `检测到创建流程中出现的 Key,但未能安全读取:${recovered.msg || '未知原因'}(为防误删未再次重建)`,
+ deletedExisting,
+ clearedPreferredProjectId,
+ };
+ }
+ // 刷新后旧行可能已经自然消失;无论是否还需补删,都必须重新进入创建流程。
+ created = await createKey(page, log, {
+ projectName,
+ keyName,
+ preferredProjectId,
+ preferredProjectName,
+ allowMissingPreferredProjectRecovery,
+ });
+ clearedPreferredProjectId = String(created && created.clearedPreferredProjectId || clearedPreferredProjectId).trim();
+ }
+ if (!created.ok) {
+ if (created.projectSyncPending) {
+ log(`[AIStudio] 项目同步等待:${created.msg}`);
+ return {
+ ok: false,
+ data: null,
+ msg: created.msg,
+ deletedExisting,
+ projectSyncPending: true,
+ retryAfterMs: Number(created.retryAfterMs) || PROJECT_SYNC_RETRY_DELAY_MS,
+ preferredProjectId: String(created.preferredProjectId || preferredProjectId || '').trim(),
+ preferredProjectName: String(created.preferredProjectName || preferredProjectName || '').trim(),
+ clearedPreferredProjectId,
+ };
+ }
+ log(`[AIStudio] ❌ 创建失败:${created.msg}`);
+ return { ok: false, data: null, msg: created.msg, deletedExisting, clearedPreferredProjectId };
+ }
+
+ log('[AIStudio] 正在读取 API key details...');
+ const details = await readKeyDetails(page, keyName, timeoutMs, log);
+ if (!details.ok || !details.data || !details.data.apiKey) {
+ const rmsg = details.msg || 'API Key 读取失败';
+ log(`[AIStudio] ❌ 读取失败:${rmsg}`);
+ return { ok: false, data: null, msg: rmsg, deletedExisting, clearedPreferredProjectId };
+ }
+
+ // 关闭详情对话框(不影响已读取到的值)。
+ await page.keyboard.press('Escape').catch(() => {});
+ log('[AIStudio] ✅ API Key 已生成并读取');
+ return { ok: true, data: details.data, msg: 'API Key 已生成', clearedPreferredProjectId };
+ } catch (error) {
+ const msg = error && error.message ? error.message : 'AI Studio API Key 处理异常';
+ log(`[AIStudio] 处理失败:${msg}`);
+ return { ok: false, data: null, msg, deletedExisting, clearedPreferredProjectId };
+ } finally {
+ if (releaseMutationSlot) releaseMutationSlot();
+ if (rpcResponseListener && page && typeof page.off === 'function') {
+ page.off('response', rpcResponseListener);
+ }
+ }
+}
+
+module.exports = {
+ API_KEYS_URL,
+ CLOUD_PROJECT_CREATE_URL,
+ DEFAULT_PROJECT_NAME,
+ DEFAULT_KEY_NAME,
+ processAistudioApiKey,
+ syncExistingAistudioApiKey,
+ _private: {
+ firstVisible,
+ pageSnapshot,
+ classifyAistudioAccessBlock,
+ countRowMenuButtons,
+ dismissWelcomeConsent,
+ isAdditionalConsentText,
+ INITIAL_CONSENT_WAIT_MS,
+ MAX_AISTUDIO_MUTATIONS,
+ PROJECT_SYNC_RETRY_DELAY_MS,
+ pendingCloudProjectSync,
+ apiKeysUrlForProject,
+ extractCloudProjectId,
+ needsCloudConsoleProjectFallback,
+ acceptCloudConsoleTerms,
+ hasVisibleCloudConsoleTermsDialog,
+ markCloudProjectCreateForm,
+ createProjectViaCloudConsole,
+ importCloudProjectFromDrawer,
+ selectProjectInNewUi,
+ markCreationDialog,
+ hasBlockingProjectCreationDialog,
+ isCreateKeyDisabled,
+ clickCreateKeyButton,
+ waitForCreationDialog,
+ waitForCreationDialogOrConsent,
+ hasBlockingConsentDialog,
+ waitCreationFormReady,
+ waitForLateExistingRows,
+ isValidCloudProjectDisplayName,
+ createProject,
+ dismissCloudProjectFallbackDialogs,
+ withAistudioMutationSlot,
+ waitKeyGenerated,
+ deleteAllKeys,
+ createKey,
+ readVisibleKeyDetailsOnce,
+ readKeyDetails,
+ markExistingKeyRow,
+ readExistingKeyThroughClipboard,
+ },
+};
diff --git a/src/services/aistudio_manual_key_watcher.js b/src/services/aistudio_manual_key_watcher.js
new file mode 100644
index 0000000..3cba863
--- /dev/null
+++ b/src/services/aistudio_manual_key_watcher.js
@@ -0,0 +1,144 @@
+/**
+ * @file services/aistudio_manual_key_watcher.js
+ * @brief 只读监听用户在比特浏览器中手工创建 AI Studio Key。
+ *
+ * 监听器不导航、不创建、不删除 Key。它只在人工操作后:
+ * 1. 捕获可见的 API key details 对话框并读取完整 Key;
+ * 2. 记录 DeleteCloudApiKey 是否真正成功,供上层清理已失效的数据库旧 Key;
+ * 3. 若生成 RPC 已成功但用户立即关了详情框,再从列表只读同步。
+ */
+
+'use strict';
+
+const {
+ DEFAULT_KEY_NAME,
+ syncExistingAistudioApiKey,
+ _private: { readVisibleKeyDetailsOnce },
+} = require('./aistudio_api_key_service');
+
+function sleep(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+function isApiKeysPage(page) {
+ try {
+ return /https:\/\/aistudio\.google\.com\/api-keys(?:[/?#]|$)/i.test(String(page.url ? page.url() : ''));
+ } catch (_) {
+ return false;
+ }
+}
+
+async function watchManualAistudioKey(browser, account, log = () => {}, options = {}) {
+ const timeoutMs = Math.max(10000, Number(options.timeoutMs) || 30 * 60 * 1000);
+ const pollMs = Math.max(250, Number(options.pollMs) || 500);
+ const keyName = String(options.keyName || DEFAULT_KEY_NAME).trim() || DEFAULT_KEY_NAME;
+ const shouldStop = typeof options.shouldStop === 'function' ? options.shouldStop : () => false;
+ const deadline = Date.now() + timeoutMs;
+ const listeners = new Map();
+ let sawApiKeysPage = false;
+ let deleteSucceeded = false;
+ let generateSucceededAt = 0;
+ let listRecoveryTried = false;
+
+ const bindPage = page => {
+ if (!page || listeners.has(page)) return;
+ const onResponse = response => {
+ try {
+ const url = String(response.url ? response.url() : '');
+ const status = Number(response.status ? response.status() : 0);
+ if (status >= 200 && status < 400 && url.includes('MakerSuiteService/DeleteCloudApiKey')) {
+ deleteSucceeded = true;
+ log('[AIStudio人工监听] 已确认线上旧 Key 删除成功');
+ }
+ if (status >= 200 && status < 400 && url.includes('MakerSuiteService/GenerateCloudApiKey')) {
+ generateSucceededAt = Date.now();
+ log('[AIStudio人工监听] 已检测到人工创建 Key 请求成功,正在读取详情...');
+ }
+ } catch (_) {}
+ };
+ page.on('response', onResponse);
+ listeners.set(page, onResponse);
+ };
+
+ const cleanup = () => {
+ for (const [page, listener] of listeners.entries()) {
+ try { page.off('response', listener); } catch (_) {}
+ }
+ listeners.clear();
+ };
+
+ try {
+ while (Date.now() < deadline) {
+ if (shouldStop()) {
+ return { ok: false, stopped: true, deletedExisting: deleteSucceeded, data: null, msg: '人工 Key 监听已停止' };
+ }
+ if (!browser || (typeof browser.isConnected === 'function' && !browser.isConnected())) {
+ return { ok: false, browserClosed: true, deletedExisting: deleteSucceeded, data: null, msg: '比特浏览器已关闭' };
+ }
+
+ const pages = [];
+ for (const context of (browser.contexts ? browser.contexts() : [])) {
+ for (const page of context.pages()) {
+ if (!page || page.isClosed()) continue;
+ bindPage(page);
+ pages.push(page);
+ }
+ }
+
+ for (const page of pages) {
+ if (!isApiKeysPage(page)) continue;
+ if (!sawApiKeysPage) {
+ sawApiKeysPage = true;
+ log('[AIStudio人工监听] 已进入当前账号 API Keys 页面,等待人工创建成功...');
+ }
+ const details = await readVisibleKeyDetailsOnce(page, keyName).catch(() => ({ ok: false }));
+ if (details.ok && details.data && details.data.apiKey) {
+ return {
+ ok: true,
+ data: details.data,
+ deletedExisting: deleteSucceeded,
+ msg: '已检测到人工创建的 API Key',
+ };
+ }
+
+ // 极端情况:创建 RPC 已成功,但用户在下一次轮询前就关了详情框。
+ // 等列表稳定 1.2 秒后仅执行一次只读同步。
+ if (generateSucceededAt && !listRecoveryTried && Date.now() - generateSucceededAt >= 1200) {
+ listRecoveryTried = true;
+ const recovered = await syncExistingAistudioApiKey(page, account, log, {
+ keyName,
+ timeoutMs: 15000,
+ }).catch(() => ({ ok: false }));
+ if (recovered.ok && recovered.data && recovered.data.apiKey) {
+ return {
+ ok: true,
+ data: recovered.data,
+ deletedExisting: deleteSucceeded,
+ msg: '人工创建成功,已从列表同步 API Key',
+ };
+ }
+ }
+ }
+ await sleep(pollMs);
+ }
+ return {
+ ok: false,
+ timeout: true,
+ sawApiKeysPage,
+ deletedExisting: deleteSucceeded,
+ data: null,
+ msg: sawApiKeysPage
+ ? '等待人工创建 AI Studio Key 超时'
+ : '未进入 AI Studio API Keys 页面',
+ };
+ } finally {
+ cleanup();
+ }
+}
+
+module.exports = {
+ watchManualAistudioKey,
+ _private: {
+ isApiKeysPage,
+ },
+};
diff --git a/src/services/change_2fa_service.js b/src/services/change_2fa_service.js
index 1420a07..8111108 100644
--- a/src/services/change_2fa_service.js
+++ b/src/services/change_2fa_service.js
@@ -15,13 +15,25 @@ const TWO_FACTOR_URL = 'https://myaccount.google.com/two-step-verification/authe
const TWOSV_URL = 'https://myaccount.google.com/signinoptions/twosv';
const DEFAULT_TIMEOUT = 30000;
const SMART_WAIT_TIMEOUT = 15000;
+const SENSITIVE_CHECKPOINTS = new Set([
+ 'change2fa_clicked_cantscan',
+ 'change2fa_key_extracted',
+ 'change2fa_before_verify',
+]);
+
+function shouldEnsureTwoStepVerificationEnabled(hadExistingAuthenticator) {
+ return !hadExistingAuthenticator;
+}
async function checkpointOnce(state, page, email, name, logCallback, meta = null) {
try {
if (!state || !name) return null;
if (state.has(name)) return null;
state.add(name);
- const sp = await saveCheckpointScreenshot(page, email, name);
+ // 手动设置密钥页和验证码页含可直接使用的认证秘密,只记事件,不保存截图。
+ const sp = SENSITIVE_CHECKPOINTS.has(name)
+ ? null
+ : await saveCheckpointScreenshot(page, email, name);
writeCheckpoint({
email,
taskType: 'change_2fa',
@@ -415,7 +427,7 @@ async function clickNextButton(page, log) {
async function verifyNew2fa(page, new2faKey, log) {
try {
const code = await generateTOTP(new2faKey);
- log(`新密钥计算的验证码: ${code}`);
+ log('新密钥验证码已生成(内容不写入日志)');
const combinedInput = page.locator('span[jsname="a6aEOe"] input[jsname="YPqjbf"]');
@@ -428,7 +440,7 @@ async function verifyNew2fa(page, new2faKey, log) {
await combinedInput.first().fill('');
await combinedInput.first().fill(code);
- log(`已填入新密钥验证码: ${code}`);
+ log('已填入新密钥验证码(内容不写入日志)');
return [true, code];
} catch (e) {
log(`验证新2FA异常: ${e.message}`);
@@ -780,7 +792,7 @@ async function change2faForAccount(page, accountInfo, logCallback = null, option
errorEvent(email, log, { phase: 'verify_code_input', url: page.url(), message: '填入验证码失败' });
return [false, '填入验证码失败', null];
}
- log(`✅ 已输入验证码: ${usedCode}`);
+ log('✅ 已输入验证码(内容不写入日志)');
await checkpointOnce(cp, page, email, 'change2fa_before_verify', log, { attempt: verifyAttempt + 1 });
// 点击 Verify
@@ -791,8 +803,13 @@ async function change2faForAccount(page, accountInfo, logCallback = null, option
log('🎉 2FA密钥更改成功,设置页已确认!');
await checkpointOnce(cp, page, email, 'change2fa_success', log, null);
- // 设置完成后检查并开启两步验证
- await ensureTwoStepVerificationEnabled(page, log);
+ // 能在设置页删除已有 Authenticator,说明两步验证已经开启;此时无需再跳回
+ // 两步验证总页重复确认。首次添加 Authenticator 时仍保留完整的开启检查。
+ if (shouldEnsureTwoStepVerificationEnabled(hasExisting)) {
+ await ensureTwoStepVerificationEnabled(page, log);
+ } else {
+ log('[2步验证] 已有 Authenticator 替换成功,跳过重复的开启状态检查');
+ }
try { updateAccount2faKey(email, new2faKey); } catch (e) {}
return [true, '2FA密钥更改成功', new2faKey];
@@ -822,5 +839,6 @@ module.exports = {
clickVerifyButton,
isAuthenticatorSettingsUrl,
isNew2faCodeRejected,
+ shouldEnsureTwoStepVerificationEnabled,
},
};
diff --git a/src/services/change_password_service.js b/src/services/change_password_service.js
index 7180e5b..868a14a 100644
--- a/src/services/change_password_service.js
+++ b/src/services/change_password_service.js
@@ -17,6 +17,8 @@ const DEFAULT_TIMEOUT = 20000;
const SMART_WAIT_TIMEOUT = 15000;
const RESULT_POLL_SECONDS = 20;
const MAX_FLOW_ATTEMPTS = 2;
+const MAX_SUBMIT_ATTEMPTS = 3;
+const SUBMIT_RETRY_POLLS = new Set([5, 11]);
async function checkpointOnce(state, page, email, name, logCallback, meta = null) {
try {
@@ -94,7 +96,15 @@ function generateStrongPassword() {
}
async function pickVisiblePasswordInputs(page) {
- const inputs = page.locator('input[type="password"]');
+ // Google 在某些交互状态下会把密码框临时切成 type="text"(例如显示密码),
+ // 因此不能只依赖 input[type=password],否则会把仍存在的表单误判为成功。
+ const inputs = page.locator([
+ 'input[type="password"]',
+ 'input[name="password"]',
+ 'input[name="confirmation_password"]',
+ 'input[autocomplete="new-password"]',
+ 'input[aria-label*="password" i]',
+ ].join(', '));
const count = await inputs.count();
const visibles = [];
for (let i = 0; i < count; i++) {
@@ -122,6 +132,106 @@ async function findNewPasswordInputs(page) {
return [null, null];
}
+/**
+ * 用不同的原生交互路径提交同一份密码表单。
+ * 第一次走正常 Playwright 点击(不使用 force,确保按钮确实可操作);若 Google
+ * 无提示地恢复表单,再依次用回车和 DOM button.click 兜底。整个过程始终复用
+ * 同一候选密码,避免不确定状态下生成并尝试多个新密码。
+ */
+async function submitPasswordForm(page, submitBtn, confirmPwdInput, attempt, log) {
+ try {
+ if (attempt === 1) {
+ await submitBtn.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
+ await submitBtn.waitFor({ state: 'visible', timeout: 10000 });
+ if (await submitBtn.isDisabled().catch(() => false)) return false;
+ await submitBtn.click({ timeout: 10000, noWaitAfter: true });
+ log('✅ 已点击确认按钮(正常点击)');
+ return true;
+ }
+
+ if (attempt === 2) {
+ if (!confirmPwdInput || !await confirmPwdInput.isVisible().catch(() => false)) return false;
+ await confirmPwdInput.press('Enter');
+ log('✅ 已按回车重新提交同一密码');
+ return true;
+ }
+
+ const submitted = await submitBtn.evaluate((button) => {
+ if (!button || button.disabled || button.getAttribute('aria-disabled') === 'true') return false;
+ // 必须点击按钮,让 Google 页面自己的 JS 处理提交;禁止 form.requestSubmit,
+ // 后者会绕过页面监听器并按 GET 把密码字段拼进 URL。
+ button.click();
+ return true;
+ }).catch(() => false);
+ if (submitted) log('✅ 已通过 DOM 按钮点击重新提交同一密码');
+ return submitted;
+ } catch (e) {
+ log(`⚠️ 第 ${attempt} 次提交未执行:${e && e.message ? e.message : e}`);
+ return false;
+ }
+}
+
+async function canRetryPasswordSubmit(submitBtn, newPwdInput, confirmPwdInput) {
+ try {
+ const inputsVisible = await newPwdInput.isVisible().catch(() => false)
+ && await confirmPwdInput.isVisible().catch(() => false);
+ const buttonVisible = await submitBtn.isVisible().catch(() => false);
+ const buttonDisabled = await submitBtn.isDisabled().catch(() => true);
+ return inputsVisible && buttonVisible && !buttonDisabled;
+ } catch (_) {
+ return false;
+ }
+}
+
+/**
+ * 点击“保持登录设备”确认弹窗中的按钮。
+ * 页面同时存在表单主按钮和弹窗确认按钮,必须优先限定在 dialog 内并从 DOM
+ * 后部选择,不能使用页面级 first(),否则可能点到被遮罩的主按钮。
+ */
+async function confirmPasswordChangeDialog(page, log = () => {}) {
+ try {
+ const dialogs = page.locator('[role="dialog"], [role="alertdialog"], [aria-modal="true"]');
+ const dialogCount = await dialogs.count().catch(() => 0);
+ for (let index = dialogCount - 1; index >= 0; index--) {
+ const dialog = dialogs.nth(index);
+ if (!await dialog.isVisible().catch(() => false)) continue;
+
+ const hint = dialog.locator('text=/stay\s+signed\s+in|sign\s+out\s+of\s+any\s+device|保持.*登录|退出.*设备/i');
+ const cancel = dialog.getByRole('button', { name: /Cancel|取消/i });
+ const confirm = dialog.getByRole('button', { name: /Change password|更改密码|修改密码/i });
+ const confirmCount = await confirm.count().catch(() => 0);
+ if (confirmCount < 1) continue;
+ if (!await hint.first().isVisible().catch(() => false)
+ && !await cancel.first().isVisible().catch(() => false)) continue;
+
+ for (let buttonIndex = confirmCount - 1; buttonIndex >= 0; buttonIndex--) {
+ const button = confirm.nth(buttonIndex);
+ if (!await button.isVisible().catch(() => false)) continue;
+ if (await button.isDisabled().catch(() => true)) continue;
+ await button.click({ timeout: 5000 });
+ log('✅ 已确认更改密码弹窗');
+ await dialog.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
+ return true;
+ }
+ }
+ } catch (_) {}
+ return false;
+}
+
+/** 防止任何错误提交方式再次把密码字段写入地址栏。 */
+function hasPasswordFieldsInUrl(currentUrl) {
+ try {
+ const parsed = new URL(String(currentUrl || ''));
+ const sensitiveKeys = new Set(['password', 'confirmation_password', 'new_password', 'passwd', 'pwd']);
+ for (const key of parsed.searchParams.keys()) {
+ if (sensitiveKeys.has(String(key).toLowerCase())) return true;
+ }
+ return false;
+ } catch (_) {
+ return false;
+ }
+}
+
// ─── 主流程 ─────────────────────────────────────────────
/**
@@ -165,28 +275,6 @@ async function changePasswordForAccount(page, accountInfo, logCallback = null, o
}
}
- async function handleChangePasswordConfirmDialog() {
- try {
- const changeBtn = page.getByRole('button', { name: /Change password|更改密码|修改密码/i });
- const cancelBtn = page.getByRole('button', { name: /Cancel|取消/i });
- const hintText = page.locator('text=/You\\s+can\\s+sign\\s+out\\s+of\\s+any\\s+device/i').or(
- page.locator('text=/devices\\s+in\\s+your\\s+settings/i')
- );
-
- const changeVisible = await changeBtn.first().isVisible().catch(() => false);
- const cancelVisible = await cancelBtn.first().isVisible().catch(() => false);
- const hintVisible = await hintText.first().isVisible().catch(() => false);
- if (!(changeVisible && cancelVisible) && !(changeVisible && hintVisible)) return false;
-
- await changeBtn.first().click({ timeout: 3000, force: true }).catch(() => false);
- try { await page.waitForLoadState('domcontentloaded', { timeout: 8000 }); } catch (_) {}
- await page.waitForTimeout(1200);
- return true;
- } catch (_) {
- return false;
- }
- }
-
async function ensureOnPasswordChangePage() {
if (await detectVerifyItsYouOotp()) return false;
try {
@@ -308,26 +396,32 @@ async function changePasswordForAccount(page, accountInfo, logCallback = null, o
// Step 5: 点击确认按钮
log('Step 5: 点击确认按钮...');
const submitBtn = page.locator('button[jsname="Pr7Yme"][type="submit"]');
-
- try {
- await submitBtn.first().waitFor({ state: 'visible', timeout: 10000 });
- await submitBtn.first().click({ force: true });
- log('✅ 已点击确认按钮');
- await checkpointOnce(cp, page, email, 'change_password_submitted', log, { url: page.url() });
- } catch (e) {
- log(`点击确认按钮失败: ${e.message}`);
- errorEvent(email, log, { phase: 'submit', url: page.url(), message: e.message, stack: e.stack });
+ const submitButton = submitBtn.first();
+ let submitAttempts = 1;
+ const initialSubmitted = await submitPasswordForm(
+ page,
+ submitButton,
+ confirmPwdInput,
+ submitAttempts,
+ log,
+ );
+ if (!initialSubmitted) {
+ const message = '确认按钮不可用或首次提交未执行';
+ log(`点击确认按钮失败: ${message}`);
+ errorEvent(email, log, { phase: 'submit', url: page.url(), message });
return [false, '找不到确认按钮', null];
}
+ await checkpointOnce(cp, page, email, 'change_password_submitted', log, { url: page.url() });
- await handleChangePasswordConfirmDialog();
+ await confirmPasswordChangeDialog(page, log);
// Step 6: 检测结果
log('Step 6: 检测修改结果...');
let lastUrl = null;
let didPostSubmit2fa = false;
+ let missingPasswordControlsPolls = 0;
for (let i = 0; i < RESULT_POLL_SECONDS; i++) {
- await handleChangePasswordConfirmDialog();
+ await confirmPasswordChangeDialog(page, log);
try { await page.waitForLoadState('domcontentloaded', { timeout: 1500 }); } catch (_) {}
await page.waitForTimeout(1000);
@@ -338,6 +432,13 @@ async function changePasswordForAccount(page, accountInfo, logCallback = null, o
}
lastUrl = currentUrl;
+ if (hasPasswordFieldsInUrl(currentUrl)) {
+ const message = '检测到密码字段进入URL,已停止以防敏感信息继续传播';
+ log(`❌ ${message}`);
+ errorEvent(email, log, { phase: 'unsafe_submit', url: currentUrl, message });
+ return [false, message, null];
+ }
+
if (await detectVerifyItsYouOotp()) return [false, "密码无法修改:触发 Verify it's you(ootp)验证,无法自动继续", null];
if (!didPostSubmit2fa) {
@@ -368,9 +469,10 @@ async function changePasswordForAccount(page, accountInfo, logCallback = null, o
const pwdChangedHint = await detectPasswordWasChangedHint();
if (pwdChangedHint) {
- log('❌ 检测到密码近期已更改提示,判定为失败');
- await checkpointOnce(cp, page, email, 'change_password_failed_pwd_changed_hint', log, { url: page.url(), text: pwdChangedHint });
- return [false, '密码已被修改', null];
+ // 该提示若出现在本次提交之后,就是新密码已经生效的明确证据。
+ log(`🎉 密码更改成功(检测到修改提示:${pwdChangedHint})!`);
+ await checkpointOnce(cp, page, email, 'change_password_success_pwd_changed_hint', log, { url: page.url(), text: pwdChangedHint });
+ return [true, '密码更改成功', newPassword];
}
if (currentUrl && !currentUrl.includes('signinoptions/password') && !currentUrl.includes('signin/challenge')) {
@@ -380,11 +482,18 @@ async function changePasswordForAccount(page, accountInfo, logCallback = null, o
}
try {
- const remaining = await page.locator('input[type="password"]').count();
- if (remaining === 0) {
- log('🎉 密码更改成功(密码输入框已消失)!');
- await checkpointOnce(cp, page, email, 'change_password_success_password_inputs_gone', log, { url: page.url() });
- return [true, '密码更改成功', newPassword];
+ const remaining = await pickVisiblePasswordInputs(page);
+ if (remaining.length === 0) {
+ missingPasswordControlsPolls++;
+ // 页面重绘、遮罩和输入框 type 切换都可能造成一次瞬时不可见;至少连续
+ // 两轮不存在密码控件,才把它作为成功辅助信号。
+ if (missingPasswordControlsPolls >= 2) {
+ log('🎉 密码更改成功(密码输入框已稳定消失)!');
+ await checkpointOnce(cp, page, email, 'change_password_success_password_inputs_gone', log, { url: page.url() });
+ return [true, '密码更改成功', newPassword];
+ }
+ } else {
+ missingPasswordControlsPolls = 0;
}
} catch (_) {}
@@ -408,16 +517,28 @@ async function changePasswordForAccount(page, accountInfo, logCallback = null, o
return [true, '密码更改成功', newPassword];
}
} catch (_) {}
+
+ // Google 偶发接收点击事件后无提示地恢复表单。仅在两个密码框仍可见、按钮重新
+ // 可用时,换一种提交方式重试同一候选密码;绝不重新生成第二个密码。
+ if (submitAttempts < MAX_SUBMIT_ATTEMPTS
+ && SUBMIT_RETRY_POLLS.has(i)
+ && await canRetryPasswordSubmit(submitButton, newPwdInput, confirmPwdInput)) {
+ submitAttempts++;
+ log(`⚠️ 提交后表单仍未完成,使用备用方式重试(${submitAttempts}/${MAX_SUBMIT_ATTEMPTS})...`);
+ const retried = await submitPasswordForm(page, submitButton, confirmPwdInput, submitAttempts, log);
+ if (retried) {
+ await page.waitForTimeout(250);
+ await confirmPasswordChangeDialog(page, log);
+ }
+ }
}
- if (flowAttempt < MAX_FLOW_ATTEMPTS - 1) {
- log('⚠️ 未捕获到明确成功标志,重新执行修改流程...');
- continue;
- }
-
- log('❌ 无法确认修改结果');
- errorEvent(email, log, { phase: 'result', url: page.url(), message: '无法确认修改结果' });
- return [false, '无法确认修改结果', null];
+ // 已经提交过候选密码时不能重新执行整套流程,否则可能连续生成多个新密码,
+ // 最终无法确定哪个密码实际生效。
+ const resultMessage = `提交 ${submitAttempts} 次后仍无法确认修改结果`;
+ log(`❌ ${resultMessage}`);
+ errorEvent(email, log, { phase: 'result', url: page.url(), message: resultMessage });
+ return [false, resultMessage, null];
} catch (e) {
const errMsg = `更改密码异常: ${e.message}`;
log(`❌ ${errMsg}`);
@@ -428,4 +549,14 @@ async function changePasswordForAccount(page, accountInfo, logCallback = null, o
return [false, '无法确认修改结果', null];
}
-module.exports = { changePasswordForAccount, generateStrongPassword };
+module.exports = {
+ changePasswordForAccount,
+ generateStrongPassword,
+ _private: {
+ submitPasswordForm,
+ canRetryPasswordSubmit,
+ confirmPasswordChangeDialog,
+ pickVisiblePasswordInputs,
+ hasPasswordFieldsInUrl,
+ },
+};
diff --git a/src/services/common_auth.js b/src/services/common_auth.js
index cb7256b..cc9471d 100644
--- a/src/services/common_auth.js
+++ b/src/services/common_auth.js
@@ -161,12 +161,40 @@ async function waitForAlternateChallengeExit(page, previousUrl, wasSecurityCode,
*/
async function checkNeedPassword(page, log) {
try {
- try { await page.waitForLoadState('networkidle', { timeout: 10000 }); } catch (_) {}
const currentUrl = page.url();
log(`当前页面URL: ${currentUrl.slice(0, 80)}...`);
if (currentUrl.includes('signin/challenge/pwd')) return true;
+ // 已经落在 Authenticator 设置页时,Google 不会再在同一 URL 内嵌密码框。
+ // 直接返回可避免后台轮询请求让 networkidle 白等数秒;若需要验证,Google 会先
+ // 重定向到 accounts.google.com/signin/challenge/pwd,上面的判断仍会命中。
+ if (currentUrl.includes('myaccount.google.com/two-step-verification/authenticator')) {
+ log('已进入 Authenticator 设置页,当前无需输入密码');
+ return false;
+ }
+
+ try { await page.waitForLoadState('networkidle', { timeout: 10000 }); } catch (_) {}
+
+ // myaccount 的 Password 设置页本身也有两个 password 输入框(新密码/确认密码),
+ // 不能把第一栏误当成“当前密码”验证框,否则会用旧密码误提交一次改密表单。
+ try {
+ const parsed = new URL(currentUrl);
+ if (parsed.hostname === 'myaccount.google.com'
+ && /\/signinoptions\/password\/?$/i.test(parsed.pathname)) {
+ const inputs = page.locator('input[type="password"]');
+ const count = await inputs.count();
+ let visibleCount = 0;
+ for (let index = 0; index < count; index++) {
+ if (await inputs.nth(index).isVisible().catch(() => false)) visibleCount++;
+ }
+ if (visibleCount >= 2) {
+ log('检测到新密码/确认密码双输入框,当前无需再次输入旧密码');
+ return false;
+ }
+ }
+ } catch (_) {}
+
const pwdInput = page.locator('input[type="password"]');
const pwdLabel1 = page.locator('text=/enter.*password/i');
const pwdLabel2 = page.locator('text=输入密码');
@@ -452,6 +480,7 @@ async function handlePasskeyEnrollment(page, log) {
async function handleIdentityVerification(page, accountInfo, log) {
const password = accountInfo.password || '';
const twofaKey = accountInfo.twofa_key || accountInfo.secret_key || accountInfo.secret || '';
+ let passwordChallengeSubmitted = false;
// 1. 密码验证
const needPassword = await checkNeedPassword(page, log);
@@ -464,6 +493,7 @@ async function handleIdentityVerification(page, accountInfo, log) {
return [false, '密码已被修改'];
}
if (!pwdSuccess) return [false, '密码输入失败'];
+ passwordChallengeSubmitted = true;
log('✅ 密码输入完成');
try { await page.waitForLoadState('networkidle', { timeout: 10000 }); } catch (_) {}
await page.waitForTimeout(3000);
@@ -477,12 +507,50 @@ async function handleIdentityVerification(page, accountInfo, log) {
let securityCodeStallCount = 0;
let verifyItsYouStallCount = 0;
+ let passwordChallengeStallCount = 0;
- // 循环检测页面状态:DP/OOTP/SKOTP → Selection → TOTP,最多 10 轮
+ // 循环检测页面状态:延迟密码页 → DP/OOTP/SKOTP → Selection → TOTP,最多 10 轮
for (let round = 0; round < 10; round++) {
const u = page.url();
log(`[身份验证] 第${round + 1}轮检测, URL: ${u.slice(0, 80)}...`);
+ // reCAPTCHA / 其他 challenge 可能在初次密码检测结束后才异步跳到 pwd。
+ // 旧流程只在进入函数时检测一次,随后会把 pwd 当成未知页面空等到失败。
+ if (u.includes('signin/challenge/pwd')) {
+ const pwdChanged = await detectPasswordChangedRecentlyText(page);
+ if (pwdChanged) {
+ log(`[身份验证] 检测到密码已被修改: ${pwdChanged}`);
+ return [false, '密码已被修改'];
+ }
+
+ if (!passwordChallengeSubmitted) {
+ if (!password) return [false, '需要密码验证但账号无密码'];
+ log('[身份验证] 检测到延迟出现的密码验证页,重新填写密码...');
+ const pwdSuccess = await inputPassword(page, password, log);
+ const changedAfterSubmit = await detectPasswordChangedRecentlyText(page);
+ if (changedAfterSubmit) {
+ log(`[身份验证] 检测到密码已被修改: ${changedAfterSubmit}`);
+ return [false, '密码已被修改'];
+ }
+ if (!pwdSuccess) return [false, '密码输入失败'];
+ passwordChallengeSubmitted = true;
+ passwordChallengeStallCount = 0;
+ log('✅ 延迟密码验证已提交');
+ await page.waitForTimeout(1000);
+ continue;
+ }
+
+ // 已提交后短暂留在原 URL 可能只是 Google 正在跳转;给两轮缓冲,
+ // 但不重复提交密码,避免页面慢时产生无意义的多次登录尝试。
+ passwordChallengeStallCount++;
+ if (passwordChallengeStallCount >= 2) {
+ return [false, '密码验证提交后仍停留在密码页面'];
+ }
+ log('[身份验证] 密码已提交,等待页面跳转...');
+ await page.waitForTimeout(1200);
+ continue;
+ }
+
// DP(设备验证)、OOTP(手机安全码)或 SKOTP(g.co/sc安全码)→ 点 "Try another way"
const isDP = u.includes('signin/challenge/dp');
const isOOTP = u.includes('signin/challenge/ootp');
diff --git a/src/services/language_service.js b/src/services/language_service.js
index ce02270..3c95d20 100644
--- a/src/services/language_service.js
+++ b/src/services/language_service.js
@@ -21,10 +21,10 @@ const PRIMARY_REFRESH_TIMEOUT_MS = 15000;
const CONFIRM_SETTLE_MS = 1500;
const EDITOR_CLOSED_STABLE_MS = 750;
const DIRECTION_MARKS_RE = /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;
-const SAVE_ACTION_RE = /^(?:save(?:\s|$)|simpan(?:\s|$)|保存|儲存|确认|確認|确定|確定|应用|套用|完成|guardar(?:\s|$)|salvar(?:\s|$)|salva(?:\s|$)|enregistrer(?:\s|$)|speichern(?:\s|$)|opslaan(?:\s|$)|zapisz(?:\s|$)|zachowaj(?:\s|$)|сохранить(?:\s|$)|зберегти(?:\s|$)|kaydet(?:\s|$)|저장|lưu(?:\s|$)|บันทึก|حفظ(?:\s|$)|שמור(?:\s|$)|ok(?:\s|$)|confirm(?:\s|$)|apply(?:\s|$)|done(?:\s|$))/i;
-const CANCEL_ACTION_RE = /^(?:cancel(?:\s|$)|back(?:\s|$)|close(?:\s|$)|dismiss(?:\s|$)|batal|kembali(?:\s|$)|tutup(?:\s|$)|取消|返回|关闭|關閉|annuler(?:\s|$)|retour(?:\s|$)|abbrechen(?:\s|$)|zurück(?:\s|$)|cancelar(?:\s|$)|volver(?:\s|$)|anular(?:\s|$)|annulla(?:\s|$)|indietro(?:\s|$)|отмена(?:\s|$)|назад(?:\s|$)|скасувати(?:\s|$)|назад(?:\s|$)|iptal(?:\s|$)|geri(?:\s|$)|취소|뒤로|キャンセル|戻る|hủy(?:\s|$)|quay lại(?:\s|$)|ยกเลิก|กลับ|إلغاء|رجوع|ביטול|חזרה)/i;
-const EDIT_ACTION_RE = /^(?:edit(?:\s|$)|ubah(?:\s|$)|修改|編輯|editar(?:\s|$)|modifier(?:\s|$)|bearbeiten(?:\s|$)|modifica(?:\s|$)|bewerken(?:\s|$)|edytuj(?:\s|$)|изменить(?:\s|$)|змінити(?:\s|$)|düzenle(?:\s|$)|수정|変更)/i;
-const NON_EDIT_ACTION_RE = /^(?:delete(?:\s|$)|remove(?:\s|$)|hapus(?:\s|$)|save(?:\s|$)|simpan(?:\s|$)|删除|刪除|移除|保存|儲存|supprimer(?:\s|$)|eliminar(?:\s|$)|entfernen(?:\s|$)|rimuovi(?:\s|$)|remover(?:\s|$)|verwijder(?:\s|$)|usuń(?:\s|$)|удалить(?:\s|$)|видалити(?:\s|$)|kaldır(?:\s|$)|삭제|削除)/i;
+const SAVE_ACTION_RE = /^(?:save(?:\s|$)|simpan(?:\s|$)|uložiť(?:\s|$)|保存|儲存|确认|確認|确定|確定|应用|套用|完成|सहेजें|सेव(?:\s|$)|चुनें|लागू करें|ठीक है|guardar(?:\s|$)|salvar(?:\s|$)|salva(?:\s|$)|enregistrer(?:\s|$)|speichern(?:\s|$)|opslaan(?:\s|$)|zapisz(?:\s|$)|zachowaj(?:\s|$)|сохранить(?:\s|$)|зберегти(?:\s|$)|kaydet(?:\s|$)|저장|lưu(?:\s|$)|บันทึก|حفظ(?:\s|$)|שמור(?:\s|$)|ok(?:\s|$)|confirm(?:\s|$)|apply(?:\s|$)|done(?:\s|$))/i;
+const CANCEL_ACTION_RE = /^(?:cancel(?:\s|$)|back(?:\s|$)|close(?:\s|$)|dismiss(?:\s|$)|batal|kembali(?:\s|$)|tutup(?:\s|$)|zrušiť(?:\s|$)|späť(?:\s|$)|zavrieť(?:\s|$)|取消|返回|关闭|關閉|रद्द करें|वापस|बंद करें|annuler(?:\s|$)|retour(?:\s|$)|abbrechen(?:\s|$)|zurück(?:\s|$)|cancelar(?:\s|$)|volver(?:\s|$)|anular(?:\s|$)|annulla(?:\s|$)|indietro(?:\s|$)|отмена(?:\s|$)|назад(?:\s|$)|скасувати(?:\s|$)|назад(?:\s|$)|iptal(?:\s|$)|geri(?:\s|$)|취소|뒤로|キャンセル|戻る|hủy(?:\s|$)|quay lại(?:\s|$)|ยกเลิก|กลับ|إلغاء|رجوع|ביטול|חזרה)/i;
+const EDIT_ACTION_RE = /^(?:edit(?:\s|$)|ubah(?:\s|$)|upraviť(?:\s|$)|修改|編輯|editar(?:\s|$)|modifier(?:\s|$)|bearbeiten(?:\s|$)|modifica(?:\s|$)|bewerken(?:\s|$)|edytuj(?:\s|$)|изменить(?:\s|$)|змінити(?:\s|$)|düzenle(?:\s|$)|수정|変更)/i;
+const NON_EDIT_ACTION_RE = /^(?:delete(?:\s|$)|remove(?:\s|$)|hapus(?:\s|$)|save(?:\s|$)|simpan(?:\s|$)|uložiť(?:\s|$)|删除|刪除|移除|保存|儲存|supprimer(?:\s|$)|eliminar(?:\s|$)|entfernen(?:\s|$)|rimuovi(?:\s|$)|remover(?:\s|$)|verwijder(?:\s|$)|usuń(?:\s|$)|удалить(?:\s|$)|видалити(?:\s|$)|kaldır(?:\s|$)|삭제|削除)/i;
/**
* 从 Playwright page 对象更改当前登录账号的语言为 English (United States)
@@ -76,28 +76,35 @@ async function changeLanguageFromPage(page, logCallback = null) {
log(`[改语言] ❌ 未找到 United States 选项(${state})`);
return [false, '未找到 United States 选项'];
}
- log('[改语言] 选择 United States...');
- await regionResult.option.click();
-
- const saveResult = await waitForSaveButtonOnCurrentEditor(page, CONTROL_TIMEOUT_MS);
- if (!saveResult) {
- log('[改语言] ❌ 未找到可点击的保存按钮');
- return [false, '未找到可点击的保存按钮'];
- }
-
- // Google 保存首选语言后会自行导航到 ?refresh_reason=primary。
- // 提前监听,避免随后主动 goto 与这次自动刷新竞争。
+ // 某些 Google 灰度版本在选择地区后会自动保存并立即刷新,因此必须在点击前监听。
const primaryRefreshWatcher = createPrimaryRefreshWatcher(page, PRIMARY_REFRESH_TIMEOUT_MS);
let confirmation;
+ let commitMode = 'unknown';
try {
- await saveResult.button.click();
- log('[改语言] 已点击保存,等待 Google 应用设置...');
+ log('[改语言] 选择 United States...');
+ await regionResult.option.click();
- const editorClosed = await waitForEditorClosed(page, APPLY_TIMEOUT_MS);
- if (!editorClosed) {
- const state = await describePageState(page);
- log(`[改语言] ❌ 保存后编辑界面仍未关闭(${state})`);
- return [false, '保存后页面未完成更新'];
+ const commitResult = await waitForSaveOrAutoApply(page, CONTROL_TIMEOUT_MS);
+ if (commitResult && commitResult.mode === 'save') {
+ commitMode = 'save';
+ await commitResult.button.click();
+ log('[改语言] 已点击保存,等待 Google 应用设置...');
+
+ const editorClosed = await waitForEditorClosed(page, APPLY_TIMEOUT_MS);
+ if (!editorClosed) {
+ const state = await describePageState(page);
+ log(`[改语言] ❌ 保存后编辑界面仍未关闭(${state})`);
+ return [false, '保存后页面未完成更新'];
+ }
+ } else if (commitResult && commitResult.mode === 'auto') {
+ commitMode = 'auto';
+ log('[改语言] 未显示保存按钮,页面已自动提交,正在复查结果...');
+ } else if (commitResult && commitResult.mode === 'blocked') {
+ log('[改语言] ❌ 编辑界面仍在,但未找到可点击的保存按钮');
+ return [false, '未找到可点击的保存按钮'];
+ } else {
+ // 不能仅凭“没找到按钮”认定失败:页面可能已自动保存,但关闭动画或刷新形态不同。
+ log('[改语言] 未找到可点击的保存按钮,正在通过当前首选语言复查是否已自动保存...');
}
confirmation = await confirmEnglishUnitedStates(page, log, primaryRefreshWatcher);
@@ -107,8 +114,14 @@ async function changeLanguageFromPage(page, logCallback = null) {
if (!confirmation.confirmed) {
const current = confirmation.ariaLabel || '未读取到当前语言';
- log(`[改语言] ❌ 保存后复查未生效,当前首选语言: ${current}`);
- return [false, '保存后复查语言未变为 English (United States)'];
+ log(`[改语言] ❌ 操作后复查未生效,当前首选语言: ${current}`);
+ if (commitMode === 'save') {
+ return [false, '保存后复查语言未变为 English (United States)'];
+ }
+ if (commitMode === 'auto') {
+ return [false, '自动保存后复查语言未变为 English (United States)'];
+ }
+ return [false, '未找到可点击的保存按钮,且复查语言未变为 English (United States)'];
}
log('[改语言] ✅ 语言已成功更改为 English (United States)');
@@ -508,6 +521,32 @@ async function waitForSaveButtonOnCurrentEditor(page, timeoutMs) {
return null;
}
+/**
+ * 等待显式保存按钮,或确认选择地区后编辑器已稳定关闭(Google 自动保存版本)。
+ * 编辑器短暂重挂载时不会立刻判为自动保存。
+ */
+async function waitForSaveOrAutoApply(page, timeoutMs) {
+ const deadline = Date.now() + timeoutMs;
+ let closedAt = 0;
+ let sawVisibleEditor = false;
+ do {
+ const editor = await findVisibleEditorRoot(page);
+ if (editor) {
+ sawVisibleEditor = true;
+ closedAt = 0;
+ const button = await findSaveButton(editor);
+ if (button) return { mode: 'save', editor, button };
+ } else {
+ if (!closedAt) closedAt = Date.now();
+ if (Date.now() - closedAt >= EDITOR_CLOSED_STABLE_MS) {
+ return { mode: 'auto' };
+ }
+ }
+ await delay(page, POLL_INTERVAL_MS);
+ } while (Date.now() < deadline);
+ return sawVisibleEditor ? { mode: 'blocked' } : null;
+}
+
async function waitForEditorClosed(page, timeoutMs) {
const deadline = Date.now() + timeoutMs;
let closedAt = 0;
@@ -647,5 +686,6 @@ module.exports = {
navigateLanguagePageSafely,
waitForEditorRoot,
waitForEditorClosed,
+ waitForSaveOrAutoApply,
},
};
diff --git a/src/services/login_service.js b/src/services/login_service.js
index 9b9edec..41120a0 100644
--- a/src/services/login_service.js
+++ b/src/services/login_service.js
@@ -741,22 +741,26 @@ async function waitForPostPasswordChallenge(page, accountInfo, logCallback = nul
const kind = challengeKind(currentUrl);
const attempts = (alternateChallengeAttempts.get(kind) || 0) + 1;
alternateChallengeAttempts.set(kind, attempts);
+ const messages = {
+ ipp: '需要手机号或设备身份确认,且页面无法切换到 Google Authenticator',
+ kpp: '当前身份确认页面无法切换到 Google Authenticator',
+ bc: '需要备用码验证,且页面无法切换到 Google Authenticator',
+ };
+ const message = messages[kind] || '当前验证页面无法切换到 Google Authenticator';
log(`[登录] 🔄 检测到 ${kind.toUpperCase()} 验证页,尝试切换到 Google Authenticator (${attempts}/3)...`);
await checkpointOnce(cp, page, email, `login_${kind}_challenge`, log, null);
const leaveResult = await tryLeaveAlternateChallenge(page, log, 8000);
if (leaveResult.moved) {
await page.waitForTimeout(500);
- continue;
+ // 部分 IPP 页会短暂改变 DOM,让 tryLeave 以为已离开,但 URL
+ // 仍是同一挑战。到达3次上限后必须按 URL 复核,禁止出现4/3、8/3。
+ if (challengeKind(page.url()) !== kind || !isRecoverableAlternateChallengeUrl(page.url())) {
+ continue;
+ }
}
if (attempts >= 3) {
- const messages = {
- ipp: '需要手机号或设备身份确认,且页面无法切换到 Google Authenticator',
- kpp: '当前身份确认页面无法切换到 Google Authenticator',
- bc: '需要备用码验证,且页面无法切换到 Google Authenticator',
- };
- const message = messages[kind] || '当前验证页面无法切换到 Google Authenticator';
errorEvent(email, log, { phase: `${kind}_challenge`, url: page.url(), message });
return [false, message];
}
diff --git a/src/utils/concurrent_runner.js b/src/utils/concurrent_runner.js
index ce0ea09..c4ff620 100644
--- a/src/utils/concurrent_runner.js
+++ b/src/utils/concurrent_runner.js
@@ -7,6 +7,7 @@
* @param {Array} items - 待处理项目列表
* @param {Function} handler - 单项处理函数 async (item, index) => result
* @param {object} options - 并发、停止、错误与进度回调配置
+ * @param {Function} [options.getDeferDelay] - 返回大于 0 的毫秒数时,将当前项延迟后重新入队;等待期间不占并发槽
* @returns {Promise<{results: Array, completed: number, total: number, stopped: boolean}>}
*/
async function runConcurrentItems(items, handler, options = {}) {
@@ -25,10 +26,49 @@ async function runConcurrentItems(items, handler, options = {}) {
const onProgress = typeof options.onProgress === 'function'
? options.onProgress
: null;
+ const getDeferDelay = typeof options.getDeferDelay === 'function'
+ ? options.getDeferDelay
+ : null;
const results = new Array(list.length);
- let cursor = 0;
+ const readyQueue = list.map((_, index) => index);
+ const workWaiters = [];
+ const deferredTimers = new Set();
let completed = 0;
+ function notifyWorkers() {
+ while (workWaiters.length) {
+ const resolve = workWaiters.shift();
+ try { resolve(); } catch (_) {}
+ }
+ }
+
+ // 定时醒来检查停止信号,避免所有项目都在延迟队列时“停止任务”要等完整延迟时间。
+ async function waitForWork() {
+ await new Promise(resolve => {
+ let settled = false;
+ let timer = null;
+ const wake = () => {
+ if (settled) return;
+ settled = true;
+ if (timer) clearTimeout(timer);
+ const index = workWaiters.indexOf(wake);
+ if (index >= 0) workWaiters.splice(index, 1);
+ resolve();
+ };
+ workWaiters.push(wake);
+ timer = setTimeout(wake, 250);
+ });
+ }
+
+ function deferIndex(index, delayMs) {
+ const timer = setTimeout(() => {
+ deferredTimers.delete(timer);
+ if (!shouldStop()) readyQueue.push(index);
+ notifyWorkers();
+ }, delayMs);
+ deferredTimers.add(timer);
+ }
+
/**
* 从共享游标连续领取项目;JavaScript 同一线程内的游标递增不会重复领取。
*/
@@ -37,13 +77,26 @@ async function runConcurrentItems(items, handler, options = {}) {
// 暂停只阻止领取下一项;已经进入 handler 的项目安全完成当前流程。
if (waitUntilReady) await waitUntilReady();
if (shouldStop()) break;
- const index = cursor;
- if (index >= list.length) break;
- cursor += 1;
+ if (completed >= list.length) break;
+ const index = readyQueue.shift();
+ if (index === undefined) {
+ await waitForWork();
+ continue;
+ }
let itemError = null;
+ let deferred = false;
try {
results[index] = await handler(list[index], index);
+ if (getDeferDelay && !shouldStop()) {
+ let delayMs = 0;
+ try { delayMs = Number(await getDeferDelay(results[index], list[index], index)) || 0; } catch (_) {}
+ if (delayMs > 0) {
+ deferred = true;
+ results[index] = undefined;
+ deferIndex(index, Math.max(1, delayMs));
+ }
+ }
} catch (error) {
itemError = error;
results[index] = { ok: false, error };
@@ -56,6 +109,7 @@ async function runConcurrentItems(items, handler, options = {}) {
}
}
} finally {
+ if (deferred) continue;
completed += 1;
if (onProgress) {
try {
@@ -76,6 +130,9 @@ async function runConcurrentItems(items, handler, options = {}) {
// allSettled 是最后一道批次隔离,避免调度器内部异常触发整批提前结束。
await Promise.allSettled(Array.from({ length: workerCount }, () => worker()));
+ for (const timer of deferredTimers) clearTimeout(timer);
+ deferredTimers.clear();
+ notifyWorkers();
return {
results,
completed,
diff --git a/src/utils/diagnostics.js b/src/utils/diagnostics.js
index cdfb283..c266674 100644
--- a/src/utils/diagnostics.js
+++ b/src/utils/diagnostics.js
@@ -28,8 +28,38 @@ function redactHostPortUserPass(text) {
return `${host}:${port}:${user}:***`;
}
+function redactSensitiveQueryValues(text) {
+ if (!text) return text;
+ return String(text).replace(
+ /([?&](?:password|confirmation_password|new_password|passwd|pwd)=)[^\s"']*/gi,
+ '$1***',
+ );
+}
+
+function redactApiKeys(text) {
+ if (!text) return text;
+ return String(text)
+ .replace(/AIza[A-Za-z0-9_-]{20,}/g, 'AIza***')
+ .replace(/AQ\.[A-Za-z0-9_-]{16,}/g, 'AQ.***');
+}
+
function redactText(text) {
- return redactBase32Key(redactHostPortUserPass(redactUrlCredentials(text)));
+ return redactApiKeys(
+ redactSensitiveQueryValues(
+ redactBase32Key(redactHostPortUserPass(redactUrlCredentials(text))),
+ ),
+ );
+}
+
+function redactValue(value, seen = new WeakSet()) {
+ if (typeof value === 'string') return redactText(value);
+ if (!value || typeof value !== 'object') return value;
+ if (seen.has(value)) return '[Circular]';
+ seen.add(value);
+ if (Array.isArray(value)) return value.map(item => redactValue(item, seen));
+ const out = {};
+ for (const [key, item] of Object.entries(value)) out[key] = redactValue(item, seen);
+ return out;
}
function safeString(v, fallback = '') {
@@ -87,7 +117,8 @@ function makeLimiter(windowMs = 10000, maxKeys = 5000) {
const allowEvent = makeLimiter(8000, 8000);
function writeEvent(email, event) {
- const e = { ts: nowIso(), ...event };
+ // 所有诊断事件统一在最底层脱敏,覆盖 url、message、meta 以及未来新增字段。
+ const e = redactValue({ ts: nowIso(), ...event });
appendAccountErrorJsonl(email, safeJson(e));
return e;
}
diff --git a/src/utils/os_mouse.js b/src/utils/os_mouse.js
new file mode 100644
index 0000000..2fb62f5
--- /dev/null
+++ b/src/utils/os_mouse.js
@@ -0,0 +1,205 @@
+/**
+ * @file utils/os_mouse.js
+ * @brief Windows 下用真实操作系统光标点击网页元素(SetCursorPos + mouse_event,经 user32.dll)。
+ *
+ * 【为什么需要】Google AI Studio 的 `alkalimakersuite-pa.clients6.google.com/$rpc/.../MakerSuiteService/*`
+ * 端点(DeleteCloudApiKey / GenerateCloudApiKey)由 botguard 反爬把关,会对“可疑请求”返回 HTTP 403。
+ * Playwright / CDP 的点击是注入到渲染进程的合成事件(不移动真实光标、无真实硬件输入时序),
+ * 在“生成凭证”这类高价值操作上会被判为可疑;而真人用真实鼠标在同一窗口手点却能通过。
+ * 本模块让点击【真的来自操作系统】:移动真实 Windows 光标到元素中心再按下/抬起,
+ * 产生的事件与真人无法区分,从而通过 botguard。仍是全自动,无需人工。
+ *
+ * 【坐标系】PowerShell 进程保持 DPI-unaware:SetCursorPos/GetCursorPos 使用“逻辑像素”,
+ * 与 Chrome 的 window.screenX/screenY(CSS px)同一坐标系,规避 DPI 缩放换算(单显示器成立)。
+ * 若多显示器/异形 DPI 导致点偏,computeScreenPoint 会把算出的坐标打进日志,便于校正。
+ */
+
+'use strict';
+
+const { execFileSync } = require('child_process');
+
+// 系统只有一个真实鼠标。多个账号并发时,如果 page.bringToFront() 和 SetCursorPos
+// 在不同任务之间交错,坐标会被点击到另一个浏览器窗口。用进程级 FIFO 锁把
+// “切前台 → 计算坐标 → 点击”作为一个不可交错的临界区;其它网页步骤仍可并发。
+let osMouseQueue = Promise.resolve();
+let osMousePending = 0;
+
+async function withOsMouseLock(operation, log = () => {}) {
+ const predecessor = osMouseQueue.catch(() => {});
+ let release;
+ osMouseQueue = new Promise(resolve => { release = resolve; });
+ osMousePending++;
+ const queuedAhead = osMousePending - 1;
+ if (queuedAhead > 0) {
+ log(`[OS鼠标] 等待其它窗口完成真实鼠标点击(前方 ${queuedAhead} 个)...`);
+ }
+
+ await predecessor;
+ try {
+ return await operation();
+ } finally {
+ osMousePending--;
+ release();
+ }
+}
+
+function isWindows() {
+ return process.platform === 'win32';
+}
+
+/**
+ * 计算元素中心点在“屏幕逻辑像素(CSS px)”坐标系下的位置。
+ * @returns {Promise<{x:number,y:number,geo:object,box:object,chromeTop:number}|null>}
+ */
+async function computeScreenPoint(page, target) {
+ try {
+ await target.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
+ const box = await target.boundingBox();
+ if (!box) return null;
+ const geo = await page.evaluate(() => ({
+ screenX: window.screenX,
+ screenY: window.screenY,
+ outerWidth: window.outerWidth,
+ outerHeight: window.outerHeight,
+ innerWidth: window.innerWidth,
+ innerHeight: window.innerHeight,
+ dpr: window.devicePixelRatio || 1,
+ })).catch(() => null);
+ if (!geo) return null;
+ // 视口左上角在屏幕上的逻辑坐标:
+ // - 现代 Chrome 无左右/底部边框:viewportLeft ≈ screenX
+ // - 全部浏览器 chrome(标签栏 + 地址栏)在顶部:viewportTop ≈ screenY + (outerHeight - innerHeight)
+ const chromeTop = Math.max(0, geo.outerHeight - geo.innerHeight);
+ const viewportLeft = geo.screenX;
+ const viewportTop = geo.screenY + chromeTop;
+ const x = Math.round(viewportLeft + box.x + box.width / 2);
+ const y = Math.round(viewportTop + box.y + box.height / 2);
+ return { x, y, geo, box, chromeTop };
+ } catch (_) {
+ return null;
+ }
+}
+
+/**
+ * 真实光标:从当前位置缓动插值 + 抖动移动到 (x,y),再真实按下/抬起左键。
+ * 同步阻塞(execFileSync),单次约 1s,供每次“危险点击”调用;失败抛异常由上层回退。
+ */
+function osHumanClickAt(x, y) {
+ const tx = Math.round(x);
+ const ty = Math.round(y);
+ const ps = [
+ "$ErrorActionPreference = 'Stop'",
+ '$sig = @"',
+ 'using System;',
+ 'using System.Runtime.InteropServices;',
+ 'public class WMouse {',
+ ' [StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; }',
+ ' [DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT p);',
+ ' [DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y);',
+ ' [DllImport("user32.dll")] public static extern void mouse_event(uint f, uint dx, uint dy, uint d, IntPtr e);',
+ '}',
+ '"@',
+ 'Add-Type -TypeDefinition $sig',
+ `$tx = ${tx}`,
+ `$ty = ${ty}`,
+ '$cur = New-Object WMouse+POINT',
+ '[WMouse]::GetCursorPos([ref]$cur) | Out-Null',
+ '$sx = $cur.X; $sy = $cur.Y',
+ '$steps = 20',
+ '$rnd = New-Object System.Random',
+ 'for ($i = 1; $i -le $steps; $i++) {',
+ ' $t = $i / $steps',
+ ' if ($t -lt 0.5) { $e = 2*$t*$t } else { $e = -1 + (4 - 2*$t)*$t }', // ease-in-out
+ ' if ($i -lt $steps) { $jx = $rnd.Next(-2,3); $jy = $rnd.Next(-2,3) } else { $jx = 0; $jy = 0 }',
+ ' $px = [int]($sx + ($tx - $sx) * $e) + $jx',
+ ' $py = [int]($sy + ($ty - $sy) * $e) + $jy',
+ ' [WMouse]::SetCursorPos($px, $py) | Out-Null',
+ ' Start-Sleep -Milliseconds ($rnd.Next(8,22))',
+ '}',
+ '[WMouse]::SetCursorPos($tx, $ty) | Out-Null',
+ 'Start-Sleep -Milliseconds ($rnd.Next(90,180))',
+ '[WMouse]::mouse_event(0x0002,0,0,0,[IntPtr]::Zero)', // LEFTDOWN
+ 'Start-Sleep -Milliseconds ($rnd.Next(50,110))',
+ '[WMouse]::mouse_event(0x0004,0,0,0,[IntPtr]::Zero)', // LEFTUP
+ ].join('\n');
+ execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ps], {
+ timeout: 20000,
+ windowsHide: true,
+ stdio: 'ignore',
+ });
+}
+
+/**
+ * 向当前前台浏览器窗口发送真实 Escape 按键。
+ * Chrome 的“要保存密码吗”属于浏览器外壳 UI,不在网页 DOM 内,Playwright
+ * 无法定位或关闭;进入 Cloud Console 前用原生按键关闭它,避免遮挡首次条款。
+ */
+function osPressEscapeKey() {
+ const ps = [
+ "$ErrorActionPreference = 'Stop'",
+ '$sig = @"',
+ 'using System;',
+ 'using System.Runtime.InteropServices;',
+ 'public class WKeyboard {',
+ ' [DllImport("user32.dll")] public static extern void keybd_event(byte key, byte scan, uint flags, IntPtr extra);',
+ '}',
+ '"@',
+ 'Add-Type -TypeDefinition $sig',
+ '[WKeyboard]::keybd_event(0x1B, 0, 0, [IntPtr]::Zero)',
+ 'Start-Sleep -Milliseconds 80',
+ '[WKeyboard]::keybd_event(0x1B, 0, 2, [IntPtr]::Zero)',
+ ].join('\n');
+ execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ps], {
+ timeout: 10000,
+ windowsHide: true,
+ stdio: 'ignore',
+ });
+}
+
+async function dismissBrowserChromePopup(page, log = () => {}) {
+ if (!isWindows() || !page) return false;
+ return withOsMouseLock(async () => {
+ try {
+ await page.bringToFront().catch(() => {});
+ osPressEscapeKey();
+ log('[OS键盘] 已发送 Escape,关闭可能遮挡页面的浏览器保存密码弹窗');
+ return true;
+ } catch (error) {
+ log(`[OS键盘] 关闭浏览器弹窗失败,继续网页流程:${error && error.message ? error.message : error}`);
+ return false;
+ }
+ }, log);
+}
+
+/**
+ * 用真实 OS 光标点击一个 Playwright 元素。
+ * @returns {Promise} true=已用 OS 光标点击;false=非 Windows / 无法定位 / 失败(上层应回退)。
+ */
+async function osClickElement(page, target, log = () => {}) {
+ if (!isWindows() || !target) return false;
+ return withOsMouseLock(async () => {
+ try {
+ await page.bringToFront().catch(() => {});
+ const pt = await computeScreenPoint(page, target);
+ if (!pt) {
+ log('[OS鼠标] 无法计算屏幕坐标,回退合成点击');
+ return false;
+ }
+ log(`[OS鼠标] 目标屏幕坐标 x=${pt.x} y=${pt.y}(screenX=${pt.geo.screenX} screenY=${pt.geo.screenY} chromeTop=${pt.chromeTop} innerH=${pt.geo.innerHeight} outerH=${pt.geo.outerHeight} box=${Math.round(pt.box.x)},${Math.round(pt.box.y)} ${Math.round(pt.box.width)}x${Math.round(pt.box.height)} dpr=${pt.geo.dpr})`);
+ osHumanClickAt(pt.x, pt.y);
+ return true;
+ } catch (e) {
+ log(`[OS鼠标] 点击失败,回退合成点击:${e && e.message ? e.message : e}`);
+ return false;
+ }
+ }, log);
+}
+
+module.exports = {
+ isWindows,
+ computeScreenPoint,
+ osHumanClickAt,
+ osClickElement,
+ dismissBrowserChromePopup,
+ _private: { withOsMouseLock, osPressEscapeKey },
+};
diff --git a/src/utils/screenshot.js b/src/utils/screenshot.js
index 771e85d..c4df19c 100644
--- a/src/utils/screenshot.js
+++ b/src/utils/screenshot.js
@@ -46,6 +46,35 @@ function isTargetClosedError(error) {
.test(String(error && error.message ? error.message : error || ''));
}
+/** 截图前统一遮盖密码控件;即使页面把 type=password 切成 text,也按名称和标签遮盖。 */
+function getSensitiveMaskLocators(page) {
+ if (!page || typeof page.locator !== 'function') return [];
+ const masks = [page.locator([
+ 'input[type="password"]',
+ 'input[name*="password" i]',
+ 'input[autocomplete*="password" i]',
+ 'input[aria-label*="password" i]',
+ 'input[autocomplete="one-time-code"]',
+ 'input[name*="code" i]',
+ 'input[aria-label*="code" i]',
+ ].join(', '))];
+ if (typeof page.getByRole === 'function') {
+ masks.push(page.getByRole('dialog').filter({
+ hasText: /Enter a setup key|Set up authenticator app|输入设置密钥|设置身份验证器/i,
+ }));
+ }
+ return masks;
+}
+
+async function takeSafeScreenshot(page, fullPath) {
+ await page.screenshot({
+ path: fullPath,
+ fullPage: false,
+ mask: getSensitiveMaskLocators(page),
+ maskColor: '#000000',
+ });
+}
+
/**
* 保存错误截图
* @param {import('playwright').Page} page - Playwright 页面对象
@@ -78,7 +107,7 @@ async function saveErrorScreenshot(page, email, step) {
// 失败页面可能随即被浏览器生命周期关闭,优先立即截图。
if (isPageClosed(page)) return null;
- await page.screenshot({ path: fullPath, fullPage: false });
+ await takeSafeScreenshot(page, fullPath);
// 返回相对路径(供前端拼 URL 和数据库存储)
return `screenshots/${safeEmailDir(email)}/${filename}`;
@@ -114,7 +143,7 @@ async function saveCheckpointScreenshot(page, email, tag) {
const fullPath = path.join(dir, filename);
if (isPageClosed(page)) return null;
- await page.screenshot({ path: fullPath, fullPage: false });
+ await takeSafeScreenshot(page, fullPath);
return `screenshots/${safeEmailDir(email)}/${filename}`;
} catch (e) {
@@ -167,5 +196,7 @@ module.exports = {
_private: {
isPageClosed,
isTargetClosedError,
+ getSensitiveMaskLocators,
+ takeSafeScreenshot,
},
};
diff --git a/src/workers/task_runner.js b/src/workers/task_runner.js
index c2fb766..fe6b03b 100644
--- a/src/workers/task_runner.js
+++ b/src/workers/task_runner.js
@@ -23,6 +23,35 @@ const {
const OAUTH_WAIT_7_DAYS_STATUS = 'OAuth等待7天';
+// ==================== AI Studio 一键组合:流程日志辅助 ====================
+// 失败步骤按 ctx.results 顺序判定,兼容 changePassword/change2fa 失败时不设 ctx.failure 的情况。
+const AISTUDIO_STEP_LABELS = {
+ loginStep: '登录',
+ countryStep: '查看国家',
+ detectCountryStep: '查看国家',
+ changeLanguageStep: '更改语言',
+ changePasswordStep: '改密码',
+ change2faStep: '更改2FA',
+ agGetCredentialStep: '获取凭证',
+ aistudioApiKeyStep: '处理AI Studio API Key',
+};
+
+function resolveAistudioFailedStep(ctx) {
+ const r = (ctx && ctx.results) || {};
+ if (r.login && r.login.success === false) return { label: '登录', msg: r.login.msg || '登录失败' };
+ if (r.changePassword && r.changePassword.success === false) return { label: '改密码', msg: r.changePassword.msg || '改密失败' };
+ if (r.change2fa && r.change2fa.success === false) return { label: '更改2FA', msg: r.change2fa.msg || '更改2FA失败' };
+ if (r.agGetCredential && r.agGetCredential.success === false) return { label: '获取凭证', msg: r.agGetCredential.msg || '获取凭证失败' };
+ if (r.aistudioApiKey && r.aistudioApiKey.success === false) return { label: '处理AI Studio API Key', msg: r.aistudioApiKey.msg || 'API Key处理失败' };
+ if (ctx && ctx.failure) return { label: AISTUDIO_STEP_LABELS[ctx.failure.step] || ctx.failure.step || '未知步骤', msg: ctx.failure.msg || '步骤异常' };
+ if (ctx && ctx.shouldStop) return { label: '未知步骤', msg: '流程中断' };
+ return null;
+}
+
+function isClosedBrowserError(message) {
+ return /Target page, context or browser has been closed|browser has been closed/i.test(String(message || ''));
+}
+
// Pipeline
const { withBrowser, openThrottle } = require('../pipeline/browser_lifecycle');
const { runImportBrowserBatch, normalizeConcurrency, DEFAULT_MAX_CONCURRENCY } = require('../services/import_browser_batch_service');
@@ -39,6 +68,9 @@ const {
HARDCODED_BROWSER_PROXIES,
pickRandomBrowserProxy,
} = require('../services/hardcoded_browser_proxy_pool');
+const {
+ watchManualAistudioKey,
+} = require('../services/aistudio_manual_key_watcher');
// ==================== 工具函数 ====================
@@ -209,6 +241,9 @@ async function runConcurrent(items, fn, concurrency, progressOptions = {}) {
total: progressTotal,
});
},
+ getDeferDelay: result => (result && result.__defer === true
+ ? Math.max(1, Number(result.delayMs) || 0)
+ : 0),
});
return batch.results;
}
@@ -438,7 +473,13 @@ async function runTask() {
const { getAccount } = require('../core/database');
const pipelineResults = await runConcurrent(accounts, async (account) => {
- const acc = normalizeAccount(account);
+ let acc = normalizeAccount(account);
+ // 项目同步延迟重试会重新调用当前账号处理器。刷新 DB 中的新密码/2FA,
+ // 同时保留调度器写在原账号对象上的项目 ID 与等待次数。
+ if (taskType === 'aistudio_combo' && Number(account && account._aistudioProjectSyncWaits) > 0) {
+ const latest = getAccount(acc.email);
+ if (latest) acc = normalizeAccount({ ...account, ...latest });
+ }
// 套餐查询是真正的后台接口任务:读取账号绑定代理,但不打开、修改或依赖
// 比特浏览器窗口。这样查询流量和其他浏览器自动化完全分离。
if (taskType === 'openai_query_plan') {
@@ -528,13 +569,21 @@ async function runTask() {
}
}
- const recipe = taskType === 'custom_pipeline'
- ? buildCustomRecipe(params && params.tasks, currentAcc)
- : getRecipe(taskType, currentAcc);
+ const resumeAistudioProjectSync = taskType === 'aistudio_combo'
+ && Number(currentAcc && currentAcc._aistudioProjectSyncWaits) > 0;
+ const recipe = resumeAistudioProjectSync
+ ? [ATOM_STEPS.aistudio_api_key.step]
+ : (taskType === 'custom_pipeline'
+ ? buildCustomRecipe(params && params.tasks, currentAcc)
+ : getRecipe(taskType, currentAcc));
if (!recipe) {
await updateMessage(acc.email, `❌ 未知任务类型: ${taskType}`);
return { email: acc.email, ok: false, skipped: false, code: 'UNKNOWN_TASK', msg: `未知任务类型: ${taskType}` };
}
+ if (resumeAistudioProjectSync) {
+ accountLog('[AIStudio] 30 秒项目同步等待结束,重新入队;本轮只重试项目导入和创建 Key');
+ await taskUpdateMessage(acc.email, 'Cloud 项目同步等待结束,正在重新导入项目并创建 Key...');
+ }
const autoRetryTasks = PIPELINE_TASKS;
const maxAutoProxyRetries = Math.max(0, Math.min(3, parseInt((params && params.autoProxyRetries) || '2', 10) || 2));
@@ -572,8 +621,13 @@ async function runTask() {
}
let lastFailure = null;
+ let aistudioFlowHandled = false;
let proxyRetryAttempt = 0;
let chatgptBrowserAttempt = 1;
+ // 人工 Key 同步是纯读取操作:当同一个比特浏览器正被人工关闭/重开时,
+ // CDP page 可能在登录跳转的瞬间失效。这不是账号失败,允许重新打开浏览器一次;
+ // 其它会修改账号状态的任务仍保持原来的不重跑策略。
+ let aistudioSyncBrowserRetry = 0;
while (proxyRetryAttempt <= maxAutoProxyRetries) {
let ctxRef = null;
const runRes = await withBrowser({
@@ -605,6 +659,18 @@ async function runTask() {
await runSteps(ctx, recipe);
+ // AI Studio 一键组合:无论成败都在同一浏览器作用域内落一条流程日志。
+ // 与 operation_logs 互不影响。改密/2FA 不可重复执行,故已排除自动重试。
+ if (taskType === 'aistudio_combo' && !aistudioFlowHandled && !ctx.defer) {
+ aistudioFlowHandled = true;
+ const failed = resolveAistudioFailedStep(ctx);
+ if (failed) {
+ await dbWrite('addAistudioFlowLog', [acc.email, 'failed', failed.label, failed.msg]).catch(() => {});
+ } else {
+ await dbWrite('addAistudioFlowLog', [acc.email, 'success', '', '全部步骤成功']).catch(() => {});
+ }
+ }
+
if (taskType === 'login' && !ctx.shouldStop) {
const subscribed = !!(ctx.results && ctx.results.login && ctx.results.login.subscribed);
await updateStatus(acc.email, subscribed ? '订阅成功' : '登录成功');
@@ -671,15 +737,47 @@ async function runTask() {
: null),
waitForManual: !!ctx.waitForManual,
manualReason: ctx.manualReason || null,
+ defer: ctx.defer || null,
};
});
if (!runRes || runRes.success === false) {
const rawMessage = runRes && runRes.error ? runRes.error : '执行失败';
lastFailure = { msg: rawMessage, meta: classifyFailure({ message: rawMessage, taskType }) };
+ if (taskType === 'aistudio_sync_key'
+ && aistudioSyncBrowserRetry < 1
+ && isClosedBrowserError(rawMessage)) {
+ aistudioSyncBrowserRetry++;
+ accountLog('[AIStudio] 同步时检测到比特浏览器被关闭/重开,重新打开后再读取一次...');
+ await taskUpdateMessage(acc.email, '比特浏览器刚被关闭,正在重新打开并同步人工 Key...');
+ await new Promise(resolve => setTimeout(resolve, 1200));
+ continue;
+ }
break;
}
+ if (taskType === 'aistudio_combo' && runRes.defer) {
+ const deferState = runRes.defer || {};
+ account._aistudioProjectSyncWaits = Math.max(
+ Number(account._aistudioProjectSyncWaits) || 0,
+ Number(deferState.waitCount) || 1,
+ );
+ account._aistudioPreferredProjectId = String(
+ deferState.preferredProjectId || acc._aistudioPreferredProjectId || '',
+ ).trim();
+ account._aistudioPreferredProjectName = String(
+ deferState.preferredProjectName || acc._aistudioPreferredProjectName || '',
+ ).trim();
+ account._aistudioPreferredProjectSavedAt = Number(
+ deferState.preferredProjectSavedAt || acc._aistudioPreferredProjectSavedAt || 0,
+ ) || 0;
+ return {
+ __defer: true,
+ delayMs: Math.max(1000, Number(deferState.delayMs) || (30 * 1000)),
+ email: acc.email,
+ };
+ }
+
if (taskType === 'openai_query_plan' && runRes.directResult) {
const direct = runRes.directResult;
return {
@@ -729,6 +827,18 @@ async function runTask() {
lastFailure = { msg: runRes.failedMsg || '任务失败', meta: runRes.failedMeta || null };
+ // login_service 会把 page/context closed 捕获后作为普通步骤失败返回,
+ // 因此除了 withBrowser 的外层异常,还必须在 shouldStop 路径恢复一次。
+ if (taskType === 'aistudio_sync_key'
+ && aistudioSyncBrowserRetry < 1
+ && isClosedBrowserError(lastFailure.msg)) {
+ aistudioSyncBrowserRetry++;
+ accountLog('[AIStudio] 同步步骤检测到比特浏览器被关闭/重开,重新打开后再读取一次...');
+ await taskUpdateMessage(acc.email, '比特浏览器刚被关闭,正在重新打开并同步人工 Key...');
+ await new Promise(resolve => setTimeout(resolve, 1200));
+ continue;
+ }
+
if (shouldRestartChatGPTBrowser(taskType, lastFailure, chatgptBrowserAttempt)) {
chatgptBrowserAttempt++;
const retryMessage = buildChatGPTBrowserRetryMessage(
@@ -745,6 +855,7 @@ async function runTask() {
const meta = lastFailure.meta || classifyFailure({ message: lastFailure.msg });
// 改密+2FA 都是不可安全重复执行的操作,组合任务失败后不自动从头重跑。
const canRetry = taskType !== 'security_chatgpt'
+ && taskType !== 'aistudio_combo'
&& autoRetryTasks.has(taskType)
&& shouldRotateProxyForFailure(meta, meta && meta.code)
&& proxyRetryAttempt < maxAutoProxyRetries;
@@ -777,6 +888,17 @@ async function runTask() {
await taskUpdateMessage(acc.email, '切换ip重试');
}
+ // 兜底:浏览器始终没打开成功(withBrowser 的 fn 没跑)→ 上面没记过日志,这里补一条 failed。
+ if (taskType === 'aistudio_combo' && !aistudioFlowHandled) {
+ aistudioFlowHandled = true;
+ await dbWrite('addAistudioFlowLog', [
+ acc.email,
+ 'failed',
+ '打开浏览器',
+ (lastFailure && lastFailure.msg) || '浏览器打开失败',
+ ]).catch(() => {});
+ }
+
if (!lastFailure) {
return { email: acc.email, ok: true, skipped: false, code: 'SUCCESS', msg: '成功' };
}
@@ -2455,6 +2577,83 @@ async function runTask() {
break;
}
+ case 'open_manual_aistudio': {
+ log('[任务] 打开比特浏览器,并监听人工创建 AI Studio Key...');
+ const watchTimeoutMs = Math.max(
+ 60000,
+ Math.min(2 * 60 * 60 * 1000, Number(params && params.watchTimeoutMs) || 30 * 60 * 1000),
+ );
+ await runConcurrent(accounts, async (account) => {
+ const acc = normalizeAccount(account);
+ if (!acc.browser_id) {
+ await updateMessage(acc.email, '⚠️ 无 browser_id,跳过');
+ return;
+ }
+ await updateMessage(acc.email, '正在打开比特浏览器...');
+ const accountLog = makeLogger(log, { context: acc.email });
+ const opened = await openBrowserWithRetry(acc.browser_id, { email: acc.email, logCallback: accountLog });
+ if (!opened.success || !opened.data || !opened.data.ws) {
+ await updateMessage(acc.email, `❌ 打开浏览器失败:${opened.msg || '无 ws 端点'}`);
+ return;
+ }
+
+ let browser = null;
+ try {
+ browser = await chromium.connectOverCDP(opened.data.ws);
+ await updateMessage(acc.email, '✅ 浏览器已打开,等待你手工创建 AI Studio Key...');
+ const watched = await watchManualAistudioKey(browser, acc, accountLog, {
+ timeoutMs: watchTimeoutMs,
+ keyName: 'Gemini API Key',
+ shouldStop: () => stopRequested,
+ });
+
+ if (watched.ok && watched.data && watched.data.apiKey) {
+ await dbWrite('upsertAistudioApiKey', [acc.email, watched.data]);
+ // 人工已经用真实项目完成导入并创建 Key,之前自动流程保存的待同步项目
+ // 无论是否仍存在都已不再适用;及时清除,避免下次自动流程恢复旧 ID。
+ await dbWrite('setSetting', [
+ `aistudio_pending_project:${String(acc.email || '').trim().toLowerCase()}`,
+ '',
+ 'AI Studio 等待同步的 Google Cloud project ID(人工创建 Key 成功后清除)',
+ ]);
+ await dbWrite('addAistudioFlowLog', [
+ acc.email,
+ 'success',
+ '',
+ '人工创建的 AI Studio API Key 已自动保存',
+ ]);
+ await updateMessage(acc.email, '✅ 已自动识别并保存人工创建的 AI Studio Key');
+ return;
+ }
+
+ // 人工已确认删除线上旧 Key,但监听结束前没有读到新 Key:
+ // 数据库旧值已经失效,必须同步清除,不能继续显示“已保存”。
+ if (watched.deletedExisting) {
+ await dbWrite('deleteAistudioApiKey', [acc.email]);
+ await dbWrite('addAistudioFlowLog', [
+ acc.email,
+ 'failed',
+ '人工创建 AI Studio Key',
+ '线上旧 Key 已删除,但未检测到新 Key;已清除数据库失效记录',
+ ]);
+ await updateMessage(acc.email, '⚠️ 旧 Key 已删除但未检测到新 Key,数据库旧值已清除');
+ return;
+ }
+
+ if (!watched.stopped) {
+ await updateMessage(acc.email, watched.browserClosed
+ ? '比特浏览器已关闭,本次未检测到新 AI Studio Key'
+ : `⚠️ ${watched.msg || '未检测到人工创建的 AI Studio Key'}`);
+ }
+ } catch (error) {
+ const message = error && error.message ? error.message : String(error || '未知错误');
+ accountLog(`[AIStudio人工监听] 监听异常:${message}`);
+ await updateMessage(acc.email, `⚠️ 浏览器已打开,但 AI Studio Key 自动监听失败:${message}`);
+ }
+ }, threadCount);
+ break;
+ }
+
case 'delete': {
log('[任务] 删除浏览器...');
const browserIds = [...new Set(accounts.filter(a => a.browser_id).map(a => a.browser_id))];
diff --git a/tests/aistudio_additional_consent.test.js b/tests/aistudio_additional_consent.test.js
new file mode 100644
index 0000000..c9cfb1e
--- /dev/null
+++ b/tests/aistudio_additional_consent.test.js
@@ -0,0 +1,26 @@
+'use strict';
+
+const assert = require('assert');
+const { _private } = require('../src/services/aistudio_api_key_service');
+
+assert.strictEqual(
+ _private.isAdditionalConsentText('Go Further. Build Smarter: Unlock more possibilities with Google AI plans now in AI Studio.'),
+ true,
+ '新版无复选框附加条款必须被识别',
+);
+assert.strictEqual(
+ _private.INITIAL_CONSENT_WAIT_MS,
+ 2500,
+ '已同意账号只保留 2.5 秒首次弹窗观察窗口,延迟条款由创建表单阶段二次检测',
+);
+assert.strictEqual(
+ _private.isAdditionalConsentText('The Gemini API Additional Terms of Service and the Google Privacy Policy apply.'),
+ true,
+);
+assert.strictEqual(
+ _private.isAdditionalConsentText('Create API key in existing project'),
+ false,
+ '创建 Key 对话框不能被误当成条款弹窗',
+);
+
+console.log('aistudio_additional_consent.test.js: PASS');
diff --git a/tests/aistudio_cloud_project_fallback.test.js b/tests/aistudio_cloud_project_fallback.test.js
new file mode 100644
index 0000000..a80bce0
--- /dev/null
+++ b/tests/aistudio_cloud_project_fallback.test.js
@@ -0,0 +1,710 @@
+'use strict';
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { chromium } = require('playwright');
+const service = require('../src/services/aistudio_api_key_service');
+
+const {
+ apiKeysUrlForProject,
+ extractCloudProjectId,
+ needsCloudConsoleProjectFallback,
+} = service._private;
+
+assert.strictEqual(
+ service.CLOUD_PROJECT_CREATE_URL,
+ 'https://console.cloud.google.com/projectcreate',
+ 'Cloud Console 兜底必须使用项目创建页(不能包含中文逗号)',
+);
+
+const projectId = 'stalwart-yen-509406-k1';
+const projectName = 'My Project 25180';
+assert.strictEqual(
+ extractCloudProjectId(`https://console.cloud.google.com/home/dashboard?project=${projectId}`, ''),
+ projectId,
+ '应从 Cloud Console 创建成功后的 dashboard URL 读取项目 ID',
+);
+assert.strictEqual(
+ extractCloudProjectId('', `Project name\nMy Project 25180\nProject ID: ${projectId}. It cannot be changed later.`),
+ projectId,
+ '应从截图对应的项目创建页文案读取项目 ID',
+);
+assert.strictEqual(
+ apiKeysUrlForProject(projectId),
+ `https://aistudio.google.com/api-keys?project=${projectId}`,
+ '返回 AI Studio 时必须携带精确项目 ID',
+);
+
+assert.strictEqual(
+ needsCloudConsoleProjectFallback('新建项目请求被拒绝(CreateCloudProject HTTP 403)'),
+ true,
+ 'CreateCloudProject 403 应触发 Cloud Console 兜底',
+);
+assert.strictEqual(
+ needsCloudConsoleProjectFallback('Unable to create API key:AI Studio 无法代建 Google Cloud 项目'),
+ true,
+ '截图中的 Unable to create API key 引导弹窗应触发兜底',
+);
+assert.strictEqual(
+ needsCloudConsoleProjectFallback('GenerateCloudApiKey HTTP 403 / The request is suspicious'),
+ false,
+ '仅生成 Key 被风控时不能误建新的 Cloud 项目',
+);
+
+const sourcePath = path.join(__dirname, '..', 'src', 'services', 'aistudio_api_key_service.js');
+const source = fs.readFileSync(sourcePath, 'utf8');
+const createKeyStart = source.indexOf('async function createKey(page, log, opts)');
+const createKeyEnd = source.indexOf('\nasync function createKeyOnce', createKeyStart);
+const createKeySource = source.slice(createKeyStart, createKeyEnd);
+assert(createKeySource.includes('createProjectViaCloudConsole(page, log'),
+ '创建项目被拒绝后必须实际调用 Cloud Console 兜底');
+assert(createKeySource.includes('runtimeOpts.preferredProjectId = fallback.projectId'),
+ 'Cloud Console 返回的项目 ID 必须传入后续精确导入流程');
+assert(createKeySource.includes('runtimeOpts.preferredProjectName = fallback.projectName'),
+ 'Cloud Console 返回的项目名称必须传入导入搜索流程');
+assert(createKeySource.includes('dismissCloudProjectFallbackDialogs(page, log)'),
+ 'Cloud Console 返回后必须清理 Unable 提示和残留的新建项目弹窗');
+assert(createKeySource.includes('r.preferredProjectMissing'),
+ '旧 project ID 精确搜索无结果时必须进入失效项目恢复分支');
+assert(createKeySource.includes("runtimeOpts.preferredProjectId = ''"),
+ '确认旧 project ID 不存在后必须解除项目限制,允许创建新项目');
+
+const cloudFallbackStart = source.indexOf('async function createProjectViaCloudConsole');
+const cloudFallbackEnd = source.indexOf('\n/** 在打开的浮层菜单里点“删除”项', cloudFallbackStart);
+const cloudFallbackSource = source.slice(cloudFallbackStart, cloudFallbackEnd);
+assert(cloudFallbackSource.includes('if (projectId) {'),
+ 'Cloud Console 已提交且有项目 ID 时,页面超时不能误判为创建失败');
+assert(cloudFallbackSource.includes('gotoApiKeys(page, 30000, projectId)'),
+ 'Cloud Console 提交后超时必须回 AI Studio 检查 Import projects');
+assert(cloudFallbackSource.includes('submittedPending: true'),
+ '已提交项目的超时应标记为待导入,不能再新建项目');
+
+const selectStart = source.indexOf('async function selectProjectInNewUi');
+const selectEnd = source.indexOf('\nasync function classifyProjectOptions', selectStart);
+const selectSource = source.slice(selectStart, selectEnd);
+assert(selectSource.indexOf('if (classified.importIndex >= 0)') >= 0,
+ '没有真实项目时必须先检查 Import projects,包括首次运行账号');
+assert(selectSource.indexOf('if (classified.importIndex >= 0)')
+ < selectSource.indexOf('if (classified.createIndex >= 0)'),
+ '项目流程必须先 Import、确认无候选后才 Create project');
+assert(selectSource.includes('Import projects 确认没有可用项目'),
+ '普通账号必须在导入列表明确为空后才新建项目');
+assert(selectSource.includes("classified = await classifyProjectOptions(page, '')"),
+ '关闭空导入抽屉后必须重开并重新读取项目下拉,不能使用失效 DOM 标记');
+assert(selectSource.includes('waitCreateReadyAfterProject(page, log, 8000'),
+ 'Imported 后应先短时复用当前创建框,避免固定整页重载');
+assert(selectSource.includes('gotoApiKeys(page, 30000, importedProjectId)'),
+ '短时复用未刷新项目时必须精确刷新一次,不能在旧项目列表里连续空等');
+assert(selectSource.includes('不要求网址发生变化'),
+ '当前位置创建项目成功不能依赖 URL 跳转,应按弹窗关闭和项目选中判断');
+
+const importDrawerStart = source.indexOf('async function importCloudProjectFromDrawer');
+const importDrawerEnd = source.indexOf('\n/** 轮询等待创建密钥按钮变为可点', importDrawerStart);
+const importDrawerSource = source.slice(importDrawerStart, importDrawerEnd);
+assert(importDrawerSource.includes('postSubmitDeadline = Date.now() + 12000'),
+ '点击 Import 后必须开启独立完成确认窗口,不能复用已经耗尽的初始超时');
+assert(importDrawerSource.includes('importSuccessToast'),
+ '导入完成确认必须识别 Projects imported successfully 提示');
+
+(async () => {
+ const serviceSource = fs.readFileSync(
+ path.join(__dirname, '..', 'src', 'services', 'aistudio_api_key_service.js'),
+ 'utf8',
+ );
+ const cloudFallbackStart = serviceSource.indexOf('async function createProjectViaCloudConsole');
+ const cloudFallbackEnd = serviceSource.indexOf('\n/**', cloudFallbackStart + 20);
+ const cloudFallbackSource = serviceSource.slice(cloudFallbackStart, cloudFallbackEnd);
+ assert(
+ cloudFallbackSource.indexOf('dismissBrowserChromePopup(page, log)')
+ < cloudFallbackSource.indexOf('page.goto(CLOUD_PROJECT_CREATE_URL'),
+ '进入 Cloud Console 前必须关闭浏览器原生保存密码弹窗,避免遮挡首次条款',
+ );
+ assert(
+ cloudFallbackSource.includes('projectId ? Math.min(timeoutMs, 15000) : timeoutMs'),
+ '表单已取得 project ID 后最多前台等待15秒,随后应释放窗口进入非阻塞同步流程',
+ );
+ assert(
+ cloudFallbackSource.includes('if (form.ready && !termsDialogVisible)')
+ && cloudFallbackSource.indexOf('if (form.ready && !termsDialogVisible)')
+ < cloudFallbackSource.indexOf('acceptCloudConsoleTerms(page, log, 35000)'),
+ '项目创建表单已经就绪且没有条款弹窗遮挡时,必须跳过条款流程并直接点击 Create',
+ );
+ const termsStart = serviceSource.indexOf('async function acceptCloudConsoleTerms');
+ const termsEnd = serviceSource.indexOf('\n/** 在 Cloud Console 的项目创建表单', termsStart);
+ const termsSource = serviceSource.slice(termsStart, termsEnd);
+ assert(termsSource.includes('/console\\.cloud\\.google\\.com\\/terms'),
+ '误入 Cloud 条款说明页时必须返回 projectcreate,不能停留等待');
+ const browser = await chromium.launch({ headless: true });
+ try {
+ const page = await browser.newPage({ viewport: { width: 1200, height: 800 } });
+ await page.setContent(`
+
+
+
+
+
Welcome Test User!
+
+
+
+
+
+ `);
+
+ assert.strictEqual(
+ await service._private.hasVisibleCloudConsoleTermsDialog(page),
+ true,
+ '项目表单已渲染但条款弹窗仍遮挡时不能直接点击底层 Create',
+ );
+ const terms = await service._private.acceptCloudConsoleTerms(page, () => {}, 3000);
+ assert.strictEqual(terms.ok, true, '首次 Google Cloud 条款应自动勾选并确认');
+ assert.strictEqual(terms.handled, true, '存在首次条款弹窗时应报告已处理');
+ assert.strictEqual(await page.locator('[role="dialog"]').count(), 0, '确认后条款弹窗应关闭');
+ assert.strictEqual(
+ await service._private.hasVisibleCloudConsoleTermsDialog(page),
+ false,
+ '条款弹窗关闭后应允许直接点击项目 Create',
+ );
+
+ // 2026-09-22 实测新版 Cloud Console:720px 高窗口里的条款容器不一定带
+ // role=dialog/cfc-dialog,复选框也可能是自定义 MDC 根节点而非可见 input。
+ // 必须能从 Agree and continue 按钮反向定位弹窗并滚动点击,不能误点底层 Create。
+ await page.setViewportSize({ width: 1255, height: 720 });
+ await page.setContent(`
+
+
+
+
+
Google Cloud
+
Welcome Vahid Tam!
+
Country
+
+
Terms of Service
+
+
I agree to the Google Cloud Platform Terms of Service
+
+
+
+ `);
+ const customTerms = await service._private.acceptCloudConsoleTerms(page, () => {}, 3000);
+ assert.strictEqual(customTerms.ok, true,
+ '小屏幕新版自定义 Cloud 条款弹窗应自动勾选并确认');
+ assert.strictEqual(customTerms.handled, true,
+ '无 role=dialog 的新版条款弹窗也必须报告已处理');
+ assert.strictEqual(await page.locator('.new-cloud-modal-container').count(), 0,
+ '新版条款确认后弹窗必须消失,才允许操作底层项目表单');
+
+ // 真实页面还存在复选框封装在不可查询组件中的版本:DOM 只能看到条款文字,
+ // 视觉复选框位于文字左侧。此时应按文字 Range 坐标反推并点击,不能空等20秒。
+ await page.setContent(`
+
+
+
+
Welcome Shadow User!
+
Terms of Service
+
I agree to the Google Cloud Platform Terms of Service
+
+
+
+ `);
+ const shadowTerms = await service._private.acceptCloudConsoleTerms(page, () => {}, 3000);
+ assert.strictEqual(shadowTerms.ok, true,
+ '复选框封装节点不可查询时应按条款文字左侧视觉坐标完成确认');
+ assert.strictEqual(await page.locator('.shadow-modal-container').count(), 0,
+ '视觉坐标兜底确认后条款弹窗必须消失');
+
+ // yinlulu970612 真实回归:首次确认点击已发出,但 Cloud Console 弹层没有
+ // 及时关闭。自动流程必须重新定位后再点一次,不能固定等待后直接判失败。
+ let delayedTermsClicks = 0;
+ await page.exposeFunction('recordDelayedTermsClick', () => { delayedTermsClicks++; });
+ await page.setContent(`
+
+
+
Welcome Test User!
Terms of Service
+
+
+
+
+ `);
+ // 极短入口预算模拟真实慢页面:第一次点击发生时原 deadline 已接近耗尽。
+ // 点击后必须获得独立确认窗口,第二次点击仍应能够执行。
+ const retriedTerms = await service._private.acceptCloudConsoleTerms(page, () => {}, 1000);
+ assert.strictEqual(retriedTerms.ok, true,
+ '首次确认后弹窗未关闭时应重新定位并确认一次');
+ assert.strictEqual(delayedTermsClicks, 2,
+ '条款弹窗卡住时应只补一次确认点击');
+ assert.strictEqual(await page.locator('#delayed-terms-dialog').count(), 0,
+ '第二次确认成功后才能继续操作项目表单');
+
+ await page.setContent(`
+
+
+
+ `);
+
+ const form = await service._private.markCloudProjectCreateForm(page);
+ assert.strictEqual(form.ready, true, '应识别截图中的 Project name + Create 表单');
+ assert.strictEqual(form.value, 'My Project 25180', '网页已有默认项目名时应保留原值');
+ assert.strictEqual(form.projectId, projectId, '提交前应保存页面生成的项目 ID');
+ assert.strictEqual(await page.locator('[data-cloud-project-create="1"]').count(), 1,
+ '应只标记项目表单内的 Create 主按钮');
+
+ // 文档截图明确要求:AI Studio 内创建项目时若默认名不合规/不是英文,改成英文名。
+ let submittedProjectName = '';
+ await page.exposeFunction('recordSubmittedProjectName', value => { submittedProjectName = value; });
+ await page.setContent(`
+
+
Create a new project
+
+
+
+
+
+ `);
+ page.__osDisabled = true;
+ const renamedProject = await service._private.createProject(page, () => {}, 'Gemini Project', 3000);
+ assert.strictEqual(renamedProject.ok, true, '不合规项目名修正后应能提交创建');
+ assert.strictEqual(submittedProjectName, 'Gemini Project', '非英文/不合规默认项目名必须替换为英文名');
+
+ // Cloud Console 返回时按人工文档顺序关闭 Unable 提示,再取消底下残留项目弹窗。
+ await page.setContent(`
+
+
Create a new project
+
+
+
Unable to create API key
Please create a project in the Google Cloud Console.
+
+
+ `);
+ const cleanedFallbackDialogs = await service._private.dismissCloudProjectFallbackDialogs(page, () => {});
+ assert.strictEqual(cleanedFallbackDialogs, true, 'Cloud Console 返回后应处理两层残留弹窗');
+ assert.strictEqual(await page.locator('[role="dialog"]').count(), 0, 'Close 和 Cancel 后不应残留项目失败弹窗');
+
+ let importClicks = 0;
+ let searchInputs = 0;
+ await page.exposeFunction('recordImportClick', () => { importClicks++; });
+ await page.exposeFunction('recordSearchInput', () => { searchInputs++; });
+ await page.setContent(`
+
+
+ `);
+ const imported = await service._private.importCloudProjectFromDrawer(page, () => {}, projectId, 5000);
+ assert.strictEqual(imported.ok, true, '项目已预选时应点击 Import 主按钮完成导入');
+ assert.strictEqual(imported.projectId, projectId, '导入结果必须携带项目 ID,供同步等待后精确重试');
+ assert.strictEqual(importClicks, 1, '勾选项目不等于已导入,必须实际点击一次 Import');
+ assert.strictEqual(searchInputs, 0, '建议项目已显示时不应再搜索项目 ID');
+ assert.strictEqual(page.url(), 'about:blank',
+ '导入完成后应复用当前创建框,不能为了刷新项目整页重载 API Keys');
+
+ // williamdoborogodo 真实回归:Import 在初始超时即将耗尽时才被点击,服务端约1秒后
+ // 才关闭抽屉。旧代码点击后只等1秒便退出循环并误报失败;现在必须使用独立确认窗口。
+ let lateImportClicks = 0;
+ await page.exposeFunction('recordLateImportClick', () => { lateImportClicks++; });
+ await page.setContent(`
+
+
+ `);
+ const lateImportStartedAt = Date.now();
+ const lateImported = await service._private.importCloudProjectFromDrawer(page, () => {}, '', 500);
+ assert.strictEqual(lateImported.ok, true,
+ '初始超时耗尽后导入才完成时,仍应确认成功而不是误报未完成');
+ assert.strictEqual(lateImportClicks, 1, '延迟完成场景只能点击一次 Import');
+ assert(Date.now() - lateImportStartedAt >= 1500,
+ '测试必须实际跨过初始超时并等待服务端完成,防止用例假通过');
+
+ let projectChoiceClicks = 0;
+ let selectAllClicks = 0;
+ let uncheckedImportClicks = 0;
+ await page.exposeFunction('recordProjectChoiceClick', () => { projectChoiceClicks++; });
+ await page.exposeFunction('recordSelectAllClick', () => { selectAllClicks++; });
+ await page.exposeFunction('recordUncheckedImportClick', () => { uncheckedImportClicks++; });
+ await page.setContent(`
+
+
+ `);
+ const uncheckedImported = await service._private.importCloudProjectFromDrawer(page, () => {}, projectId, 5000);
+ assert.strictEqual(uncheckedImported.ok, true,
+ '项目已出现但未勾选时,应先勾选具体项目再点击 Import');
+ assert.strictEqual(uncheckedImported.projectId, projectId,
+ '从未勾选项目行中应提取项目 ID,防止延迟重试时重复创建项目');
+ assert.strictEqual(projectChoiceClicks, 1, '必须点击具体项目复选框');
+ assert.strictEqual(selectAllClicks, 0, '不能把 Select all 误当成项目复选框');
+ assert.strictEqual(uncheckedImportClicks, 1, '项目选中后必须点击 Import 主按钮');
+
+ let delayedChoiceClicks = 0;
+ let delayedImportClicks = 0;
+ await page.exposeFunction('recordDelayedChoiceClick', () => { delayedChoiceClicks++; });
+ await page.exposeFunction('recordDelayedImportClick', () => { delayedImportClicks++; });
+ await page.setContent(`
+
+
+ `);
+ const delayedImported = await service._private.importCloudProjectFromDrawer(page, () => {}, projectId, 5000);
+ assert.strictEqual(delayedImported.ok, true,
+ '指定 Cloud 项目同步时,抽屉短暂显示 No projects 后候选晚到仍应继续勾选并导入');
+ assert.strictEqual(delayedChoiceClicks, 1, '晚到候选必须点击具体项目复选框');
+ assert.strictEqual(delayedImportClicks, 1, '晚到候选选中后必须点击 Import 主按钮');
+
+ let exactSearchInputs = 0;
+ let searchedChoiceClicks = 0;
+ let searchedImportClicks = 0;
+ await page.exposeFunction('recordExactSearchInput', () => { exactSearchInputs++; });
+ await page.exposeFunction('recordSearchedChoiceClick', () => { searchedChoiceClicks++; });
+ await page.exposeFunction('recordSearchedImportClick', () => { searchedImportClicks++; });
+ await page.setContent(`
+
+
+ `);
+ const searchedImported = await service._private.importCloudProjectFromDrawer(
+ page,
+ () => {},
+ projectId,
+ 10000,
+ projectName,
+ );
+ assert.strictEqual(searchedImported.ok, true,
+ '新建项目不在推荐列表时,应按项目名称搜索后继续导入,不能重复创建项目');
+ assert(exactSearchInputs >= 1, '推荐列表持续为空时必须输入 Cloud Console 项目名称搜索');
+ assert.strictEqual(searchedChoiceClicks, 1, '搜索结果出现后必须勾选具体项目');
+ assert.strictEqual(searchedImportClicks, 1, '搜索结果选中后必须点击 Import 主按钮');
+
+ let missingSearchInputs = 0;
+ let missingDrawerClosed = 0;
+ await page.exposeFunction('recordMissingSearchInput', () => { missingSearchInputs++; });
+ await page.exposeFunction('recordMissingDrawerClose', () => { missingDrawerClosed++; });
+ await page.setContent(`
+
+
+ `);
+ const missingProject = await service._private.importCloudProjectFromDrawer(
+ page,
+ () => {},
+ projectId,
+ 5000,
+ projectName,
+ );
+ assert.strictEqual(missingProject.ok, false, '精确搜索仍无项目时不应误报导入成功');
+ assert.strictEqual(missingProject.noProjects, true, '精确搜索无结果应转入无项目恢复流程');
+ assert.strictEqual(missingProject.exactSearchMiss, true,
+ '精确搜索无结果必须显式标记旧 project ID 已失效');
+ assert.strictEqual(missingProject.projectId, projectId, '失效结果应保留被核验的旧 project ID');
+ assert.strictEqual(missingProject.projectName, projectName, '失效结果应保留被搜索的项目名称');
+ assert(missingSearchInputs >= 1, '必须实际执行 project ID 精确搜索后才能判定旧项目失效');
+ assert.strictEqual(missingDrawerClosed, 1, '创建新项目前必须关闭 Import projects 抽屉');
+
+ let drawerClosed = 0;
+ await page.exposeFunction('recordDrawerClose', () => { drawerClosed++; });
+ await page.setContent(`
+
+
+ `);
+ const noProject = await service._private.importCloudProjectFromDrawer(page, () => {}, '', 3000);
+ assert.strictEqual(noProject.ok, false, '没有可导入项目时不应误报导入成功');
+ assert.strictEqual(noProject.noProjects, true, '没有候选项目时应允许上层回退到 Create project');
+ assert.strictEqual(drawerClosed, 1, '回退创建项目前应先关闭 Import projects 抽屉');
+
+ // 状态机级回归:无真实选中项目时,必须先 Import 已有候选,不能误点 Create。
+ let importOptionClicks = 0;
+ let createOptionClicks = 0;
+ await page.exposeFunction('recordImportOptionClick', () => { importOptionClicks++; });
+ await page.exposeFunction('recordCreateOptionClick', () => { createOptionClicks++; });
+ await page.setContent(`
+
+ Create a new key
+
+
+ No Cloud Projects Available
+
+
+
+
+
+ `);
+ page.__osDisabled = true;
+ const importedBeforeCreate = await service._private.selectProjectInNewUi(page, () => {}, 'Gemini Project');
+ assert.strictEqual(importedBeforeCreate.ok, true, '存在可导入项目时应完成导入并选中');
+ assert.strictEqual(importOptionClicks, 1, '无真实项目时应先进入一次 Import projects');
+ assert.strictEqual(createOptionClicks, 0, '已有可导入项目时严禁重复创建项目');
+
+ // Import 明确为空后,要关闭抽屉、重开下拉并创建;验证不能复用已经销毁的 option。
+ let emptyImportClicks = 0;
+ let fallbackCreateClicks = 0;
+ await page.exposeFunction('recordEmptyImportClick', () => { emptyImportClicks++; });
+ await page.exposeFunction('recordFallbackCreateClick', () => { fallbackCreateClicks++; });
+ await page.setContent(`
+
+ Create a new key
+
+
+ No Cloud Projects Available
+
+
+
+
+
+ `);
+ page.__osDisabled = true;
+ const createdAfterEmptyImport = await service._private.selectProjectInNewUi(page, () => {}, 'Gemini Project');
+ assert.strictEqual(createdAfterEmptyImport.ok, true, 'Import 为空后应返回下拉创建并选中新项目');
+ assert.strictEqual(createdAfterEmptyImport.created, true, '空导入列表分支应标记为已创建项目');
+ assert.strictEqual(emptyImportClicks, 1, '创建项目前必须先确认一次 Import projects 为空');
+ assert.strictEqual(fallbackCreateClicks, 1, '空抽屉关闭后必须重开下拉并点击 Create project');
+
+ console.log('aistudio_cloud_project_fallback.test.js: PASS');
+ } finally {
+ await browser.close();
+ }
+})().catch(error => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/tests/aistudio_consent_sequence.test.js b/tests/aistudio_consent_sequence.test.js
new file mode 100644
index 0000000..9f3a72d
--- /dev/null
+++ b/tests/aistudio_consent_sequence.test.js
@@ -0,0 +1,63 @@
+'use strict';
+
+const assert = require('assert');
+const { chromium } = require('playwright');
+const { _private } = require('../src/services/aistudio_api_key_service');
+
+(async () => {
+ const browser = await chromium.launch({ headless: true });
+ try {
+ const page = await browser.newPage({ viewport: { width: 1200, height: 800 } });
+ await page.setContent(`
+
+
+ `);
+ const delayedStartedAt = Date.now();
+ const delayed = await _private.waitForCreationDialogOrConsent(page, 5000);
+ assert.strictEqual(delayed.consent, true,
+ '创建框点击期间延迟出现的 Additional Terms 必须被立即识别');
+ assert(Date.now() - delayedStartedAt < 2000,
+ '检测到条款后不能继续空等完整的创建框超时');
+
+ await page.setContent(`
+
+
Welcome to AI Studio
+
+
+
+
+ `);
+
+ const handled = await _private.dismissWelcomeConsent(page, () => {});
+ assert.strictEqual(handled, true);
+ assert.strictEqual(await page.locator('[role="dialog"]').count(), 0,
+ '第一层条款关闭后的延迟 Additional Terms 也必须被处理');
+ console.log('aistudio_consent_sequence.test.js: PASS');
+ } finally {
+ await browser.close();
+ }
+})().catch(error => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/tests/aistudio_deleted_existing_propagation.test.js b/tests/aistudio_deleted_existing_propagation.test.js
new file mode 100644
index 0000000..be0f12a
--- /dev/null
+++ b/tests/aistudio_deleted_existing_propagation.test.js
@@ -0,0 +1,47 @@
+'use strict';
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const servicePath = path.join(__dirname, '..', 'src', 'services', 'aistudio_api_key_service.js');
+const source = fs.readFileSync(servicePath, 'utf8');
+const start = source.indexOf('async function processAistudioApiKey');
+const end = source.indexOf('\nmodule.exports =', start);
+
+assert(start >= 0 && end > start, '必须存在 processAistudioApiKey 实现');
+
+const implementation = source.slice(start, end);
+const tryIndex = implementation.indexOf('\n try {');
+const stateIndex = implementation.indexOf('let deletedExisting = false;');
+
+assert(stateIndex >= 0, '必须跟踪线上旧 key 是否已被删除');
+assert(tryIndex >= 0 && stateIndex < tryIndex,
+ 'deletedExisting 必须声明在 try 外,异常处理才能访问删除状态');
+assert.match(
+ implementation,
+ /catch\s*\(error\)[\s\S]*?return\s*\{\s*ok:\s*false,\s*data:\s*null,\s*msg,\s*deletedExisting,\s*clearedPreferredProjectId\s*\};/,
+ '异常返回必须携带 deletedExisting 和失效项目状态,供上层同步清理数据库记录',
+);
+const lateStart = implementation.indexOf('for (let lateRecovery = 1;');
+const lateEnd = implementation.indexOf('\n if (!created.ok)', lateStart + 10);
+const lateRecovery = implementation.slice(lateStart, lateEnd);
+const lateCountBranch = lateRecovery.indexOf('if (lateCount > 0)');
+const unconditionalRecreate = lateRecovery.lastIndexOf('created = await createKey(page, log, {');
+assert(lateStart >= 0 && lateEnd > lateStart, '必须存在列表晚到恢复分支');
+assert(lateRecovery.includes('lateRecovery <= 2'),
+ '列表 0→1 连续抖动时应允许最多两轮恢复,同时防止无限循环');
+assert(lateRecovery.includes('waitForLateExistingRows(page, 5000)'),
+ '刷新后必须给列表数据稳定时间,不能立即把 0 当成无旧 Key');
+assert(lateCountBranch >= 0 && unconditionalRecreate > lateCountBranch,
+ '列表刷新后即使旧行已经自然消失,也必须重新创建 Key,不能保留 lateExisting 失败结果');
+assert(lateRecovery.includes('syncExistingAistudioApiKey(page, account, log'),
+ '创建流程中才出现的 Key 必须直接读取保存,不能再次删除重建');
+assert(!lateRecovery.includes('deleteAllKeys('),
+ 'lateExisting 恢复分支禁止删除 Key,避免把本轮刚创建成功的 Key 当作旧 Key 删除');
+assert(lateRecovery.includes('recoveredLateKey: true'),
+ '安全读取晚到 Key 后必须直接作为成功结果返回');
+assert(lateRecovery.includes('allowMissingPreferredProjectRecovery,'),
+ '列表晚到恢复后必须保留新旧项目判定参数,防止新项目被误判失效');
+
+console.log('aistudio_deleted_existing_propagation.test.js: PASS');
diff --git a/tests/aistudio_dialog_state_regression.test.js b/tests/aistudio_dialog_state_regression.test.js
new file mode 100644
index 0000000..5fdfc6a
--- /dev/null
+++ b/tests/aistudio_dialog_state_regression.test.js
@@ -0,0 +1,120 @@
+'use strict';
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { chromium } = require('playwright');
+const { _private } = require('../src/services/aistudio_api_key_service');
+
+(async () => {
+ const browser = await chromium.launch({ headless: true });
+ try {
+ const page = await browser.newPage({ viewport: { width: 1200, height: 800 } });
+ page.__osDisabled = true;
+
+ await page.setContent(`
+
+ Create API key
+
+ Gemini Project
+
+
+
+
Create a new project
+
+
+
+
Loading
+
+
+ `);
+
+ assert.strictEqual(await _private.hasBlockingProjectCreationDialog(page), true,
+ '新建项目弹窗仍在时必须阻止底层 Create key');
+ assert.strictEqual(await _private.isCreateKeyDisabled(page), true,
+ '项目弹窗转圈期间 Create key 应视为不可点');
+ assert.strictEqual(await _private.clickCreateKeyButton(page, () => {}), false,
+ '不能把新建项目弹窗的关闭按钮当成 Create key');
+ assert.strictEqual(await page.evaluate(() => window.createKeyClicks), 0);
+
+ const stalled = await _private.createProject(page, () => {}, 'Gemini Project', 600);
+ assert.strictEqual(stalled.ok, false, '新建项目弹窗一直转圈不能误报成功');
+ assert(/持续加载超时/.test(stalled.msg), '转圈超时应触发 Cloud Console 兜底');
+
+ await page.locator('#project-dialog').evaluate(element => element.remove());
+ assert.strictEqual(await _private.clickCreateKeyButton(page, () => {}), true,
+ '项目弹窗关闭后应能精确点击 Create key');
+ assert.strictEqual(await page.evaluate(() => window.createKeyClicks), 1);
+
+ await page.setContent(`
+
+ Create API key
+
+ Gemini Project
+
+
+
+ `);
+ assert.strictEqual(await _private.countRowMenuButtons(page), 1,
+ '无 more_vert/aria-haspopup 的新版 Key 行也必须被识别');
+ assert.strictEqual(await _private.waitCreationFormReady(page, 1000), 'late_existing',
+ '列表晚到时应立即切回先删除旧 Key 的路径');
+
+ await page.setContent('');
+ await page.evaluate(() => {
+ setTimeout(() => {
+ document.getElementById('late-table').innerHTML = `
+ `;
+ }, 500);
+ });
+ assert.strictEqual(await _private.waitForLateExistingRows(page, 2000), 1,
+ '刷新瞬间读到 0 后应继续轮询,并识别延迟出现的旧 Key');
+
+ await page.setContent(`
+
+
+ Create API key
+
+ Gemini Project
+
+
+ `);
+ assert.strictEqual(await _private.countRowMenuButtons(page), 0,
+ '工具栏 more_vert 不能被误计为已有 Key 行');
+ assert.strictEqual(await _private.waitCreationFormReady(page, 1000), 'ready',
+ '无 Key 时创建表单应正常继续,不能误报 late_existing');
+
+ await page.setContent(`
+
+ Create API key
+
+ Gemini Project
+
+
+
Go Further. Build Smarter. Gemini API Additional Terms of Service and Google Privacy Policy.
+
+
+ `);
+ assert.strictEqual(await _private.waitCreationFormReady(page, 1000), 'consent_required',
+ '第二层附加条款必须优先处理,不能误报项目选择控件缺失');
+
+ const source = fs.readFileSync(
+ path.join(__dirname, '..', 'src', 'services', 'aistudio_api_key_service.js'),
+ 'utf8',
+ );
+ const onceStart = source.indexOf('async function createKeyOnce');
+ const onceSource = source.slice(onceStart, source.indexOf('\n/**', onceStart + 30));
+ assert(onceSource.includes('postProjectFormState = await waitCreationFormReady(page, 15000)'),
+ '导入/新建项目后必须再等创建表单 spinner 消失,不能直接点 Create key');
+
+ console.log('aistudio_dialog_state_regression.test.js: PASS');
+ } finally {
+ await browser.close();
+ }
+})().catch(error => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/tests/aistudio_fast_rejection.test.js b/tests/aistudio_fast_rejection.test.js
new file mode 100644
index 0000000..a86c939
--- /dev/null
+++ b/tests/aistudio_fast_rejection.test.js
@@ -0,0 +1,69 @@
+'use strict';
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { _private } = require('../src/services/aistudio_api_key_service');
+
+(async () => {
+ const accessBlock = _private.classifyAistudioAccessBlock(
+ 'https://ai.google.dev/gemini-api/docs/available-regions',
+ 'Available regions for Google AI Studio and Gemini API\nRegional restrictions\nAge requirements\nAccount verification',
+ );
+ assert(accessBlock, 'Available regions 跳转必须被识别为 AI Studio 访问受限');
+ assert.strictEqual(accessBlock.code, 'AI_STUDIO_ACCESS_RESTRICTED');
+ assert.strictEqual(
+ _private.classifyAistudioAccessBlock('https://aistudio.google.com/api-keys', 'API keys'),
+ null,
+ '正常 API Keys 页不能被误判为访问受限',
+ );
+ const source = fs.readFileSync(
+ path.join(__dirname, '..', 'src', 'services', 'aistudio_api_key_service.js'),
+ 'utf8',
+ );
+ const readyStart = source.indexOf('async function waitForApiKeysReady');
+ const readyEnd = source.indexOf('\n/** 定位工具栏', readyStart);
+ const readySource = source.slice(readyStart, readyEnd);
+ assert(
+ readySource.indexOf('classifyAistudioAccessBlock(')
+ < readySource.indexOf('findCreateApiKeyButton(page)'),
+ '必须先识别 Available regions 受限页,再匹配通用按钮,避免把 Dismiss 当成创建按钮',
+ );
+ const processStart = source.indexOf('async function processAistudioApiKey');
+ const processEnd = source.indexOf('\nmodule.exports', processStart);
+ const processSource = source.slice(processStart, processEnd);
+ assert(
+ processSource.indexOf('const postConsentSnapshot = await pageSnapshot(page)')
+ > processSource.indexOf('const consentHandled = await dismissWelcomeConsent(page, log)'),
+ '首次条款提交后必须再次识别 Available regions 重定向',
+ );
+
+ let evaluateCalls = 0;
+ const page = {
+ evaluate: async () => {
+ evaluateCalls++;
+ return true; // 模拟 suspicious / Failed to generate API key toast 已出现
+ },
+ };
+
+ const startedAt = Date.now();
+ const generated = await _private.waitKeyGenerated(page, 20000);
+ const elapsed = Date.now() - startedAt;
+
+ assert.strictEqual(generated, false);
+ assert.strictEqual(evaluateCalls, 1);
+ assert(elapsed < 200, '明确失败提示出现后不应继续空等 20 秒');
+
+ let rpcEvaluateCalls = 0;
+ const rpcRejectedPage = {
+ __aistudioGenerateKeyResponse: { status: 403, at: Date.now() },
+ evaluate: async () => { rpcEvaluateCalls++; return false; },
+ };
+ assert.strictEqual(await _private.waitKeyGenerated(rpcRejectedPage, 20000), false);
+ assert.strictEqual(rpcEvaluateCalls, 0, '已捕获 GenerateCloudApiKey 403 时应在读取 DOM 前立即失败');
+
+ console.log('aistudio_fast_rejection.test.js: PASS');
+})().catch(error => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/tests/aistudio_manual_key_sync.test.js b/tests/aistudio_manual_key_sync.test.js
new file mode 100644
index 0000000..5d5b15f
--- /dev/null
+++ b/tests/aistudio_manual_key_sync.test.js
@@ -0,0 +1,136 @@
+'use strict';
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { chromium } = require('playwright');
+const { syncExistingAistudioApiKey } = require('../src/services/aistudio_api_key_service');
+const { watchManualAistudioKey } = require('../src/services/aistudio_manual_key_watcher');
+
+const dummyKey = `AIza${'A'.repeat(32)}`;
+const projectId = 'manual-project-25180';
+
+(async () => {
+ const browser = await chromium.launch({ headless: true });
+ try {
+ const page = await browser.newPage({ viewport: { width: 1200, height: 800 } });
+ await page.route('https://aistudio.google.com/api-keys', route => route.fulfill({
+ status: 200,
+ contentType: 'text/html',
+ body: `
+
+
+
+ |
+
+ Manual Gemini Key
+ |
+
+ ${projectId}
+ |
+ |
+
+
+
+ `,
+ }));
+
+ const result = await syncExistingAistudioApiKey(page, { email: 'manual@example.com' }, () => {}, {
+ timeoutMs: 10000,
+ });
+ assert.strictEqual(result.ok, true, '人工创建的线上 Key 应能被只读同步');
+ assert.strictEqual(result.data.apiKey, dummyKey, '应读到详情框中的完整 Key');
+ assert.strictEqual(result.data.keyName, 'Manual Gemini Key', '应保存列表行中的 Key 名称');
+ assert.strictEqual(result.data.projectName, 'My Project 25180', '应保存列表行中的项目名称');
+
+ await page.close();
+ const manualPage = await browser.newPage({ viewport: { width: 1200, height: 800 } });
+ await manualPage.route('https://aistudio.google.com/api-keys', route => route.fulfill({
+ status: 200,
+ contentType: 'text/html',
+ body: 'API Keys
',
+ }));
+ await manualPage.goto('https://aistudio.google.com/api-keys');
+ const watchPromise = watchManualAistudioKey(browser, { email: 'manual@example.com' }, () => {}, {
+ timeoutMs: 10000,
+ pollMs: 100,
+ });
+ await manualPage.waitForTimeout(300);
+ await manualPage.evaluate(({ key }) => {
+ const dialog = document.createElement('div');
+ dialog.setAttribute('role', 'dialog');
+ dialog.style.cssText = 'position:fixed;left:200px;top:100px;width:600px;height:300px;background:white';
+ dialog.innerHTML = `API key details
projects/568908549937
`;
+ document.body.appendChild(dialog);
+ }, { key: dummyKey });
+ const watched = await watchPromise;
+ assert.strictEqual(watched.ok, true, '点击浏览器后应自动识别人工创建成功的 Key 详情');
+ assert.strictEqual(watched.data.apiKey, dummyKey, '自动监听应读取完整 Key');
+
+ const serviceSource = fs.readFileSync(path.join(__dirname, '..', 'src', 'services', 'aistudio_api_key_service.js'), 'utf8');
+ const syncStart = serviceSource.indexOf('async function syncExistingAistudioApiKey');
+ const syncEnd = serviceSource.indexOf('\n/**', syncStart + 20);
+ const syncSource = serviceSource.slice(syncStart, syncEnd);
+ assert(!syncSource.includes('deleteAllKeys('), '人工同步流程不得删除线上 Key');
+ assert(!syncSource.includes('createKey('), '人工同步流程不得新建线上 Key');
+
+ const stepSource = fs.readFileSync(path.join(__dirname, '..', 'src', 'pipeline', 'steps', 'aistudio_sync_key.js'), 'utf8');
+ assert(stepSource.includes("dbWrite('upsertAistudioApiKey'"), '同步成功必须写入 Key 数据库');
+ assert(stepSource.includes("dbWrite('addAistudioFlowLog'"), '同步成功必须追加 AI Studio 流程日志');
+ assert(stepSource.includes("'success'"), '人工同步日志必须标记为成功');
+ assert(stepSource.includes('aistudio_pending_project:'),
+ '人工同步成功后必须清除该邮箱遗留的待同步 project ID');
+
+ const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'index.html'), 'utf8');
+ const app = fs.readFileSync(path.join(__dirname, '..', 'public', 'app.js'), 'utf8');
+ assert(!html.includes('openAistudioKeysModal()'), '顶部总 API Keys 查看入口应移除');
+ assert(html.includes('openAistudioKeyModal(account)'), '账号列表行必须提供单邮箱查看入口');
+ assert(!html.includes('syncAistudioKey(account)'), '页面不应要求用户创建 Key 后再点一次“同步人工 Key”');
+ assert(app.includes("runTask('open_manual_aistudio'"),
+ '点击浏览器必须同时启动人工 AI Studio Key 自动监听');
+ assert(app.includes('aistudio-api-keys?email=${encodeURIComponent(account.email)}'),
+ '查看接口必须按当前邮箱查询,不能读取全部账号');
+
+ const runnerSource = fs.readFileSync(path.join(__dirname, '..', 'src', 'workers', 'task_runner.js'), 'utf8');
+ assert(runnerSource.includes("taskType === 'aistudio_sync_key'"),
+ '人工 Key 同步必须有独立的浏览器恢复策略');
+ assert(runnerSource.includes('aistudioSyncBrowserRetry < 1'),
+ '比特浏览器被人工关闭/重开时应重连一次,但不能无限重试');
+ assert(runnerSource.includes('Target page, context or browser has been closed'),
+ '只应对 page/context/browser 被关闭的瞬时错误执行重连');
+ assert(runnerSource.includes('isClosedBrowserError(lastFailure.msg)'),
+ '登录服务将关闭错误转成普通步骤失败时,也必须触发一次重连');
+ assert(runnerSource.includes("case 'open_manual_aistudio':"),
+ '工作器必须在比特浏览器打开后持续监听人工 Key');
+ assert(runnerSource.includes("dbWrite('upsertAistudioApiKey'"),
+ '自动监听读到 Key 后必须直接写入当前邮箱数据库');
+ assert(runnerSource.includes('人工创建的 AI Studio API Key 已自动保存'),
+ '自动保存后必须把 AI Studio 流程日志标记为成功');
+ const manualOpenStart = runnerSource.indexOf("case 'open_manual_aistudio':");
+ const manualOpenEnd = runnerSource.indexOf("\n case '", manualOpenStart + 20);
+ const manualOpenSource = runnerSource.slice(manualOpenStart, manualOpenEnd);
+ assert(manualOpenSource.includes('aistudio_pending_project:'),
+ '浏览器人工监听保存成功后必须清除该邮箱遗留的待同步 project ID');
+
+ const operationsSource = fs.readFileSync(path.join(__dirname, '..', 'src', 'routes', 'operations.js'), 'utf8');
+ assert(operationsSource.includes("taskType === 'open_manual_aistudio' && !isLocalRequest(req)"),
+ '能读取人工 Key 的浏览器监听任务必须只允许本机启动');
+ assert(operationsSource.includes('Promise.resolve(worker.terminate())'),
+ '自动保存完成后必须断开监听 Worker,但不能调用 browser.close() 关闭用户窗口');
+
+ console.log('aistudio_manual_key_sync.test.js: PASS');
+ } finally {
+ await browser.close();
+ }
+})().catch(error => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/tests/aistudio_mutation_concurrency.test.js b/tests/aistudio_mutation_concurrency.test.js
new file mode 100644
index 0000000..62c8687
--- /dev/null
+++ b/tests/aistudio_mutation_concurrency.test.js
@@ -0,0 +1,33 @@
+'use strict';
+
+const assert = require('assert');
+const { _private } = require('../src/services/aistudio_api_key_service');
+
+(async () => {
+ let active = 0;
+ let maxActive = 0;
+ const logs = [];
+
+ const results = await Promise.all([0, 1, 2, 3, 4].map(index => (
+ _private.withAistudioMutationSlot(async () => {
+ active++;
+ maxActive = Math.max(maxActive, active);
+ await new Promise(resolve => setTimeout(resolve, 8));
+ active--;
+ return index;
+ }, message => logs.push(message))
+ )));
+
+ assert.deepStrictEqual(results, [0, 1, 2, 3, 4]);
+ assert.strictEqual(_private.MAX_AISTUDIO_MUTATIONS, 2);
+ assert.strictEqual(maxActive, 2, 'AI Studio 项目/Key 高风险区最多允许两个账号并行');
+ assert(logs.some(message => message.includes('排队等待')),
+ '超过并发上限时应记录排队日志');
+ assert(logs.some(message => message.includes('排队结束')),
+ '获得执行槽后应记录恢复日志');
+
+ console.log('aistudio_mutation_concurrency.test.js: PASS');
+})().catch(error => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/tests/aistudio_project_selected_gate.test.js b/tests/aistudio_project_selected_gate.test.js
new file mode 100644
index 0000000..18a6028
--- /dev/null
+++ b/tests/aistudio_project_selected_gate.test.js
@@ -0,0 +1,22 @@
+'use strict';
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const servicePath = path.join(__dirname, '..', 'src', 'services', 'aistudio_api_key_service.js');
+const source = fs.readFileSync(servicePath, 'utf8');
+const start = source.indexOf('async function waitCreateReadyAfterProject');
+const end = source.indexOf('\n/**', start + 10);
+
+assert(start >= 0 && end > start, '必须存在新建项目后的就绪等待逻辑');
+const implementation = source.slice(start, end);
+const selectedCheck = implementation.indexOf('await hasRealProjectSelected(page)');
+const disabledCheck = implementation.indexOf('await isCreateKeyDisabled(page)');
+
+assert(selectedCheck >= 0, '新建项目后必须直接验证项目是否已真实选中');
+assert(disabledCheck >= 0, '仍应保留按钮状态作为无法识别项目时的兜底');
+assert(selectedCheck < disabledCheck,
+ '真实项目已选中时必须优先放行,不能被残留 data-disabled 属性提前拦截');
+
+console.log('aistudio_project_selected_gate.test.js: PASS');
diff --git a/tests/aistudio_project_sync_deferral.test.js b/tests/aistudio_project_sync_deferral.test.js
new file mode 100644
index 0000000..ba44de2
--- /dev/null
+++ b/tests/aistudio_project_sync_deferral.test.js
@@ -0,0 +1,97 @@
+'use strict';
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const service = require('../src/services/aistudio_api_key_service');
+const step = require('../src/pipeline/steps/aistudio_api_key');
+
+const projectId = 'delayed-project-509408-x1';
+const pending = service._private.pendingCloudProjectSync(projectId);
+assert.strictEqual(pending.projectSyncPending, true,
+ 'Cloud 项目尚未同步时必须返回可延迟重试状态,不能直接判失败');
+assert.strictEqual(pending.retryAfterMs, 30000,
+ '项目同步等待时间必须是 30 秒');
+assert.strictEqual(pending.preferredProjectId, projectId,
+ '延迟重试必须携带已创建的项目 ID,防止重复创建项目');
+
+const root = path.join(__dirname, '..', 'src');
+const serviceSource = fs.readFileSync(path.join(root, 'services', 'aistudio_api_key_service.js'), 'utf8');
+const selectStart = serviceSource.indexOf('async function selectProjectInNewUi');
+const selectEnd = serviceSource.indexOf('\nasync function classifyProjectOptions', selectStart);
+const selectSource = serviceSource.slice(selectStart, selectEnd);
+assert(selectSource.includes('pendingCloudProjectSync('),
+ '已有 Cloud 项目但导入列表为空时必须挂起,不能转去新建重复项目');
+assert(selectSource.indexOf('if (preferredProjectId)')
+ < selectSource.indexOf('if (classified.createIndex >= 0)'),
+'挂起判断必须发生在 Create project 分支之前');
+
+const ensureStart = serviceSource.indexOf('async function ensureProjectSelected');
+const ensureEnd = serviceSource.indexOf('\nasync function ', ensureStart + 20);
+const ensureSource = serviceSource.slice(ensureStart, ensureEnd);
+assert(ensureSource.includes('if (newUi.handled) return newUi;'),
+ '新版项目选择结果必须原样向上传递,否则 projectSyncPending 会被丢失并立即判失败');
+assert(!ensureSource.includes('if (newUi.handled) return { ok: newUi.ok'),
+ '不得只挑选 ok/created/msg 字段,必须保留延迟重试元数据');
+
+const stepSource = fs.readFileSync(path.join(root, 'pipeline', 'steps', 'aistudio_api_key.js'), 'utf8');
+assert(stepSource.includes("type: 'aistudio_project_sync'"),
+ 'AI Studio 步骤必须把项目同步状态交给批量调度器');
+
+const runnerSource = fs.readFileSync(path.join(root, 'workers', 'task_runner.js'), 'utf8');
+assert(runnerSource.includes('? [ATOM_STEPS.aistudio_api_key.step]'),
+ '延迟到期后必须只补跑 AI Studio Key 步骤');
+assert(runnerSource.includes('__defer: true'),
+ '账号处理器必须返回调度器可识别的延迟重新入队标记');
+
+const pendingStore = step._private;
+const persisted = pendingStore.serializePendingProject(projectId, 1000, 'My Project 25180');
+assert.strictEqual(pendingStore.parsePendingProject(persisted, 2000), projectId,
+ '待同步 project ID 必须能跨批次持久化并恢复');
+assert.strictEqual(
+ pendingStore.parsePendingProject(persisted, 1000 + pendingStore.PENDING_PROJECT_MAX_AGE_MS + 1),
+ '',
+ '超过10分钟的待同步 project ID 必须失效,避免跨批次继续搜索错误项目');
+assert.strictEqual(
+ pendingStore.pendingProjectSettingKey(' User@Example.COM '),
+ 'aistudio_pending_project:user@example.com',
+ '待同步项目记录必须按规范化邮箱隔离');
+const pendingRecord = pendingStore.parsePendingProjectRecord(persisted, 31000);
+assert.strictEqual(pendingRecord.projectId, projectId,
+ '持久化记录必须恢复 project ID');
+assert.strictEqual(pendingRecord.savedAt, 1000,
+ '跨批次重试必须保留项目最初保存时间,不能无限刷新等待窗口');
+assert.strictEqual(pendingRecord.projectName, 'My Project 25180',
+ '待同步记录必须保存 Cloud Console 生成的项目名称,供导入搜索使用');
+assert.strictEqual(pendingRecord.ageMs, 30000,
+ '持久化记录必须提供年龄,供旧项目失效恢复判断');
+assert.strictEqual(pendingStore.PENDING_PROJECT_MAX_AGE_MS, 600000,
+ '待同步项目最多保留10分钟,过期后必须以页面实际状态为准');
+assert.strictEqual(pendingStore.PENDING_PROJECT_RECOVERY_MIN_AGE_MS, 600000,
+ '30秒只是回队间隔;10分钟保护期内搜索为空也不得重复创建项目');
+assert.strictEqual(pendingStore.MAX_PROJECT_SYNC_DEFERS, 4,
+ '项目同步应允许多轮30秒非阻塞回队,不能首次回队就重复创建项目');
+assert(stepSource.includes('if (waited < MAX_PROJECT_SYNC_DEFERS)'),
+ '项目同步步骤必须按上限多轮挂起,不能只等一轮');
+assert(stepSource.includes('loadPendingProjectRecord(dbWrite, account.email)'),
+ 'AI Studio 步骤启动时必须恢复跨批次待同步 project ID');
+assert(stepSource.includes('account._aistudioPreferredProjectSavedAt = await savePendingProjectId('),
+ '进入延迟队列前必须持久化 project ID,防止下轮重复创建项目');
+assert(stepSource.includes('allowMissingPreferredProjectRecovery:'),
+ '服务层必须收到项目是否已等满同步窗口,区分新项目同步延迟和失效旧项目');
+assert(runnerSource.includes('account._aistudioPreferredProjectSavedAt = Number('),
+ '延迟重新入队必须保留项目最初保存时间,供10分钟同步保护期判定');
+assert(runnerSource.includes('account._aistudioPreferredProjectName = String('),
+ '延迟重新入队必须保留项目名称,继续按名称搜索');
+assert(serviceSource.includes('if (!allowMissingPreferredProjectRecovery)'),
+ '10分钟保护期内项目搜索暂空时必须挂起,不能再次创建项目');
+assert(serviceSource.includes("await search.press('Enter').catch(() => {})"),
+ '按项目名称填入搜索框后必须触发 Enter,避免页面保留旧的空搜索状态');
+assert(serviceSource.includes('clearedPreferredProjectId'),
+ '确认旧项目不存在后必须把失效 project ID 传回流水线清理持久化状态');
+assert(stepSource.includes("await clearPendingProjectId(dbWrite, account.email)"),
+ '流水线收到失效 project ID 后必须清除数据库中的待同步项目记录');
+assert(stepSource.includes('account._aistudioProjectSyncWaits = 0'),
+ '旧项目失效后必须重置等待次数,让新项目获得完整的同步重试额度');
+
+console.log('aistudio_project_sync_deferral.test.js: PASS');
diff --git a/tests/change_2fa_existing_authenticator_fast_path.test.js b/tests/change_2fa_existing_authenticator_fast_path.test.js
new file mode 100644
index 0000000..631fa4a
--- /dev/null
+++ b/tests/change_2fa_existing_authenticator_fast_path.test.js
@@ -0,0 +1,17 @@
+'use strict';
+
+const assert = require('assert');
+const { _private } = require('../src/services/change_2fa_service');
+
+assert.strictEqual(
+ _private.shouldEnsureTwoStepVerificationEnabled(true),
+ false,
+ '替换已有 Authenticator 后不应重复检查两步验证开关',
+);
+assert.strictEqual(
+ _private.shouldEnsureTwoStepVerificationEnabled(false),
+ true,
+ '首次添加 Authenticator 后仍必须检查并开启两步验证',
+);
+
+console.log('change_2fa_existing_authenticator_fast_path.test.js: PASS');
diff --git a/tests/change_language_step.test.js b/tests/change_language_step.test.js
new file mode 100644
index 0000000..c90140b
--- /dev/null
+++ b/tests/change_language_step.test.js
@@ -0,0 +1,58 @@
+'use strict';
+
+const assert = require('assert');
+
+const servicePath = require.resolve('../src/services/language_service');
+const stepPath = require.resolve('../src/pipeline/steps/change_language');
+const languageService = require(servicePath);
+const originalChangeLanguage = languageService.changeLanguageFromPage;
+
+function makeContext(taskType) {
+ const dbWrites = [];
+ const messages = [];
+ const logs = [];
+ return {
+ account: { email: 'language-test@example.com' },
+ page: { url: () => 'https://myaccount.google.com/language' },
+ taskType,
+ results: {},
+ shouldStop: false,
+ failure: null,
+ log: message => logs.push(message),
+ dbWrite: async (...args) => { dbWrites.push(args); },
+ updateMessage: async (...args) => { messages.push(args); },
+ dbWrites,
+ messages,
+ logs,
+ };
+}
+
+(async () => {
+ try {
+ languageService.changeLanguageFromPage = async () => [false, '未找到可点击的保存按钮'];
+ delete require.cache[stepPath];
+ const changeLanguageStep = require(stepPath);
+
+ const combo = makeContext('aistudio_combo');
+ await changeLanguageStep(combo);
+ assert.strictEqual(combo.shouldStop, false, 'AI Studio 组合流程不应被语言切换失败中止');
+ assert.strictEqual(combo.failure, null);
+ assert.strictEqual(combo.results.changeLanguage.warning, true);
+ assert.strictEqual(combo.dbWrites.length, 0, '可降级警告不应污染账号失败原因');
+ assert(combo.logs.some(line => line.includes('继续后续流程')));
+
+ const standalone = makeContext('change_language');
+ await changeLanguageStep(standalone);
+ assert.strictEqual(standalone.shouldStop, true, '单独改语言任务失败时仍应停止并报错');
+ assert(standalone.failure && standalone.failure.msg.includes('改语言失败'));
+ assert.strictEqual(standalone.dbWrites.length, 1);
+
+ console.log('change_language_step.test.js: PASS');
+ } finally {
+ languageService.changeLanguageFromPage = originalChangeLanguage;
+ delete require.cache[stepPath];
+ }
+})().catch(error => {
+ console.error(error && error.stack ? error.stack : error);
+ process.exitCode = 1;
+});
diff --git a/tests/change_password_submit_fallback.test.js b/tests/change_password_submit_fallback.test.js
new file mode 100644
index 0000000..9276e55
--- /dev/null
+++ b/tests/change_password_submit_fallback.test.js
@@ -0,0 +1,100 @@
+'use strict';
+
+const assert = require('assert');
+const { _private } = require('../src/services/change_password_service');
+
+function locator(overrides = {}) {
+ return {
+ scrollIntoViewIfNeeded: async () => {},
+ waitFor: async () => {},
+ isDisabled: async () => false,
+ isVisible: async () => true,
+ click: async () => {},
+ press: async () => {},
+ evaluate: async () => true,
+ inputValue: async () => '',
+ ...overrides,
+ };
+}
+
+(async () => {
+ const calls = [];
+ const button = locator({
+ click: async (options) => calls.push(['click', options]),
+ evaluate: async () => { calls.push(['domClick']); return true; },
+ });
+ const confirm = locator({
+ press: async key => calls.push(['press', key]),
+ });
+ const log = () => {};
+
+ assert.strictEqual(await _private.submitPasswordForm({}, button, confirm, 1, log), true);
+ assert.deepStrictEqual(calls[0], ['click', { timeout: 10000, noWaitAfter: true }],
+ '首次提交必须使用正常点击且禁止 force');
+
+ assert.strictEqual(await _private.submitPasswordForm({}, button, confirm, 2, log), true);
+ assert.deepStrictEqual(calls[1], ['press', 'Enter'], '第二次必须复用原表单并按回车提交');
+
+ assert.strictEqual(await _private.submitPasswordForm({}, button, confirm, 3, log), true);
+ assert.deepStrictEqual(calls[2], ['domClick'], '第三次必须走 DOM 按钮点击兜底');
+
+ assert.strictEqual(await _private.canRetryPasswordSubmit(button, locator(), confirm), true);
+ assert.strictEqual(await _private.canRetryPasswordSubmit(
+ locator({ isDisabled: async () => true }),
+ locator(),
+ confirm,
+ ), false, '按钮仍禁用时不得重复提交');
+
+ assert.strictEqual(_private.hasPasswordFieldsInUrl(
+ 'https://myaccount.google.com/signinoptions/password?password=secret&confirmation_password=secret',
+ ), true, '密码字段进入 URL 时必须触发安全保护');
+ assert.strictEqual(_private.hasPasswordFieldsInUrl(
+ 'https://myaccount.google.com/signinoptions/password?rapt=token',
+ ), false);
+ assert.strictEqual(_private.hasPasswordFieldsInUrl(
+ 'https://myaccount.google.com/signinoptions/password?NEW_PASSWORD=secret',
+ ), true, '敏感查询参数检查必须忽略大小写');
+
+ let passwordSelector = '';
+ const visibleInputs = await _private.pickVisiblePasswordInputs({
+ locator: (selector) => {
+ passwordSelector = selector;
+ return {
+ count: async () => 2,
+ nth: () => locator(),
+ };
+ },
+ });
+ assert.strictEqual(visibleInputs.length, 2);
+ assert(passwordSelector.includes('input[name="password"]'),
+ '显示密码后 type 可能变成 text,仍必须按 name 识别密码控件');
+
+ let dialogConfirmClicks = 0;
+ const makeDialog = (visible, hasConfirm) => ({
+ isVisible: async () => visible,
+ locator: () => ({ first: () => ({ isVisible: async () => true }) }),
+ getByRole: (_role, options) => {
+ const isConfirm = /Change password/.test(String(options.name));
+ if (!isConfirm) return { first: () => ({ isVisible: async () => true }) };
+ return {
+ count: async () => (hasConfirm ? 1 : 0),
+ nth: () => locator({ click: async () => { dialogConfirmClicks++; } }),
+ };
+ },
+ waitFor: async () => {},
+ });
+ const dialogs = [makeDialog(true, false), makeDialog(true, true)];
+ const dialogHandled = await _private.confirmPasswordChangeDialog({
+ locator: () => ({
+ count: async () => dialogs.length,
+ nth: index => dialogs[index],
+ }),
+ }, log);
+ assert.strictEqual(dialogHandled, true);
+ assert.strictEqual(dialogConfirmClicks, 1, '必须点击弹窗作用域内的确认按钮');
+
+ console.log('change_password_submit_fallback.test.js: PASS');
+})().catch(error => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/tests/common_auth_password_change_form.test.js b/tests/common_auth_password_change_form.test.js
new file mode 100644
index 0000000..6650abd
--- /dev/null
+++ b/tests/common_auth_password_change_form.test.js
@@ -0,0 +1,116 @@
+'use strict';
+
+const assert = require('assert');
+const { checkNeedPassword, handleIdentityVerification } = require('../src/services/common_auth');
+
+function passwordInputs(visibleCount) {
+ return {
+ count: async () => visibleCount,
+ nth: () => ({ isVisible: async () => true }),
+ };
+}
+
+(async () => {
+ const logs = [];
+ const page = {
+ waitForLoadState: async () => {},
+ url: () => 'https://myaccount.google.com/signinoptions/password?rapt=test',
+ locator: selector => {
+ assert.strictEqual(selector, 'input[type="password"]');
+ return passwordInputs(2);
+ },
+ };
+
+ assert.strictEqual(await checkNeedPassword(page, message => logs.push(message)), false);
+ assert(logs.some(message => message.includes('双输入框')),
+ '两栏新密码表单应明确跳过旧密码验证');
+
+ const authenticatorLogs = [];
+ let networkIdleWaited = false;
+ const authenticatorPage = {
+ waitForLoadState: async () => { networkIdleWaited = true; },
+ url: () => 'https://myaccount.google.com/two-step-verification/authenticator?utm_source=test',
+ locator: () => { throw new Error('设置页快速路径不应扫描密码输入框'); },
+ };
+ assert.strictEqual(
+ await checkNeedPassword(authenticatorPage, message => authenticatorLogs.push(message)),
+ false,
+ );
+ assert.strictEqual(networkIdleWaited, false, 'Authenticator 设置页不应等待 networkidle');
+ assert(authenticatorLogs.some(message => message.includes('Authenticator 设置页')),
+ '日志应说明命中了设置页快速路径');
+
+ // 真实回归:reCAPTCHA 页面在初次密码检测结束后才跳到 challenge/pwd。
+ // 身份验证循环必须重新识别并提交密码,不能把它当成未知 challenge 空等。
+ {
+ let currentUrl = 'https://accounts.google.com/v3/signin/challenge/recaptcha';
+ const fills = [];
+ let submitCount = 0;
+
+ const missing = () => ({
+ first() { return this; },
+ nth() { return this; },
+ or(other) { return other; },
+ count: async () => 0,
+ waitFor: async () => { throw new Error('not visible'); },
+ isVisible: async () => false,
+ textContent: async () => '',
+ innerText: async () => '',
+ });
+ const visiblePassword = () => ({
+ first() { return this; },
+ nth() { return this; },
+ or() { return this; },
+ count: async () => 1,
+ waitFor: async () => {},
+ isVisible: async () => true,
+ fill: async value => { fills.push(value); },
+ press: async () => { submitCount++; currentUrl = 'https://myaccount.google.com/'; },
+ });
+ const visibleSubmit = () => ({
+ first() { return this; },
+ nth() { return this; },
+ or() { return this; },
+ count: async () => 1,
+ waitFor: async () => {},
+ isVisible: async () => true,
+ evaluate: async () => { submitCount++; currentUrl = 'https://myaccount.google.com/'; },
+ });
+ const page = {
+ url: () => currentUrl,
+ waitForLoadState: async () => {},
+ waitForTimeout: async () => {
+ if (currentUrl.includes('/recaptcha')) {
+ currentUrl = 'https://accounts.google.com/v3/signin/challenge/pwd';
+ }
+ },
+ locator: selector => {
+ if (selector === 'body') return { ...missing(), innerText: async () => '' };
+ if (selector === 'input[type="password"]' && currentUrl.includes('/pwd')) {
+ return visiblePassword();
+ }
+ if (selector === 'button:has-text("Next")' && currentUrl.includes('/pwd')) {
+ return visibleSubmit();
+ }
+ return missing();
+ },
+ };
+
+ const delayedLogs = [];
+ const result = await handleIdentityVerification(
+ page,
+ { password: 'correct-password' },
+ message => delayedLogs.push(message),
+ );
+ assert.deepStrictEqual(result, [true, null]);
+ assert.deepStrictEqual(fills, ['', 'correct-password']);
+ assert.strictEqual(submitCount, 1, '延迟密码页只能提交一次');
+ assert(delayedLogs.some(message => message.includes('延迟出现的密码验证页')),
+ '日志应明确记录异步出现的密码页恢复路径');
+ }
+
+ console.log('common_auth_password_change_form.test.js: PASS');
+})().catch(error => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/tests/concurrent_runner_resilience.test.js b/tests/concurrent_runner_resilience.test.js
index 82a6b1f..e07618b 100644
--- a/tests/concurrent_runner_resilience.test.js
+++ b/tests/concurrent_runner_resilience.test.js
@@ -74,6 +74,29 @@ function yieldEventLoop() {
assert.strictEqual(resumedBatch.completed, 4, '继续后必须处理完全部剩余项目');
assert.deepStrictEqual(pauseStarted.sort(), [1, 2, 3, 4], '继续后不得遗漏项目');
+ const deferredRuns = [];
+ const attempts = new Map();
+ const deferredBatch = await runConcurrentItems(['sleeping', 'next-a', 'next-b'], async item => {
+ deferredRuns.push(item);
+ const attempt = (attempts.get(item) || 0) + 1;
+ attempts.set(item, attempt);
+ if (item === 'sleeping' && attempt === 1) {
+ return { defer: true, delayMs: 40 };
+ }
+ await yieldEventLoop();
+ return { item, attempt };
+ }, {
+ concurrency: 1,
+ getDeferDelay: result => (result && result.defer ? result.delayMs : 0),
+ });
+ assert.deepStrictEqual(
+ deferredRuns,
+ ['sleeping', 'next-a', 'next-b', 'sleeping'],
+ '延迟项目等待期间必须让出唯一并发槽,让后续项目先完成',
+ );
+ assert.strictEqual(deferredBatch.completed, 3, '延迟重试不应重复累计完成进度');
+ assert.strictEqual(deferredBatch.results[0].attempt, 2, '延迟项目到期后应在原索引写入最终结果');
+
console.log('concurrent_runner_resilience.test.js: PASS');
})().catch(error => {
console.error(error);
diff --git a/tests/diagnostics_sensitive_url_redaction.test.js b/tests/diagnostics_sensitive_url_redaction.test.js
new file mode 100644
index 0000000..580e004
--- /dev/null
+++ b/tests/diagnostics_sensitive_url_redaction.test.js
@@ -0,0 +1,22 @@
+'use strict';
+
+const assert = require('assert');
+const { redactText } = require('../src/utils/diagnostics');
+
+const secret = 'DoNotLeak123456';
+const input = `https://example.test/path?username=user@example.com&password=${secret}&confirmation_password=${secret}&pli=1`;
+const redacted = redactText(input);
+
+assert(!redacted.includes(secret), '诊断脱敏后不得保留密码值');
+assert(redacted.includes('password=***'));
+assert(redacted.includes('confirmation_password=***'));
+
+const googleApiKey = `AIza${'A'.repeat(35)}`;
+const geminiApiKey = `AQ.${'B'.repeat(30)}`;
+const keyRedacted = redactText(`https://example.test?key=${googleApiKey}&gemini=${geminiApiKey}`);
+assert(!keyRedacted.includes(googleApiKey), 'Google API Key 不得进入诊断日志');
+assert(!keyRedacted.includes(geminiApiKey), 'Gemini API Key 不得进入诊断日志');
+assert(keyRedacted.includes('key=AIza***'));
+assert(keyRedacted.includes('gemini=AQ.***'));
+
+console.log('diagnostics_sensitive_url_redaction.test.js: PASS');
diff --git a/tests/language_service.test.js b/tests/language_service.test.js
index b6c77bc..9ec4125 100644
--- a/tests/language_service.test.js
+++ b/tests/language_service.test.js
@@ -67,7 +67,15 @@ class FakeLanguagePage {
this.usOption = new FakeElement({
text: 'United States',
box: { x: 300, y: 360, width: 300, height: 48 },
- onClick: () => { this.usSelected = true; },
+ onClick: () => {
+ this.usSelected = true;
+ if (this.saveBehavior === 'auto_apply') {
+ this.mode = 'saved';
+ this.currentTitle = 'Language';
+ this.pencilButton.aria = 'Edit language: English (United States)';
+ this.emitMainNavigation('https://myaccount.google.com/language?refresh_reason=primary');
+ }
+ },
});
this.cancelButton = new FakeElement({
text: 'Batal',
@@ -268,6 +276,8 @@ class NavigationProbePage extends FakeLanguagePage {
for (const saveButton of [
new FakeElement({ text: 'Save', aria: 'Save your language preference' }),
new FakeElement({ text: '保存', aria: '保存您的语言偏好设置' }),
+ new FakeElement({ text: 'Uložiť', aria: 'Uložiť výber jazyka' }),
+ new FakeElement({ text: 'चुनें', aria: 'भाषा चुनें' }),
new FakeElement({ text: 'Continue', attrs: { 'data-mdc-dialog-action': 'confirm' } }),
]) {
const cancel = new FakeElement({ text: 'Cancel', aria: 'Cancel language change' });
@@ -275,6 +285,22 @@ class NavigationProbePage extends FakeLanguagePage {
assert.strictEqual(await _private.findSaveButton(dialog), saveButton, '应按按钮语义识别保存动作');
}
+ const hindiCancel = new FakeElement({ text: 'रद्द करें', aria: 'भाषा बदलना रद्द करें' });
+ const hindiSave = new FakeElement({ text: 'चुनें', aria: 'भाषा चुनें' });
+ assert.strictEqual(
+ await _private.findSaveButton({ $$: async () => [hindiCancel, hindiSave] }),
+ hindiSave,
+ 'Hindi 页面必须排除“रद्द करें”并识别“चुनें”为确认动作',
+ );
+
+ const slovakCancel = new FakeElement({ text: 'Zrušiť', aria: 'Zrušiť zmenu jazyka' });
+ const slovakSave = new FakeElement({ text: 'Uložiť', aria: 'Uložiť výber jazyka' });
+ assert.strictEqual(
+ await _private.findSaveButton({ $$: async () => [slovakCancel, slovakSave] }),
+ slovakSave,
+ 'Slovak 页面必须排除“Zrušiť”并识别“Uložiť”为保存动作',
+ );
+
const expectedPrimaryPage = new NavigationProbePage('expected_primary');
const expectedConfirmation = await _private.confirmEnglishUnitedStates(expectedPrimaryPage, () => {});
assert.strictEqual(expectedConfirmation.confirmed, true, '本次主 frame primary refresh 应继续并完成最终确认');
@@ -324,6 +350,12 @@ class NavigationProbePage extends FakeLanguagePage {
assert.strictEqual(page.listenerCount('framenavigated'), 0, '完成后应解绑导航监听');
assert(logs.some(line => line.includes('语言已成功更改')));
+ const autoApplyPage = new FakeLanguagePage(0, 'auto_apply');
+ const [autoApplyOk, autoApplyMessage] = await changeLanguageFromPage(autoApplyPage, () => {});
+ assert.strictEqual(autoApplyOk, true, autoApplyMessage);
+ assert.strictEqual(autoApplyPage.usSelected, true, '自动保存版本也应先选择 United States');
+ assert.strictEqual(autoApplyPage.listenerCount('framenavigated'), 0, '自动保存完成后应解绑导航监听');
+
const cancelLastPage = new FakeLanguagePage(0, 'cancel_last');
const [cancelLastOk, cancelLastMessage] = await changeLanguageFromPage(cancelLastPage, () => {});
assert.strictEqual(cancelLastOk, false, '最后一个按钮为取消时不得当作保存');
diff --git a/tests/login_challenge_recovery.test.js b/tests/login_challenge_recovery.test.js
index dd020a4..c8cb8c6 100644
--- a/tests/login_challenge_recovery.test.js
+++ b/tests/login_challenge_recovery.test.js
@@ -63,6 +63,11 @@ function emptyLocator() {
'直接密码页应兼容 current-password 输入框');
assert.match(source, /const passwordNextSelector = await clickFirstVisible[\s\S]*未定位到密码页 Next 按钮,改用回车提交/,
'密码页 Next 应有多选择器和回车兜底');
+ const ippStart = source.indexOf('if (isRecoverableAlternateChallengeUrl(currentUrl))');
+ const ippEnd = source.indexOf('// 某些账号从其它验证方式返回后', ippStart);
+ const ippSource = source.slice(ippStart, ippEnd);
+ assert(ippSource.includes('challengeKind(page.url()) !== kind'),
+ 'IPP 点击其它方式后必须复核 URL,禁止假离开造成4/3以上重试');
}
{
diff --git a/tests/os_mouse_concurrency_lock.test.js b/tests/os_mouse_concurrency_lock.test.js
new file mode 100644
index 0000000..6eb5a12
--- /dev/null
+++ b/tests/os_mouse_concurrency_lock.test.js
@@ -0,0 +1,45 @@
+'use strict';
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { _private } = require('../src/utils/os_mouse');
+
+(async () => {
+ let active = 0;
+ let maxActive = 0;
+ const timeline = [];
+ const logs = [];
+
+ const results = await Promise.all([0, 1, 2, 3].map(index => (
+ _private.withOsMouseLock(async () => {
+ active++;
+ maxActive = Math.max(maxActive, active);
+ timeline.push(`start-${index}`);
+ await new Promise(resolve => setTimeout(resolve, 5));
+ timeline.push(`end-${index}`);
+ active--;
+ return index;
+ }, message => logs.push(message))
+ )));
+
+ assert.deepStrictEqual(results, [0, 1, 2, 3]);
+ assert.strictEqual(maxActive, 1, '真实鼠标临界区不得并行执行');
+ assert.deepStrictEqual(timeline, [
+ 'start-0', 'end-0',
+ 'start-1', 'end-1',
+ 'start-2', 'end-2',
+ 'start-3', 'end-3',
+ ], '真实鼠标操作应按进入顺序串行执行');
+ assert(logs.some(message => message.includes('等待其它窗口')),
+ '出现并发等待时应写入可观测日志');
+
+ const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'utils', 'os_mouse.js'), 'utf8');
+ assert(source.includes('[WKeyboard]::keybd_event(0x1B'),
+ '必须用系统级 Escape 关闭网页 DOM 无法控制的浏览器保存密码弹窗');
+
+ console.log('os_mouse_concurrency_lock.test.js: PASS');
+})().catch(error => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/tests/screenshot_closed_page.test.js b/tests/screenshot_closed_page.test.js
index 567b154..1301f54 100644
--- a/tests/screenshot_closed_page.test.js
+++ b/tests/screenshot_closed_page.test.js
@@ -22,6 +22,19 @@ const { saveErrorScreenshot, saveCheckpointScreenshot, _private } = require('../
);
assert.strictEqual(_private.isTargetClosedError(new Error('disk full')), false);
+ let maskSelector = '';
+ const maskLocators = _private.getSensitiveMaskLocators({
+ locator: (selector) => {
+ maskSelector = selector;
+ return { selector };
+ },
+ });
+ assert.strictEqual(maskLocators.length, 1);
+ assert(maskSelector.includes('input[name*="password" i]'),
+ '截图必须遮盖已切换为明文类型的密码输入框');
+ assert(maskSelector.includes('input[autocomplete="one-time-code"]'),
+ '截图必须遮盖动态验证码输入框');
+
console.log('screenshot_closed_page.test.js: PASS');
})().catch(error => {
console.error(error);