@reconcrap/boss-recommend-mcp 1.3.28 → 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.28",
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({
@@ -1132,6 +1232,122 @@ async function testBossChatAppShouldResetPrimaryChatLabelBeforeInitialPrime() {
1132
1232
  assert.equal(summary.skipped, 1);
1133
1233
  }
1134
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
+
1135
1351
  async function testBossChatAppShouldRestoreListContextAfterRecovery() {
1136
1352
  const calls = [];
1137
1353
  let primeCount = 0;
@@ -1509,6 +1725,7 @@ async function main() {
1509
1725
  await testBossChatPrepareShouldRetryWhenChatPageIsNotReady();
1510
1726
  await testBossChatPageShouldTreatBlankChatShellAsOnChatPage();
1511
1727
  await testBossChatRecoverToChatIndexShouldForceNavigateAndWaitForCompleteLoad();
1728
+ await testBossChatPageShouldFallbackToEscapeWhenClosingCandidateDetail();
1512
1729
  await testBossChatMcpToolsShouldValidateAndRoute();
1513
1730
  await testBossChatCliShouldSupportRunAndFollowUpParsing();
1514
1731
  await testVendorBossChatCliShouldWaitForHydratedChatShell();
@@ -1519,6 +1736,7 @@ async function main() {
1519
1736
  await testBossChatLlmTextChunkFallbackShouldWork();
1520
1737
  await testBossChatLlmShouldApplyThinkingDefaultsAndOverrides();
1521
1738
  await testBossChatAppShouldResetPrimaryChatLabelBeforeInitialPrime();
1739
+ await testBossChatAppShouldCloseCandidateDetailDuringRunCleanup();
1522
1740
  await testBossChatAppShouldRestoreListContextAfterRecovery();
1523
1741
  await testBossChatAppShouldWaitForCandidateListBeforePriming();
1524
1742
  await testBossChatAppShouldPersistEvidenceArtifacts();
@@ -214,6 +214,46 @@ export class BossChatApp {
214
214
  };
215
215
  }
216
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
+
217
257
  async run(profile) {
218
258
  const startedAt = new Date().toISOString();
219
259
  const runId = runToken(new Date());
@@ -576,12 +616,13 @@ export class BossChatApp {
576
616
  }
577
617
 
578
618
  try {
579
- const finalClose =
580
- typeof this.page.closeResumeModalDomOnce === 'function'
581
- ? await this.page.closeResumeModalDomOnce()
582
- : await this.page.closeResumeModal({ maxAttempts: 6, ensureDismiss: true });
619
+ const finalClose = await this.cleanupPanels({
620
+ resumeMaxAttempts: 6,
621
+ detailMaxAttempts: 4,
622
+ ensureDismiss: true,
623
+ });
583
624
  this.logger.log(
584
- `运行收尾关闭简历弹层:closed=${finalClose.closed} | method=${finalClose.method}`,
625
+ `运行收尾关闭弹层:resumeClosed=${finalClose.resume.closed} | resumeMethod=${finalClose.resume.method} | detailClosed=${finalClose.detail.closed} | detailMethod=${finalClose.detail.method}`,
585
626
  );
586
627
  } catch (cleanupError) {
587
628
  this.logger.log(`运行收尾清理告警:${cleanupError?.message || cleanupError}`);
@@ -614,13 +655,17 @@ export class BossChatApp {
614
655
  let modalOpened = false;
615
656
  try {
616
657
  this.logger.log(`候选人开始:${customer.name || '未知'} (${customer.customerKey})`);
617
- const preClose =
618
- typeof this.page.closeResumeModalDomOnce === 'function'
619
- ? await this.page.closeResumeModalDomOnce()
620
- : await this.page.closeResumeModal({ maxAttempts: 4, ensureDismiss: true });
621
- 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
+ ) {
622
667
  this.logger.log(
623
- `候选人开始前清理残留弹层:closed=${preClose.closed} | method=${preClose.method}`,
668
+ `候选人开始前清理残留面板:resumeClosed=${preClose.resume.closed} | resumeMethod=${preClose.resume.method} | detailClosed=${preClose.detail.closed} | detailMethod=${preClose.detail.method}`,
624
669
  );
625
670
  }
626
671
  if (!skipCardClick) {
@@ -980,6 +1025,24 @@ export class BossChatApp {
980
1025
  }
981
1026
  }
982
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
+
983
1046
  await this.stateStore.record(baseResult.customerKey, baseResult, baseAliases);
984
1047
  return baseResult;
985
1048
  } catch (error) {
@@ -987,16 +1050,19 @@ export class BossChatApp {
987
1050
  throw error;
988
1051
  }
989
1052
 
990
- if (modalOpened) {
1053
+ if (modalOpened || typeof this.page.closeCandidateDetailDomOnce === 'function' || typeof this.page.closeCandidateDetail === 'function') {
991
1054
  try {
992
- const closeResult =
993
- typeof this.page.closeResumeModalDomOnce === 'function'
994
- ? await this.page.closeResumeModalDomOnce()
995
- : await this.page.closeResumeModal({ maxAttempts: 6, ensureDismiss: true });
996
- baseResult.artifacts.resumeCloseMethod = closeResult.method;
997
- 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;
998
1064
  this.logger.log(
999
- `异常后关闭简历结果: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'}`,
1000
1066
  );
1001
1067
  } catch {}
1002
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;