@reconcrap/boss-recommend-mcp 1.3.27 → 1.3.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "1.3.27",
3
+ "version": "1.3.29",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -484,6 +484,106 @@ async function testBossChatRecoverToChatIndexShouldForceNavigateAndWaitForComple
484
484
  );
485
485
  }
486
486
 
487
+ async function testBossChatPageShouldFallbackToEscapeWhenClosingCandidateDetail() {
488
+ const calls = [];
489
+ const mouseEvents = [];
490
+ let stateIndex = 0;
491
+ const states = [
492
+ {
493
+ open: true,
494
+ panelCount: 1,
495
+ closeCount: 1,
496
+ topPanelClass: "base-info-single-top-detail",
497
+ topPanelScore: 520,
498
+ panelRect: {
499
+ left: 940,
500
+ top: 60,
501
+ width: 360,
502
+ height: 720,
503
+ right: 1300,
504
+ bottom: 780
505
+ },
506
+ closeRect: {
507
+ left: 1274,
508
+ top: 12,
509
+ width: 30,
510
+ height: 30,
511
+ right: 1304,
512
+ bottom: 42
513
+ }
514
+ },
515
+ {
516
+ open: true,
517
+ panelCount: 1,
518
+ closeCount: 1,
519
+ topPanelClass: "base-info-single-top-detail",
520
+ topPanelScore: 520,
521
+ panelRect: {
522
+ left: 940,
523
+ top: 60,
524
+ width: 360,
525
+ height: 720,
526
+ right: 1300,
527
+ bottom: 780
528
+ },
529
+ closeRect: {
530
+ left: 1274,
531
+ top: 12,
532
+ width: 30,
533
+ height: 30,
534
+ right: 1304,
535
+ bottom: 42
536
+ }
537
+ },
538
+ {
539
+ open: false,
540
+ panelCount: 0,
541
+ closeCount: 0,
542
+ topPanelClass: "",
543
+ topPanelScore: 0,
544
+ panelRect: null,
545
+ closeRect: null
546
+ }
547
+ ];
548
+
549
+ const fakeChromeClient = {
550
+ Input: {
551
+ async dispatchMouseEvent(payload) {
552
+ mouseEvents.push(payload);
553
+ }
554
+ },
555
+ async pressEscape() {
556
+ calls.push("pressEscape");
557
+ },
558
+ async callFunction(fn) {
559
+ calls.push(fn.name);
560
+ if (fn.name === "browserIsCandidateDetailOpen") {
561
+ const value = states[Math.min(stateIndex, states.length - 1)];
562
+ stateIndex += 1;
563
+ return value;
564
+ }
565
+ if (fn.name === "browserCloseCandidateDetailDomOnce") {
566
+ return {
567
+ ok: true,
568
+ selector: ".close-btn",
569
+ method: "dom-click-once"
570
+ };
571
+ }
572
+ throw new Error(`unexpected function: ${fn.name}`);
573
+ }
574
+ };
575
+
576
+ const page = new BossChatPage(fakeChromeClient);
577
+ const result = await page.closeCandidateDetail({
578
+ maxAttempts: 1,
579
+ ensureDismiss: true
580
+ });
581
+
582
+ assert.equal(result.closed, true);
583
+ assert.equal(calls.includes("pressEscape"), true);
584
+ assert.equal(mouseEvents.length > 0, true);
585
+ }
586
+
487
587
  async function testBossChatMcpToolsShouldValidateAndRoute() {
488
588
  await withBossChatWorkspace(async (workspaceRoot) => {
489
589
  const toolsResponse = await handleRequest({
@@ -1089,6 +1189,16 @@ async function testBossChatAppShouldResetPrimaryChatLabelBeforeInitialPrime() {
1089
1189
  artifactRootDir: os.tmpdir(),
1090
1190
  resumeOpenCooldownMs: 0,
1091
1191
  });
1192
+ app.waitForCandidateList = async ({ reason } = {}) => {
1193
+ calls.push(`waitForCandidateList:${reason || "unknown"}`);
1194
+ return {
1195
+ ready: true,
1196
+ waitedMs: 0,
1197
+ attempts: 1,
1198
+ listItemCount: 1,
1199
+ lastError: "",
1200
+ };
1201
+ };
1092
1202
  app.processCustomer = async (_customer, _profile, _runId, options = {}) => {
1093
1203
  calls.push(`processCustomer:${options.skipCardClick === true ? "skip" : "click"}`);
1094
1204
  return {
@@ -1110,18 +1220,134 @@ async function testBossChatAppShouldResetPrimaryChatLabelBeforeInitialPrime() {
1110
1220
  llm: { model: "gpt-test" },
1111
1221
  });
1112
1222
 
1113
- assert.deepEqual(calls.slice(0, 5), [
1223
+ assert.deepEqual(calls.slice(0, 4), [
1114
1224
  "ensureReady",
1115
1225
  "activatePrimaryChatLabel:全部",
1116
1226
  "selectJob:算法工程师",
1117
1227
  "activateUnreadFilter",
1118
- "primeConversationByFirstCandidate:1",
1119
1228
  ]);
1229
+ assert.equal(calls.includes("primeConversationByFirstCandidate:1"), true);
1120
1230
  assert.equal(calls.includes("processCustomer:skip"), true);
1121
1231
  assert.equal(summary.inspected, 1);
1122
1232
  assert.equal(summary.skipped, 1);
1123
1233
  }
1124
1234
 
1235
+ async function testBossChatAppShouldCloseCandidateDetailDuringRunCleanup() {
1236
+ const calls = [];
1237
+ const page = {
1238
+ async ensureReady() {
1239
+ calls.push("ensureReady");
1240
+ return { hasListContainer: true, listItemCount: 1 };
1241
+ },
1242
+ async activatePrimaryChatLabel(label) {
1243
+ calls.push(`activatePrimaryChatLabel:${label}`);
1244
+ return { changed: false, verified: true, activeLabel: label };
1245
+ },
1246
+ async selectJob(jobSelection) {
1247
+ calls.push(`selectJob:${jobSelection.label}`);
1248
+ return jobSelection;
1249
+ },
1250
+ async activateUnreadFilter() {
1251
+ calls.push("activateUnreadFilter");
1252
+ return { changed: false, verified: true, activeLabel: "未读" };
1253
+ },
1254
+ async primeConversationByFirstCandidate() {
1255
+ calls.push("primeConversationByFirstCandidate:1");
1256
+ return {
1257
+ candidate: {
1258
+ customerId: "1008",
1259
+ name: "候选人清理",
1260
+ sourceJob: "算法工程师",
1261
+ domIndex: 0
1262
+ },
1263
+ totalVisibleCandidates: 1,
1264
+ readyState: {
1265
+ hasOnlineResume: true,
1266
+ hasAskResume: true,
1267
+ hasAttachmentResume: false
1268
+ }
1269
+ };
1270
+ },
1271
+ async getLoadedCustomers() {
1272
+ calls.push("getLoadedCustomers:1");
1273
+ return [];
1274
+ },
1275
+ async closeResumeModalDomOnce() {
1276
+ calls.push("closeResumeModalDomOnce");
1277
+ return {
1278
+ closed: true,
1279
+ method: "already-closed",
1280
+ finalState: { scopeCount: 0, iframeCount: 0, closeCount: 0, topScopeClass: "" }
1281
+ };
1282
+ },
1283
+ async closeCandidateDetailDomOnce() {
1284
+ calls.push("closeCandidateDetailDomOnce");
1285
+ return {
1286
+ closed: true,
1287
+ method: "dom-close-once:.close-btn",
1288
+ finalState: { panelCount: 0, closeCount: 0, topPanelClass: "" }
1289
+ };
1290
+ }
1291
+ };
1292
+ const stateStore = {
1293
+ async load() {},
1294
+ hasAny() {
1295
+ return false;
1296
+ },
1297
+ async record() {}
1298
+ };
1299
+ const app = new BossChatApp({
1300
+ page,
1301
+ llmClient: {},
1302
+ interaction: {
1303
+ async sleepRange() {},
1304
+ async maybeRest() {}
1305
+ },
1306
+ resumeCaptureService: {},
1307
+ stateStore,
1308
+ reportStore: {
1309
+ async write() {
1310
+ return "report.json";
1311
+ }
1312
+ },
1313
+ logger: { log() {} },
1314
+ dryRun: true,
1315
+ artifactRootDir: os.tmpdir(),
1316
+ resumeOpenCooldownMs: 0
1317
+ });
1318
+ app.waitForCandidateList = async ({ reason } = {}) => {
1319
+ calls.push(`waitForCandidateList:${reason || "unknown"}`);
1320
+ return {
1321
+ ready: true,
1322
+ waitedMs: 0,
1323
+ attempts: 1,
1324
+ listItemCount: 1,
1325
+ lastError: ""
1326
+ };
1327
+ };
1328
+ app.processCustomer = async () => ({
1329
+ name: "候选人清理",
1330
+ passed: false,
1331
+ requested: false,
1332
+ reason: "skip",
1333
+ error: "",
1334
+ artifacts: {}
1335
+ });
1336
+
1337
+ const summary = await app.run({
1338
+ screeningCriteria: "有 AI 项目经验",
1339
+ targetCount: 1,
1340
+ startFrom: "unread",
1341
+ jobSelection: { label: "算法工程师", value: "job-1" },
1342
+ chrome: { port: 9222 },
1343
+ llm: { model: "gpt-test" }
1344
+ });
1345
+
1346
+ assert.equal(summary.inspected, 1);
1347
+ assert.equal(calls.includes("closeCandidateDetailDomOnce"), true);
1348
+ assert.equal(calls.lastIndexOf("closeCandidateDetailDomOnce") > calls.indexOf("getLoadedCustomers:1"), true);
1349
+ }
1350
+
1125
1351
  async function testBossChatAppShouldRestoreListContextAfterRecovery() {
1126
1352
  const calls = [];
1127
1353
  let primeCount = 0;
@@ -1209,6 +1435,22 @@ async function testBossChatAppShouldRestoreListContextAfterRecovery() {
1209
1435
  artifactRootDir: os.tmpdir(),
1210
1436
  resumeOpenCooldownMs: 0,
1211
1437
  });
1438
+ app.waitForCandidateList = async ({ reason } = {}) => {
1439
+ calls.push(`waitForCandidateList:${reason || "unknown"}`);
1440
+ return {
1441
+ ready:
1442
+ reason === "initial-context-restore" ||
1443
+ reason === "post-recovery-context-restore",
1444
+ waitedMs: 0,
1445
+ attempts: 1,
1446
+ listItemCount:
1447
+ reason === "initial-context-restore" ||
1448
+ reason === "post-recovery-context-restore"
1449
+ ? 1
1450
+ : 0,
1451
+ lastError: "",
1452
+ };
1453
+ };
1212
1454
  app.processCustomer = async (_customer, _profile, _runId, options = {}) => {
1213
1455
  calls.push(`processCustomer:${options.skipCardClick === true ? "skip" : "click"}`);
1214
1456
  return {
@@ -1236,12 +1478,127 @@ async function testBossChatAppShouldRestoreListContextAfterRecovery() {
1236
1478
  assert.equal(calls[recoverIndex + 1], "activatePrimaryChatLabel:全部");
1237
1479
  assert.equal(calls[recoverIndex + 2], "selectJob:算法工程师");
1238
1480
  assert.equal(calls[recoverIndex + 3], "activateUnreadFilter");
1239
- assert.equal(calls[recoverIndex + 4], "primeConversationByFirstCandidate:2");
1481
+ assert.equal(calls[recoverIndex + 4], "waitForCandidateList:post-recovery-context-restore");
1482
+ assert.equal(calls[recoverIndex + 5], "primeConversationByFirstCandidate:2");
1240
1483
  assert.equal(calls.includes("processCustomer:skip"), true);
1241
1484
  assert.equal(summary.inspected, 1);
1242
1485
  assert.equal(summary.skipped, 1);
1243
1486
  }
1244
1487
 
1488
+ async function testBossChatAppShouldWaitForCandidateListBeforePriming() {
1489
+ const calls = [];
1490
+ let pageStateCall = 0;
1491
+ const page = {
1492
+ async ensureReady() {
1493
+ calls.push("ensureReady");
1494
+ return { hasListContainer: false, listItemCount: 0 };
1495
+ },
1496
+ async activatePrimaryChatLabel(label) {
1497
+ calls.push(`activatePrimaryChatLabel:${label}`);
1498
+ return { changed: false, verified: true, activeLabel: label };
1499
+ },
1500
+ async selectJob(jobSelection) {
1501
+ calls.push(`selectJob:${jobSelection.label}`);
1502
+ return jobSelection;
1503
+ },
1504
+ async activateUnreadFilter() {
1505
+ calls.push("activateUnreadFilter");
1506
+ return { changed: true, verified: true, activeLabel: "未读" };
1507
+ },
1508
+ async getPageState() {
1509
+ pageStateCall += 1;
1510
+ calls.push(`getPageState:${pageStateCall}`);
1511
+ return {
1512
+ href: "https://www.zhipin.com/web/chat/index",
1513
+ readyState: "complete",
1514
+ hasListContainer: pageStateCall >= 3,
1515
+ listItemCount: pageStateCall >= 3 ? 2 : 0,
1516
+ };
1517
+ },
1518
+ async primeConversationByFirstCandidate() {
1519
+ calls.push("primeConversationByFirstCandidate:1");
1520
+ return {
1521
+ candidate: {
1522
+ customerId: "1003",
1523
+ name: "候选人C",
1524
+ sourceJob: "算法工程师",
1525
+ domIndex: 0,
1526
+ },
1527
+ totalVisibleCandidates: 2,
1528
+ readyState: {
1529
+ hasOnlineResume: true,
1530
+ hasAskResume: true,
1531
+ hasAttachmentResume: false,
1532
+ },
1533
+ };
1534
+ },
1535
+ async getLoadedCustomers() {
1536
+ calls.push("getLoadedCustomers:1");
1537
+ return [];
1538
+ },
1539
+ async closeResumeModalDomOnce() {
1540
+ return {
1541
+ closed: true,
1542
+ method: "already-closed",
1543
+ finalState: { scopeCount: 0, iframeCount: 0, closeCount: 0, topScopeClass: "" },
1544
+ };
1545
+ },
1546
+ };
1547
+ const stateStore = {
1548
+ async load() {},
1549
+ hasAny() {
1550
+ return false;
1551
+ },
1552
+ async record() {},
1553
+ };
1554
+ const app = new BossChatApp({
1555
+ page,
1556
+ llmClient: {},
1557
+ interaction: {
1558
+ async sleepRange() {},
1559
+ async maybeRest() {},
1560
+ },
1561
+ resumeCaptureService: {},
1562
+ stateStore,
1563
+ reportStore: {
1564
+ async write() {
1565
+ return "report.json";
1566
+ },
1567
+ },
1568
+ logger: { log() {} },
1569
+ dryRun: true,
1570
+ artifactRootDir: os.tmpdir(),
1571
+ resumeOpenCooldownMs: 0,
1572
+ });
1573
+ app.processCustomer = async (_customer, _profile, _runId, options = {}) => {
1574
+ calls.push(`processCustomer:${options.skipCardClick === true ? "skip" : "click"}`);
1575
+ return {
1576
+ name: "候选人C",
1577
+ passed: false,
1578
+ requested: false,
1579
+ reason: "skip",
1580
+ error: "",
1581
+ artifacts: {},
1582
+ };
1583
+ };
1584
+
1585
+ const summary = await app.run({
1586
+ screeningCriteria: "有 AI 项目经验",
1587
+ targetCount: 1,
1588
+ startFrom: "unread",
1589
+ jobSelection: { label: "算法工程师", value: "job-1" },
1590
+ chrome: { port: 9222 },
1591
+ llm: { model: "gpt-test" },
1592
+ });
1593
+
1594
+ const primeIndex = calls.indexOf("primeConversationByFirstCandidate:1");
1595
+ const thirdStateIndex = calls.indexOf("getPageState:3");
1596
+ assert.equal(thirdStateIndex >= 0, true);
1597
+ assert.equal(primeIndex > thirdStateIndex, true);
1598
+ assert.equal(summary.inspected, 1);
1599
+ assert.equal(summary.skipped, 1);
1600
+ }
1601
+
1245
1602
  async function testBossChatAppShouldPersistEvidenceArtifacts() {
1246
1603
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-chat-artifacts-"));
1247
1604
  await mkdir(tempDir, { recursive: true });
@@ -1368,6 +1725,7 @@ async function main() {
1368
1725
  await testBossChatPrepareShouldRetryWhenChatPageIsNotReady();
1369
1726
  await testBossChatPageShouldTreatBlankChatShellAsOnChatPage();
1370
1727
  await testBossChatRecoverToChatIndexShouldForceNavigateAndWaitForCompleteLoad();
1728
+ await testBossChatPageShouldFallbackToEscapeWhenClosingCandidateDetail();
1371
1729
  await testBossChatMcpToolsShouldValidateAndRoute();
1372
1730
  await testBossChatCliShouldSupportRunAndFollowUpParsing();
1373
1731
  await testVendorBossChatCliShouldWaitForHydratedChatShell();
@@ -1378,7 +1736,9 @@ async function main() {
1378
1736
  await testBossChatLlmTextChunkFallbackShouldWork();
1379
1737
  await testBossChatLlmShouldApplyThinkingDefaultsAndOverrides();
1380
1738
  await testBossChatAppShouldResetPrimaryChatLabelBeforeInitialPrime();
1739
+ await testBossChatAppShouldCloseCandidateDetailDuringRunCleanup();
1381
1740
  await testBossChatAppShouldRestoreListContextAfterRecovery();
1741
+ await testBossChatAppShouldWaitForCandidateListBeforePriming();
1382
1742
  await testBossChatAppShouldPersistEvidenceArtifacts();
1383
1743
  console.log("boss-chat tests passed");
1384
1744
  }
@@ -64,6 +64,9 @@ function hasResumeRequestSentMessage(state = {}) {
64
64
  return recent.some((item) => normalizeText(item).includes('简历请求已发送'));
65
65
  }
66
66
 
67
+ const CANDIDATE_LIST_WAIT_AFTER_CONTEXT_MS = 5000;
68
+ const CANDIDATE_LIST_WAIT_POLL_MS = 500;
69
+
67
70
  export class BossChatApp {
68
71
  constructor({
69
72
  page,
@@ -152,6 +155,105 @@ export class BossChatApp {
152
155
  : this.page.activateUnreadFilter();
153
156
  }
154
157
 
158
+ async waitForCandidateList({
159
+ reason = 'unknown',
160
+ maxWaitMs = CANDIDATE_LIST_WAIT_AFTER_CONTEXT_MS,
161
+ pollMs = CANDIDATE_LIST_WAIT_POLL_MS,
162
+ } = {}) {
163
+ const startedAt = Date.now();
164
+ let attempts = 0;
165
+ let lastState = null;
166
+ let lastError = '';
167
+
168
+ while (Date.now() - startedAt <= maxWaitMs) {
169
+ attempts += 1;
170
+ try {
171
+ if (typeof this.page.getPageState === 'function') {
172
+ lastState = await this.page.getPageState();
173
+ if (Number(lastState?.listItemCount || 0) > 0) {
174
+ return {
175
+ ready: true,
176
+ waitedMs: Date.now() - startedAt,
177
+ attempts,
178
+ listItemCount: Number(lastState?.listItemCount || 0),
179
+ lastState,
180
+ lastError,
181
+ };
182
+ }
183
+ } else if (typeof this.page.getLoadedCustomers === 'function') {
184
+ const customers = await this.page.getLoadedCustomers();
185
+ if (Array.isArray(customers) && customers.length > 0) {
186
+ return {
187
+ ready: true,
188
+ waitedMs: Date.now() - startedAt,
189
+ attempts,
190
+ listItemCount: customers.length,
191
+ lastState,
192
+ lastError,
193
+ };
194
+ }
195
+ }
196
+ } catch (error) {
197
+ lastError = String(error?.message || error || '');
198
+ }
199
+
200
+ if (Date.now() - startedAt >= maxWaitMs) {
201
+ break;
202
+ }
203
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
204
+ }
205
+
206
+ return {
207
+ ready: false,
208
+ waitedMs: Date.now() - startedAt,
209
+ attempts,
210
+ listItemCount: Number(lastState?.listItemCount || 0),
211
+ lastState,
212
+ lastError,
213
+ reason,
214
+ };
215
+ }
216
+
217
+ async cleanupPanels({
218
+ resumeMaxAttempts = 6,
219
+ detailMaxAttempts = 4,
220
+ ensureDismiss = true,
221
+ } = {}) {
222
+ const resume =
223
+ typeof this.page.closeResumeModalDomOnce === 'function'
224
+ ? await this.page.closeResumeModalDomOnce()
225
+ : await this.page.closeResumeModal({
226
+ maxAttempts: resumeMaxAttempts,
227
+ ensureDismiss,
228
+ });
229
+
230
+ let detail = {
231
+ closed: true,
232
+ method: 'unsupported',
233
+ finalState: {
234
+ panelCount: 0,
235
+ closeCount: 0,
236
+ topPanelClass: '',
237
+ },
238
+ };
239
+ if (typeof this.page.closeCandidateDetailDomOnce === 'function') {
240
+ detail = await this.page.closeCandidateDetailDomOnce();
241
+ if (!detail.closed && typeof this.page.closeCandidateDetail === 'function') {
242
+ detail = await this.page.closeCandidateDetail({
243
+ maxAttempts: detailMaxAttempts,
244
+ ensureDismiss,
245
+ });
246
+ }
247
+ } else if (typeof this.page.closeCandidateDetail === 'function') {
248
+ detail = await this.page.closeCandidateDetail({
249
+ maxAttempts: detailMaxAttempts,
250
+ ensureDismiss,
251
+ });
252
+ }
253
+
254
+ return { resume, detail };
255
+ }
256
+
155
257
  async run(profile) {
156
258
  const startedAt = new Date().toISOString();
157
259
  const runId = runToken(new Date());
@@ -168,8 +270,34 @@ export class BossChatApp {
168
270
  } catch (error) {
169
271
  this.logger.log(`页面就绪检查告警:${error?.message || error},将继续执行预热恢复流程。`);
170
272
  }
171
- const filterResult = await this.restoreListContext(profile);
273
+ let filterResult = await this.restoreListContext(profile);
172
274
  await this.interaction.sleepRange(420, 160);
275
+ let initialListWait = await this.waitForCandidateList({
276
+ reason: 'initial-context-restore',
277
+ });
278
+ if (initialListWait.ready) {
279
+ this.logger.log(
280
+ `候选人列表已就绪:reason=initial-context-restore | waited=${initialListWait.waitedMs}ms | attempts=${initialListWait.attempts} | count=${initialListWait.listItemCount}`,
281
+ );
282
+ } else {
283
+ this.logger.log(
284
+ `候选人列表等待超时:reason=initial-context-restore | waited=${initialListWait.waitedMs}ms | attempts=${initialListWait.attempts} | count=${initialListWait.listItemCount} | lastError=${initialListWait.lastError || 'n/a'},继续尝试预热。`,
285
+ );
286
+ filterResult = await this.restoreListContext(profile);
287
+ await this.interaction.sleepRange(420, 160);
288
+ initialListWait = await this.waitForCandidateList({
289
+ reason: 'initial-context-restore-reapply',
290
+ });
291
+ if (initialListWait.ready) {
292
+ this.logger.log(
293
+ `候选人列表二次恢复成功:reason=initial-context-restore-reapply | waited=${initialListWait.waitedMs}ms | attempts=${initialListWait.attempts} | count=${initialListWait.listItemCount}`,
294
+ );
295
+ } else {
296
+ this.logger.log(
297
+ `候选人列表二次等待仍超时:reason=initial-context-restore-reapply | waited=${initialListWait.waitedMs}ms | attempts=${initialListWait.attempts} | count=${initialListWait.listItemCount} | lastError=${initialListWait.lastError || 'n/a'},继续尝试预热。`,
298
+ );
299
+ }
300
+ }
173
301
  this.logger.log('预热步骤:准备点击首位人选初始化聊天容器...');
174
302
  let primedCustomer = null;
175
303
 
@@ -269,13 +397,22 @@ export class BossChatApp {
269
397
  message,
270
398
  )
271
399
  ) {
400
+ const delayedListWait = await this.waitForCandidateList({
401
+ reason: `main-loop:${message}`,
402
+ });
403
+ if (delayedListWait.ready) {
404
+ this.logger.log(
405
+ `候选人列表延迟恢复成功:reason=main-loop:${message} | waited=${delayedListWait.waitedMs}ms | attempts=${delayedListWait.attempts} | count=${delayedListWait.listItemCount},继续重试扫描。`,
406
+ );
407
+ continue;
408
+ }
272
409
  try {
273
410
  const recover = await this.page.recoverToChatIndex();
274
411
  this.logger.log(
275
412
  `页面恢复:changed=${recover.changed} | href=${recover.href || 'unknown'},准备重新预热并继续。`,
276
413
  );
277
414
  await this.interaction.sleepRange(900, 220);
278
- const recoveredFilterResult = await this.restoreListContext(profile);
415
+ let recoveredFilterResult = await this.restoreListContext(profile);
279
416
  this.logger.log(
280
417
  `恢复后列表上下文:岗位=${profile.jobSelection?.label || profile.jobSelection?.value || '未知'};列表范围: ${filterLabel}${
281
418
  recoveredFilterResult.changed
@@ -285,6 +422,41 @@ export class BossChatApp {
285
422
  : '(已在目标筛选)'
286
423
  }${recoveredFilterResult?.activeLabel ? ` | active=${recoveredFilterResult.activeLabel}` : ''}`,
287
424
  );
425
+ let recoveredListWait = await this.waitForCandidateList({
426
+ reason: 'post-recovery-context-restore',
427
+ });
428
+ if (recoveredListWait.ready) {
429
+ this.logger.log(
430
+ `恢复后候选人列表已就绪:reason=post-recovery-context-restore | waited=${recoveredListWait.waitedMs}ms | attempts=${recoveredListWait.attempts} | count=${recoveredListWait.listItemCount}`,
431
+ );
432
+ } else {
433
+ this.logger.log(
434
+ `恢复后候选人列表等待超时:reason=post-recovery-context-restore | waited=${recoveredListWait.waitedMs}ms | attempts=${recoveredListWait.attempts} | count=${recoveredListWait.listItemCount} | lastError=${recoveredListWait.lastError || 'n/a'},继续尝试预热。`,
435
+ );
436
+ recoveredFilterResult = await this.restoreListContext(profile);
437
+ this.logger.log(
438
+ `恢复后二次应用列表上下文:岗位=${profile.jobSelection?.label || profile.jobSelection?.value || '未知'};列表范围: ${filterLabel}${
439
+ recoveredFilterResult.changed
440
+ ? recoveredFilterResult.verified === false
441
+ ? '(已尝试切换,未验证 active)'
442
+ : '(已切换)'
443
+ : '(已在目标筛选)'
444
+ }${recoveredFilterResult?.activeLabel ? ` | active=${recoveredFilterResult.activeLabel}` : ''}`,
445
+ );
446
+ await this.interaction.sleepRange(420, 160);
447
+ recoveredListWait = await this.waitForCandidateList({
448
+ reason: 'post-recovery-context-restore-reapply',
449
+ });
450
+ if (recoveredListWait.ready) {
451
+ this.logger.log(
452
+ `恢复后二次候选人列表恢复成功:reason=post-recovery-context-restore-reapply | waited=${recoveredListWait.waitedMs}ms | attempts=${recoveredListWait.attempts} | count=${recoveredListWait.listItemCount}`,
453
+ );
454
+ } else {
455
+ this.logger.log(
456
+ `恢复后二次候选人列表等待仍超时:reason=post-recovery-context-restore-reapply | waited=${recoveredListWait.waitedMs}ms | attempts=${recoveredListWait.attempts} | count=${recoveredListWait.listItemCount} | lastError=${recoveredListWait.lastError || 'n/a'},继续尝试预热。`,
457
+ );
458
+ }
459
+ }
288
460
  const prime = await this.page.primeConversationByFirstCandidate();
289
461
  const candidate = prime?.candidate || {};
290
462
  const candidateBase = {
@@ -444,12 +616,13 @@ export class BossChatApp {
444
616
  }
445
617
 
446
618
  try {
447
- const finalClose =
448
- typeof this.page.closeResumeModalDomOnce === 'function'
449
- ? await this.page.closeResumeModalDomOnce()
450
- : await this.page.closeResumeModal({ maxAttempts: 6, ensureDismiss: true });
619
+ const finalClose = await this.cleanupPanels({
620
+ resumeMaxAttempts: 6,
621
+ detailMaxAttempts: 4,
622
+ ensureDismiss: true,
623
+ });
451
624
  this.logger.log(
452
- `运行收尾关闭简历弹层:closed=${finalClose.closed} | method=${finalClose.method}`,
625
+ `运行收尾关闭弹层:resumeClosed=${finalClose.resume.closed} | resumeMethod=${finalClose.resume.method} | detailClosed=${finalClose.detail.closed} | detailMethod=${finalClose.detail.method}`,
453
626
  );
454
627
  } catch (cleanupError) {
455
628
  this.logger.log(`运行收尾清理告警:${cleanupError?.message || cleanupError}`);
@@ -482,13 +655,17 @@ export class BossChatApp {
482
655
  let modalOpened = false;
483
656
  try {
484
657
  this.logger.log(`候选人开始:${customer.name || '未知'} (${customer.customerKey})`);
485
- const preClose =
486
- typeof this.page.closeResumeModalDomOnce === 'function'
487
- ? await this.page.closeResumeModalDomOnce()
488
- : await this.page.closeResumeModal({ maxAttempts: 4, ensureDismiss: true });
489
- if (preClose.method !== 'already-closed') {
658
+ const preClose = await this.cleanupPanels({
659
+ resumeMaxAttempts: 4,
660
+ detailMaxAttempts: 3,
661
+ ensureDismiss: true,
662
+ });
663
+ if (
664
+ preClose.resume.method !== 'already-closed' ||
665
+ preClose.detail.method !== 'already-closed'
666
+ ) {
490
667
  this.logger.log(
491
- `候选人开始前清理残留弹层:closed=${preClose.closed} | method=${preClose.method}`,
668
+ `候选人开始前清理残留面板:resumeClosed=${preClose.resume.closed} | resumeMethod=${preClose.resume.method} | detailClosed=${preClose.detail.closed} | detailMethod=${preClose.detail.method}`,
492
669
  );
493
670
  }
494
671
  if (!skipCardClick) {
@@ -848,6 +1025,24 @@ export class BossChatApp {
848
1025
  }
849
1026
  }
850
1027
 
1028
+ const finalPanels = await this.cleanupPanels({
1029
+ resumeMaxAttempts: 4,
1030
+ detailMaxAttempts: 4,
1031
+ ensureDismiss: true,
1032
+ });
1033
+ baseResult.artifacts.finalResumeCloseMethod = finalPanels.resume.method;
1034
+ baseResult.artifacts.finalResumeClosed = finalPanels.resume.closed;
1035
+ baseResult.artifacts.finalDetailCloseMethod = finalPanels.detail.method;
1036
+ baseResult.artifacts.finalDetailClosed = finalPanels.detail.closed;
1037
+ if (
1038
+ finalPanels.resume.method !== 'already-closed' ||
1039
+ finalPanels.detail.method !== 'already-closed'
1040
+ ) {
1041
+ this.logger.log(
1042
+ `候选人收尾清理:resumeClosed=${finalPanels.resume.closed} | resumeMethod=${finalPanels.resume.method} | detailClosed=${finalPanels.detail.closed} | detailMethod=${finalPanels.detail.method}`,
1043
+ );
1044
+ }
1045
+
851
1046
  await this.stateStore.record(baseResult.customerKey, baseResult, baseAliases);
852
1047
  return baseResult;
853
1048
  } catch (error) {
@@ -855,16 +1050,19 @@ export class BossChatApp {
855
1050
  throw error;
856
1051
  }
857
1052
 
858
- if (modalOpened) {
1053
+ if (modalOpened || typeof this.page.closeCandidateDetailDomOnce === 'function' || typeof this.page.closeCandidateDetail === 'function') {
859
1054
  try {
860
- const closeResult =
861
- typeof this.page.closeResumeModalDomOnce === 'function'
862
- ? await this.page.closeResumeModalDomOnce()
863
- : await this.page.closeResumeModal({ maxAttempts: 6, ensureDismiss: true });
864
- baseResult.artifacts.resumeCloseMethod = closeResult.method;
865
- baseResult.artifacts.resumeClosed = closeResult.closed;
1055
+ const closeResult = await this.cleanupPanels({
1056
+ resumeMaxAttempts: 6,
1057
+ detailMaxAttempts: 4,
1058
+ ensureDismiss: true,
1059
+ });
1060
+ baseResult.artifacts.resumeCloseMethod = closeResult.resume.method;
1061
+ baseResult.artifacts.resumeClosed = closeResult.resume.closed;
1062
+ baseResult.artifacts.finalDetailCloseMethod = closeResult.detail.method;
1063
+ baseResult.artifacts.finalDetailClosed = closeResult.detail.closed;
866
1064
  this.logger.log(
867
- `异常后关闭简历结果:closed=${closeResult.closed} | method=${closeResult.method} | scope=${closeResult?.finalState?.scopeCount ?? 'n/a'} | iframe=${closeResult?.finalState?.iframeCount ?? 'n/a'} | close=${closeResult?.finalState?.closeCount ?? 'n/a'} | class=${closeResult?.finalState?.topScopeClass || 'n/a'}`,
1065
+ `异常后关闭面板结果:resumeClosed=${closeResult.resume.closed} | resumeMethod=${closeResult.resume.method} | resumeScope=${closeResult?.resume?.finalState?.scopeCount ?? 'n/a'} | resumeIframe=${closeResult?.resume?.finalState?.iframeCount ?? 'n/a'} | resumeClose=${closeResult?.resume?.finalState?.closeCount ?? 'n/a'} | resumeClass=${closeResult?.resume?.finalState?.topScopeClass || 'n/a'} | detailClosed=${closeResult.detail.closed} | detailMethod=${closeResult.detail.method} | detailPanels=${closeResult?.detail?.finalState?.panelCount ?? 'n/a'} | detailClose=${closeResult?.detail?.finalState?.closeCount ?? 'n/a'} | detailClass=${closeResult?.detail?.finalState?.topPanelClass || 'n/a'}`,
868
1066
  );
869
1067
  } catch {}
870
1068
  }
@@ -1011,6 +1011,272 @@ function browserOpenOnlineResume(options = {}) {
1011
1011
  };
1012
1012
  }
1013
1013
 
1014
+ function browserIsCandidateDetailOpen() {
1015
+ const collectSnapshot = () => {
1016
+ const normalize = (value) => String(value || '').replace(/\s+/g, ' ').trim();
1017
+ const isVisible = (el) => {
1018
+ if (!(el instanceof HTMLElement)) return false;
1019
+ const style = getComputedStyle(el);
1020
+ if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') < 0.01) {
1021
+ return false;
1022
+ }
1023
+ const rect = el.getBoundingClientRect();
1024
+ return rect.width > 2 && rect.height > 2;
1025
+ };
1026
+ const rectToJson = (rect) => ({
1027
+ left: rect.left,
1028
+ top: rect.top,
1029
+ width: rect.width,
1030
+ height: rect.height,
1031
+ right: rect.right,
1032
+ bottom: rect.bottom,
1033
+ });
1034
+ const panelSelectors = [
1035
+ '.base-info-single-top-detail',
1036
+ '.resume-detail-wrap',
1037
+ '.geek-card-detail',
1038
+ '.candidate-detail-wrap',
1039
+ '.chat-detail-wrap',
1040
+ ];
1041
+ const closeButtons = Array.from(document.querySelectorAll('.close-btn')).filter(isVisible);
1042
+ const panelEntries = [];
1043
+ const seen = new Set();
1044
+ const pushPanel = (node, source) => {
1045
+ if (!(node instanceof HTMLElement) || !isVisible(node)) return;
1046
+ const rect = node.getBoundingClientRect();
1047
+ if (rect.width < 240 || rect.height < 160) return;
1048
+ const key = `${Math.round(rect.left)}:${Math.round(rect.top)}:${Math.round(rect.width)}:${Math.round(rect.height)}:${normalize(node.className || '')}`;
1049
+ if (seen.has(key)) return;
1050
+ seen.add(key);
1051
+ panelEntries.push({ node, rect, source });
1052
+ };
1053
+
1054
+ for (const selector of panelSelectors) {
1055
+ for (const node of Array.from(document.querySelectorAll(selector))) {
1056
+ pushPanel(node, `selector:${selector}`);
1057
+ }
1058
+ }
1059
+
1060
+ for (const closeButton of closeButtons) {
1061
+ let current = closeButton.parentElement;
1062
+ let depth = 0;
1063
+ while (current instanceof HTMLElement && depth < 10) {
1064
+ pushPanel(current, 'close-ancestor');
1065
+ current = current.parentElement;
1066
+ depth += 1;
1067
+ }
1068
+ }
1069
+
1070
+ const scoredPanels = panelEntries
1071
+ .map((entry) => {
1072
+ const classText = normalize(entry.node.className || '').toLowerCase();
1073
+ const text = normalize(entry.node.textContent || '').slice(0, 240).toLowerCase();
1074
+ const containsClose = closeButtons.some((button) => entry.node.contains(button));
1075
+ const anchoredRight =
1076
+ entry.rect.left >= window.innerWidth * 0.4 ||
1077
+ entry.rect.right >= window.innerWidth * 0.72;
1078
+ const hasKnownDetailClass =
1079
+ classText.includes('base-info-single-top-detail') ||
1080
+ classText.includes('resume-detail-wrap') ||
1081
+ classText.includes('candidate-detail') ||
1082
+ classText.includes('chat-detail') ||
1083
+ classText.includes('geek-card-detail');
1084
+ const hasDetailHint =
1085
+ text.includes('在线简历') ||
1086
+ text.includes('附件简历') ||
1087
+ text.includes('牛人分析器') ||
1088
+ text.includes('活跃');
1089
+
1090
+ let score = 0;
1091
+ if (containsClose) score += 220;
1092
+ if (anchoredRight) score += 140;
1093
+ if (hasKnownDetailClass) score += 160;
1094
+ if (hasDetailHint) score += 80;
1095
+ if (entry.source === 'close-ancestor') score += 40;
1096
+ score += Math.min(180, Math.floor((entry.rect.width * entry.rect.height) / 12000));
1097
+
1098
+ return {
1099
+ ...entry,
1100
+ score,
1101
+ };
1102
+ })
1103
+ .sort((a, b) => b.score - a.score);
1104
+
1105
+ const topPanel = scoredPanels[0] || null;
1106
+ const topPanelNode = topPanel?.node || null;
1107
+ const closeButton =
1108
+ closeButtons.find((button) => topPanelNode instanceof HTMLElement && topPanelNode.contains(button)) ||
1109
+ closeButtons[0] ||
1110
+ null;
1111
+
1112
+ return {
1113
+ open: Boolean(topPanel || closeButton),
1114
+ panelCount: scoredPanels.length,
1115
+ closeCount: closeButtons.length,
1116
+ topPanelClass: normalize(topPanelNode?.className || ''),
1117
+ topPanelScore: Number(topPanel?.score || 0),
1118
+ panelRect: topPanel ? rectToJson(topPanel.rect) : null,
1119
+ closeRect: closeButton ? rectToJson(closeButton.getBoundingClientRect()) : null,
1120
+ };
1121
+ };
1122
+
1123
+ return collectSnapshot();
1124
+ }
1125
+
1126
+ function browserCloseCandidateDetailDomOnce() {
1127
+ const collectSnapshot = () => {
1128
+ const normalize = (value) => String(value || '').replace(/\s+/g, ' ').trim();
1129
+ const isVisible = (el) => {
1130
+ if (!(el instanceof HTMLElement)) return false;
1131
+ const style = getComputedStyle(el);
1132
+ if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') < 0.01) {
1133
+ return false;
1134
+ }
1135
+ const rect = el.getBoundingClientRect();
1136
+ return rect.width > 2 && rect.height > 2;
1137
+ };
1138
+ const rectToJson = (rect) => ({
1139
+ left: rect.left,
1140
+ top: rect.top,
1141
+ width: rect.width,
1142
+ height: rect.height,
1143
+ right: rect.right,
1144
+ bottom: rect.bottom,
1145
+ });
1146
+ const panelSelectors = [
1147
+ '.base-info-single-top-detail',
1148
+ '.resume-detail-wrap',
1149
+ '.geek-card-detail',
1150
+ '.candidate-detail-wrap',
1151
+ '.chat-detail-wrap',
1152
+ ];
1153
+ const closeButtons = Array.from(document.querySelectorAll('.close-btn')).filter(isVisible);
1154
+ const panelEntries = [];
1155
+ const seen = new Set();
1156
+ const pushPanel = (node, source) => {
1157
+ if (!(node instanceof HTMLElement) || !isVisible(node)) return;
1158
+ const rect = node.getBoundingClientRect();
1159
+ if (rect.width < 240 || rect.height < 160) return;
1160
+ const key = `${Math.round(rect.left)}:${Math.round(rect.top)}:${Math.round(rect.width)}:${Math.round(rect.height)}:${normalize(node.className || '')}`;
1161
+ if (seen.has(key)) return;
1162
+ seen.add(key);
1163
+ panelEntries.push({ node, rect, source });
1164
+ };
1165
+
1166
+ for (const selector of panelSelectors) {
1167
+ for (const node of Array.from(document.querySelectorAll(selector))) {
1168
+ pushPanel(node, `selector:${selector}`);
1169
+ }
1170
+ }
1171
+
1172
+ for (const closeButton of closeButtons) {
1173
+ let current = closeButton.parentElement;
1174
+ let depth = 0;
1175
+ while (current instanceof HTMLElement && depth < 10) {
1176
+ pushPanel(current, 'close-ancestor');
1177
+ current = current.parentElement;
1178
+ depth += 1;
1179
+ }
1180
+ }
1181
+
1182
+ const scoredPanels = panelEntries
1183
+ .map((entry) => {
1184
+ const classText = normalize(entry.node.className || '').toLowerCase();
1185
+ const text = normalize(entry.node.textContent || '').slice(0, 240).toLowerCase();
1186
+ const containsClose = closeButtons.some((button) => entry.node.contains(button));
1187
+ const anchoredRight =
1188
+ entry.rect.left >= window.innerWidth * 0.4 ||
1189
+ entry.rect.right >= window.innerWidth * 0.72;
1190
+ const hasKnownDetailClass =
1191
+ classText.includes('base-info-single-top-detail') ||
1192
+ classText.includes('resume-detail-wrap') ||
1193
+ classText.includes('candidate-detail') ||
1194
+ classText.includes('chat-detail') ||
1195
+ classText.includes('geek-card-detail');
1196
+ const hasDetailHint =
1197
+ text.includes('在线简历') ||
1198
+ text.includes('附件简历') ||
1199
+ text.includes('牛人分析器') ||
1200
+ text.includes('活跃');
1201
+
1202
+ let score = 0;
1203
+ if (containsClose) score += 220;
1204
+ if (anchoredRight) score += 140;
1205
+ if (hasKnownDetailClass) score += 160;
1206
+ if (hasDetailHint) score += 80;
1207
+ if (entry.source === 'close-ancestor') score += 40;
1208
+ score += Math.min(180, Math.floor((entry.rect.width * entry.rect.height) / 12000));
1209
+
1210
+ return {
1211
+ ...entry,
1212
+ score,
1213
+ };
1214
+ })
1215
+ .sort((a, b) => b.score - a.score);
1216
+
1217
+ const topPanel = scoredPanels[0] || null;
1218
+ const topPanelNode = topPanel?.node || null;
1219
+ const closeButton =
1220
+ closeButtons.find((button) => topPanelNode instanceof HTMLElement && topPanelNode.contains(button)) ||
1221
+ closeButtons[0] ||
1222
+ null;
1223
+
1224
+ return {
1225
+ open: Boolean(topPanel || closeButton),
1226
+ panelCount: scoredPanels.length,
1227
+ closeCount: closeButtons.length,
1228
+ topPanelClass: normalize(topPanelNode?.className || ''),
1229
+ topPanelScore: Number(topPanel?.score || 0),
1230
+ panelRect: topPanel ? rectToJson(topPanel.rect) : null,
1231
+ closeRect: closeButton ? rectToJson(closeButton.getBoundingClientRect()) : null,
1232
+ closeButton,
1233
+ };
1234
+ };
1235
+ const serializeSnapshot = (snapshot = {}) => ({
1236
+ open: Boolean(snapshot?.open),
1237
+ panelCount: Number(snapshot?.panelCount || 0),
1238
+ closeCount: Number(snapshot?.closeCount || 0),
1239
+ topPanelClass: String(snapshot?.topPanelClass || ''),
1240
+ topPanelScore: Number(snapshot?.topPanelScore || 0),
1241
+ panelRect: snapshot?.panelRect || null,
1242
+ closeRect: snapshot?.closeRect || null,
1243
+ });
1244
+
1245
+ const snapshot = collectSnapshot();
1246
+ if (!snapshot?.open || !(snapshot.closeButton instanceof HTMLElement)) {
1247
+ return {
1248
+ ok: false,
1249
+ error: 'CANDIDATE_DETAIL_CLOSE_BUTTON_NOT_FOUND',
1250
+ state: serializeSnapshot(snapshot),
1251
+ };
1252
+ }
1253
+
1254
+ try {
1255
+ snapshot.closeButton.click();
1256
+ const rect = snapshot.closeButton.getBoundingClientRect();
1257
+ return {
1258
+ ok: true,
1259
+ selector: '.close-btn',
1260
+ method: 'dom-click-once',
1261
+ rect: {
1262
+ left: rect.left,
1263
+ top: rect.top,
1264
+ width: rect.width,
1265
+ height: rect.height,
1266
+ right: rect.right,
1267
+ bottom: rect.bottom,
1268
+ },
1269
+ state: serializeSnapshot(snapshot),
1270
+ };
1271
+ } catch (error) {
1272
+ return {
1273
+ ok: false,
1274
+ error: `CANDIDATE_DETAIL_DOM_CLOSE_FAILED:${error?.message || error}`,
1275
+ state: serializeSnapshot(snapshot),
1276
+ };
1277
+ }
1278
+ }
1279
+
1014
1280
  function browserCloseResumeModalDomOnce() {
1015
1281
  const isVisible = (el) => {
1016
1282
  if (!(el instanceof HTMLElement)) return false;
@@ -2596,6 +2862,160 @@ export class BossChatPage {
2596
2862
  };
2597
2863
  }
2598
2864
 
2865
+ async isCandidateDetailOpen() {
2866
+ const result = await this.chromeClient.callFunction(browserIsCandidateDetailOpen);
2867
+ return Boolean(result?.open);
2868
+ }
2869
+
2870
+ async getCandidateDetailState() {
2871
+ const result = await this.chromeClient.callFunction(browserIsCandidateDetailOpen);
2872
+ return {
2873
+ open: Boolean(result?.open),
2874
+ panelCount: Number(result?.panelCount || 0),
2875
+ closeCount: Number(result?.closeCount || 0),
2876
+ topPanelClass: String(result?.topPanelClass || ''),
2877
+ topPanelScore: Number(result?.topPanelScore || 0),
2878
+ panelRect: result?.panelRect || null,
2879
+ closeRect: result?.closeRect || null,
2880
+ };
2881
+ }
2882
+
2883
+ async closeCandidateDetailDomOnce() {
2884
+ const stateBefore = await this.getCandidateDetailState();
2885
+ const drawerOpen = (state) =>
2886
+ Boolean(state?.open) ||
2887
+ Number(state?.panelCount || 0) > 0 ||
2888
+ Number(state?.closeCount || 0) > 0;
2889
+ const openBefore = drawerOpen(stateBefore);
2890
+ if (!openBefore) {
2891
+ return {
2892
+ closed: true,
2893
+ method: 'already-closed',
2894
+ finalState: stateBefore,
2895
+ };
2896
+ }
2897
+
2898
+ const result = await this.chromeClient.callFunction(browserCloseCandidateDetailDomOnce);
2899
+ if (!result?.ok) {
2900
+ const finalState = await this.getCandidateDetailState();
2901
+ return {
2902
+ closed: false,
2903
+ method: `dom-close-miss:${result?.error || 'unknown'}`,
2904
+ finalState,
2905
+ };
2906
+ }
2907
+
2908
+ let finalState = await this.getCandidateDetailState();
2909
+ let openAfter = drawerOpen(finalState);
2910
+ for (let attempt = 0; openAfter && attempt < 8; attempt += 1) {
2911
+ await new Promise((resolve) => setTimeout(resolve, 220));
2912
+ finalState = await this.getCandidateDetailState();
2913
+ openAfter = drawerOpen(finalState);
2914
+ }
2915
+ return {
2916
+ closed: !openAfter,
2917
+ method: `dom-close-once:${result.selector || '.close-btn'}`,
2918
+ finalState,
2919
+ };
2920
+ }
2921
+
2922
+ async closeCandidateDetail({ maxAttempts = 4, ensureDismiss = false } = {}) {
2923
+ const drawerOpen = (state) =>
2924
+ Boolean(state?.open) ||
2925
+ Number(state?.panelCount || 0) > 0 ||
2926
+ Number(state?.closeCount || 0) > 0;
2927
+ const methods = [];
2928
+ for (let index = 0; index < maxAttempts; index += 1) {
2929
+ const state = await this.getCandidateDetailState();
2930
+ if (!drawerOpen(state) && !ensureDismiss) {
2931
+ return {
2932
+ closed: true,
2933
+ method: methods.join('+') || 'already-closed',
2934
+ finalState: state,
2935
+ };
2936
+ }
2937
+
2938
+ const selectorResult = await this.chromeClient.callFunction(browserCloseCandidateDetailDomOnce);
2939
+ if (selectorResult?.ok) {
2940
+ methods.push(`selector:${selectorResult.selector || '.close-btn'}`);
2941
+ } else {
2942
+ methods.push(`selector-miss:${selectorResult?.error || 'unknown'}`);
2943
+ }
2944
+ await new Promise((resolve) => setTimeout(resolve, 220));
2945
+
2946
+ let midState = await this.getCandidateDetailState();
2947
+ if (!drawerOpen(midState)) {
2948
+ return {
2949
+ closed: true,
2950
+ method: methods.join('+'),
2951
+ finalState: midState,
2952
+ };
2953
+ }
2954
+
2955
+ if (midState?.panelRect) {
2956
+ await this.clickRect(midState.panelRect);
2957
+ methods.push('focus-panel');
2958
+ await new Promise((resolve) => setTimeout(resolve, 160));
2959
+ } else if (midState?.closeRect) {
2960
+ await this.clickRect(midState.closeRect);
2961
+ methods.push('focus-close');
2962
+ await new Promise((resolve) => setTimeout(resolve, 160));
2963
+ }
2964
+
2965
+ await this.chromeClient.pressEscape();
2966
+ methods.push('escape');
2967
+ await new Promise((resolve) => setTimeout(resolve, 220));
2968
+
2969
+ midState = await this.getCandidateDetailState();
2970
+ if (!drawerOpen(midState)) {
2971
+ return {
2972
+ closed: true,
2973
+ method: methods.join('+'),
2974
+ finalState: midState,
2975
+ };
2976
+ }
2977
+
2978
+ if (midState?.closeRect) {
2979
+ await this.clickRect(midState.closeRect);
2980
+ methods.push('rect-close');
2981
+ await new Promise((resolve) => setTimeout(resolve, 220));
2982
+ midState = await this.getCandidateDetailState();
2983
+ if (!drawerOpen(midState)) {
2984
+ return {
2985
+ closed: true,
2986
+ method: methods.join('+'),
2987
+ finalState: midState,
2988
+ };
2989
+ }
2990
+ }
2991
+
2992
+ if (ensureDismiss && index >= 1) {
2993
+ const finalSweep = await this.getCandidateDetailState();
2994
+ if (!drawerOpen(finalSweep)) {
2995
+ return {
2996
+ closed: true,
2997
+ method: methods.join('+'),
2998
+ finalState: finalSweep,
2999
+ };
3000
+ }
3001
+ }
3002
+ }
3003
+
3004
+ const finalState = await this.getCandidateDetailState();
3005
+ if (!drawerOpen(finalState)) {
3006
+ return {
3007
+ closed: true,
3008
+ method: methods.join('+') || 'fallback',
3009
+ finalState,
3010
+ };
3011
+ }
3012
+ return {
3013
+ closed: false,
3014
+ method: methods.join('+') || 'failed',
3015
+ finalState,
3016
+ };
3017
+ }
3018
+
2599
3019
  async waitForResumeModalOpen(options = {}) {
2600
3020
  const maxAttempts = options.maxAttempts || 30;
2601
3021
  const delayMs = options.delayMs || 300;