@reconcrap/boss-recommend-mcp 1.3.28 → 1.3.30

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/README.md CHANGED
@@ -68,6 +68,7 @@ MCP 工具:
68
68
  - 在真正开始 search/screen 前,会进行最后一轮全参数总确认(岗位 + 全部筛选参数 + criteria + target_count + post_action + max_greet_count)
69
69
  - npm 全局安装后会自动执行 install:生成 skill、导出 MCP 模板,并自动尝试写入已检测到的外部 agent MCP 配置(含 Trae / trae-cn / Cursor / Claude / OpenClaw)
70
70
  - npm / npx 安装后会自动初始化 `screening-config.json` 模板(优先写入 workspace 的 `config/`,不可写时回退到用户目录)
71
+ - npm 安装流程会预创建运行目录(跨平台):`~/.boss-recommend-mcp`、`~/.boss-recommend-mcp/runs`、`<workspace>/.boss-chat` 及其 `logs/runs/profiles/reports/artifacts`
71
72
  - `post_action` 必须在每次完整运行开始时确认一次
72
73
  - `target_count` 会在每次运行开始时询问一次(可留空,不设上限)
73
74
  - 当 `post_action=greet` 时,必须在运行开始时确认 `max_greet_count`
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.30",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
package/src/cli.js CHANGED
@@ -742,6 +742,70 @@ function ensureUserConfig(options = {}) {
742
742
  throw lastError || new Error("No writable target for screening-config.json");
743
743
  }
744
744
 
745
+ function getBossChatDataDir(workspaceRoot) {
746
+ return path.join(path.resolve(String(workspaceRoot || process.cwd())), ".boss-chat");
747
+ }
748
+
749
+ function collectRuntimeDirectories(options = {}) {
750
+ const workspaceRoot = getWorkspaceRoot(options);
751
+ const stateHome = getStateHome();
752
+ const bossChatRoot = getBossChatDataDir(workspaceRoot);
753
+ const recommendRuntimeDirs = [
754
+ stateHome,
755
+ path.join(stateHome, "runs")
756
+ ];
757
+ const bossChatRuntimeDirs = [
758
+ bossChatRoot,
759
+ path.join(bossChatRoot, "logs"),
760
+ path.join(bossChatRoot, "runs"),
761
+ path.join(bossChatRoot, "profiles"),
762
+ path.join(bossChatRoot, "reports"),
763
+ path.join(bossChatRoot, "artifacts")
764
+ ];
765
+ return {
766
+ workspaceRoot,
767
+ stateHome,
768
+ bossChatRoot,
769
+ directories: dedupePaths([
770
+ ...recommendRuntimeDirs,
771
+ ...bossChatRuntimeDirs
772
+ ]).filter(Boolean)
773
+ };
774
+ }
775
+
776
+ function ensureRuntimeDirectories(options = {}) {
777
+ const { workspaceRoot, stateHome, bossChatRoot, directories } = collectRuntimeDirectories(options);
778
+ const created = [];
779
+ const existed = [];
780
+ const failed = [];
781
+
782
+ for (const directory of directories) {
783
+ try {
784
+ const existedBefore = fs.existsSync(directory);
785
+ ensureDir(directory);
786
+ if (existedBefore) {
787
+ existed.push(directory);
788
+ } else {
789
+ created.push(directory);
790
+ }
791
+ } catch (error) {
792
+ failed.push({
793
+ path: directory,
794
+ message: error?.message || String(error)
795
+ });
796
+ }
797
+ }
798
+
799
+ return {
800
+ workspaceRoot,
801
+ stateHome,
802
+ bossChatRoot,
803
+ created,
804
+ existed,
805
+ failed
806
+ };
807
+ }
808
+
745
809
  function readJsonObjectFile(filePath) {
746
810
  const raw = fs.readFileSync(filePath, "utf8");
747
811
  const parsed = JSON.parse(raw);
@@ -1333,11 +1397,22 @@ function printMcpConfig(options = {}) {
1333
1397
  }
1334
1398
 
1335
1399
  function installAll(options = {}) {
1400
+ const runtimeDirsResult = ensureRuntimeDirectories(options);
1336
1401
  const skillResults = installSkill();
1337
1402
  const configResult = ensureUserConfig(options);
1338
1403
  const mcpTemplateResult = writeMcpConfigFiles({ client: "all" });
1339
1404
  const externalMcpResult = installExternalMcpConfigs(options);
1340
1405
  const externalSkillResult = mirrorSkillToExternalDirs(options);
1406
+ console.log(
1407
+ `Runtime directories prepared: created=${runtimeDirsResult.created.length}, existing=${runtimeDirsResult.existed.length}, failed=${runtimeDirsResult.failed.length}`
1408
+ );
1409
+ console.log(`- recommend runtime: ${runtimeDirsResult.stateHome}`);
1410
+ console.log(`- boss-chat runtime: ${runtimeDirsResult.bossChatRoot}`);
1411
+ if (runtimeDirsResult.failed.length > 0) {
1412
+ for (const item of runtimeDirsResult.failed) {
1413
+ console.warn(`Runtime dir warning: ${item.path} -> ${item.message}`);
1414
+ }
1415
+ }
1341
1416
  console.log(`Bundled skills installed: ${skillResults.length}`);
1342
1417
  for (const item of skillResults) {
1343
1418
  console.log(`- ${item.skill}: ${item.targetDir}`);
@@ -1546,7 +1621,18 @@ export async function runCli(argv = process.argv) {
1546
1621
  }
1547
1622
  break;
1548
1623
  case "init-config": {
1624
+ const runtimeDirsResult = ensureRuntimeDirectories(options);
1549
1625
  const result = ensureUserConfig(options);
1626
+ console.log(
1627
+ `Runtime directories prepared: created=${runtimeDirsResult.created.length}, existing=${runtimeDirsResult.existed.length}, failed=${runtimeDirsResult.failed.length}`
1628
+ );
1629
+ console.log(`- recommend runtime: ${runtimeDirsResult.stateHome}`);
1630
+ console.log(`- boss-chat runtime: ${runtimeDirsResult.bossChatRoot}`);
1631
+ if (runtimeDirsResult.failed.length > 0) {
1632
+ for (const item of runtimeDirsResult.failed) {
1633
+ console.warn(`Runtime dir warning: ${item.path} -> ${item.message}`);
1634
+ }
1635
+ }
1550
1636
  console.log(result.created ? `Config template created at: ${result.path}` : `Config already exists at: ${result.path}`);
1551
1637
  if (Array.isArray(result.patched_keys) && result.patched_keys.length > 0) {
1552
1638
  console.log(`Config patched missing defaults: ${result.patched_keys.join(", ")}`);
@@ -1635,6 +1721,7 @@ export const __testables = {
1635
1721
  getRunFollowUp,
1636
1722
  installSkill,
1637
1723
  isInstalledPackageRoot,
1724
+ ensureRuntimeDirectories,
1638
1725
  runBossChatCliCommand,
1639
1726
  runPipelineOnce
1640
1727
  };
@@ -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;