@reconcrap/boss-recommend-mcp 1.3.10 → 1.3.12

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.10",
3
+ "version": "1.3.12",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
package/src/boss-chat.js CHANGED
@@ -10,6 +10,8 @@ const currentFilePath = fileURLToPath(import.meta.url);
10
10
  const packageRoot = path.resolve(path.dirname(currentFilePath), "..");
11
11
  const VENDORED_BOSS_CHAT_DIR = path.join(packageRoot, "vendor", "boss-chat-cli");
12
12
  const DEFAULT_BOSS_CHAT_POLL_MS = 1500;
13
+ const PREPARE_BOSS_CHAT_MAX_ATTEMPTS = 3;
14
+ const PREPARE_BOSS_CHAT_RETRY_DELAY_MS = 1200;
13
15
  const BOSS_CHAT_TERMINAL_STATES = new Set(["completed", "failed", "canceled"]);
14
16
  const CHAT_REQUIRED_FIELDS = ["job", "start_from", "target_count", "criteria"];
15
17
  export const TARGET_COUNT_ACCEPTED_EXAMPLES = ["all", -1, 20, "全部候选人"];
@@ -583,7 +585,14 @@ export async function startBossChatRun({ workspaceRoot, input = {} }) {
583
585
  }
584
586
 
585
587
  export async function prepareBossChatRun({ workspaceRoot, input = {} }) {
586
- const payload = (await spawnBossChatCli({ workspaceRoot, command: "prepare-run", input })).payload;
588
+ let payload = null;
589
+ for (let attempt = 1; attempt <= PREPARE_BOSS_CHAT_MAX_ATTEMPTS; attempt += 1) {
590
+ payload = (await spawnBossChatCli({ workspaceRoot, command: "prepare-run", input })).payload;
591
+ if (payload?.status !== "FAILED") break;
592
+ if (attempt >= PREPARE_BOSS_CHAT_MAX_ATTEMPTS) break;
593
+ await sleep(PREPARE_BOSS_CHAT_RETRY_DELAY_MS);
594
+ }
595
+
587
596
  if (payload?.status !== "NEED_INPUT") return payload;
588
597
 
589
598
  const missingFields = getMissingBossChatStartFields(input);
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { mkdir } from "node:fs/promises";
5
6
 
6
7
  import {
7
8
  cancelBossChatRun,
@@ -14,6 +15,8 @@ import {
14
15
  } from "./boss-chat.js";
15
16
  import { __testables as cliTestables } from "./cli.js";
16
17
  import { __testables as indexTestables } from "./index.js";
18
+ import { BossChatApp } from "../vendor/boss-chat-cli/src/app.js";
19
+ import { LlmClient, parseLlmJson } from "../vendor/boss-chat-cli/src/services/llm.js";
17
20
 
18
21
  const { handleRequest } = indexTestables;
19
22
 
@@ -66,6 +69,11 @@ function createBossChatTestWorkspace() {
66
69
  "const raw = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf8') : '{}';",
67
70
  "const state = JSON.parse(raw || '{}');",
68
71
  "state.counter = Number.isInteger(state.counter) ? state.counter : 0;",
72
+ "state.prepare_calls = Number.isInteger(state.prepare_calls) ? state.prepare_calls : 0;",
73
+ "if (!Number.isInteger(state.prepare_fail_budget)) {",
74
+ " const configured = Number.parseInt(process.env.BOSS_CHAT_STUB_PREPARE_FAILS || '0', 10);",
75
+ " state.prepare_fail_budget = Number.isFinite(configured) && configured > 0 ? configured : 0;",
76
+ "}",
69
77
  "state.runs = state.runs && typeof state.runs === 'object' ? state.runs : {};",
70
78
  "state.get_calls = state.get_calls && typeof state.get_calls === 'object' ? state.get_calls : {};",
71
79
  "const argv = process.argv.slice(2);",
@@ -88,6 +96,12 @@ function createBossChatTestWorkspace() {
88
96
  " process.stdout.write(`${JSON.stringify(payload)}\\n`);",
89
97
  "}",
90
98
  "if (command === 'prepare-run') {",
99
+ " state.prepare_calls += 1;",
100
+ " if (state.prepare_fail_budget > 0) {",
101
+ " state.prepare_fail_budget -= 1;",
102
+ " saveAndPrint({ status: 'FAILED', error: { code: 'CHAT_PAGE_NOT_READY', message: 'chat page is still loading' } });",
103
+ " process.exit(1);",
104
+ " }",
91
105
  " state.last_prepare_args = options;",
92
106
  " saveAndPrint({",
93
107
  " status: 'NEED_INPUT',",
@@ -356,6 +370,29 @@ async function testBossChatAdapterShouldResolveSharedConfigAndInvokeLocalCli() {
356
370
  });
357
371
  }
358
372
 
373
+ async function testBossChatPrepareShouldRetryWhenChatPageIsNotReady() {
374
+ await withBossChatWorkspace(async (workspaceRoot) => {
375
+ const previousPrepareFails = process.env.BOSS_CHAT_STUB_PREPARE_FAILS;
376
+ process.env.BOSS_CHAT_STUB_PREPARE_FAILS = "2";
377
+ try {
378
+ const prepared = await prepareBossChatRun({
379
+ workspaceRoot,
380
+ input: {}
381
+ });
382
+ assert.equal(prepared.status, "NEED_INPUT");
383
+ const state = readStubState(workspaceRoot);
384
+ assert.equal(state.prepare_calls, 3);
385
+ assert.equal(state.prepare_fail_budget, 0);
386
+ } finally {
387
+ if (previousPrepareFails === undefined) {
388
+ delete process.env.BOSS_CHAT_STUB_PREPARE_FAILS;
389
+ } else {
390
+ process.env.BOSS_CHAT_STUB_PREPARE_FAILS = previousPrepareFails;
391
+ }
392
+ }
393
+ });
394
+ }
395
+
359
396
  async function testBossChatMcpToolsShouldValidateAndRoute() {
360
397
  await withBossChatWorkspace(async (workspaceRoot) => {
361
398
  const toolsResponse = await handleRequest({
@@ -550,10 +587,266 @@ async function testBossChatCliShouldSupportRunAndFollowUpParsing() {
550
587
  });
551
588
  }
552
589
 
590
+ function testBossChatLlmEvidenceGateShouldDemoteMissingEvidence() {
591
+ const parsed = parseLlmJson(
592
+ JSON.stringify({
593
+ passed: true,
594
+ reason: "命中标准",
595
+ summary: "命中",
596
+ evidence: [],
597
+ }),
598
+ {
599
+ evidenceCorpus: "南京大学 机器学习 项目经历",
600
+ },
601
+ );
602
+ assert.equal(parsed.rawPassed, true);
603
+ assert.equal(parsed.passed, false);
604
+ assert.equal(parsed.evidenceGateDemoted, true);
605
+ assert.equal(parsed.evidenceMatchedCount, 0);
606
+ }
607
+
608
+ function testBossChatLlmEvidenceGateShouldDemoteUnmatchedEvidence() {
609
+ const parsed = parseLlmJson(
610
+ JSON.stringify({
611
+ passed: true,
612
+ reason: "命中标准",
613
+ summary: "命中",
614
+ evidence: ["十年金融风控投研经验"],
615
+ }),
616
+ {
617
+ evidenceCorpus: "南京大学 机器学习 项目经历",
618
+ },
619
+ );
620
+ assert.equal(parsed.rawPassed, true);
621
+ assert.equal(parsed.passed, false);
622
+ assert.equal(parsed.evidenceGateDemoted, true);
623
+ assert.equal(parsed.evidenceRawCount, 1);
624
+ assert.equal(parsed.evidenceMatchedCount, 0);
625
+ }
626
+
627
+ async function testBossChatLlmTextChunkFallbackShouldWork() {
628
+ const originalChunkSize = process.env.BOSS_CHAT_TEXT_CHUNK_SIZE_CHARS;
629
+ const originalChunkOverlap = process.env.BOSS_CHAT_TEXT_CHUNK_OVERLAP_CHARS;
630
+ const originalMaxChunks = process.env.BOSS_CHAT_TEXT_MAX_CHUNKS;
631
+ process.env.BOSS_CHAT_TEXT_CHUNK_SIZE_CHARS = "1000";
632
+ process.env.BOSS_CHAT_TEXT_CHUNK_OVERLAP_CHARS = "120";
633
+ process.env.BOSS_CHAT_TEXT_MAX_CHUNKS = "6";
634
+ try {
635
+ class FakeChunkFallbackClient extends LlmClient {
636
+ constructor() {
637
+ super({
638
+ baseUrl: "https://api.example.com/v1",
639
+ apiKey: "sk-test",
640
+ model: "gpt-test",
641
+ });
642
+ this.calls = [];
643
+ }
644
+
645
+ async requestByPreference(payload) {
646
+ this.calls.push(payload);
647
+ if (payload.chunkTotal === 1 && !payload.imageDataUrl) {
648
+ const error = new Error("maximum context length exceeded");
649
+ throw error;
650
+ }
651
+ if (payload.chunkTotal > 1) {
652
+ if (payload.chunkIndex === 2) {
653
+ return {
654
+ passed: true,
655
+ rawPassed: true,
656
+ reason: "命中分段证据",
657
+ summary: "命中",
658
+ evidence: ["PASS_MARKER_ABC"],
659
+ evidenceRawCount: 1,
660
+ evidenceMatchedCount: 1,
661
+ evidenceGateDemoted: false,
662
+ chunkIndex: payload.chunkIndex,
663
+ chunkTotal: payload.chunkTotal,
664
+ };
665
+ }
666
+ return {
667
+ passed: false,
668
+ rawPassed: false,
669
+ reason: "本段证据不足",
670
+ summary: "不足",
671
+ evidence: [],
672
+ evidenceRawCount: 0,
673
+ evidenceMatchedCount: 0,
674
+ evidenceGateDemoted: false,
675
+ chunkIndex: payload.chunkIndex,
676
+ chunkTotal: payload.chunkTotal,
677
+ };
678
+ }
679
+ return {
680
+ passed: false,
681
+ rawPassed: false,
682
+ reason: "unexpected",
683
+ summary: "unexpected",
684
+ evidence: [],
685
+ evidenceRawCount: 0,
686
+ evidenceMatchedCount: 0,
687
+ evidenceGateDemoted: false,
688
+ chunkIndex: 1,
689
+ chunkTotal: 1,
690
+ };
691
+ }
692
+ }
693
+
694
+ const client = new FakeChunkFallbackClient();
695
+ const longResume = `${"A".repeat(1200)} PASS_MARKER_ABC ${"B".repeat(1200)} PASS_MARKER_DEF`;
696
+ const result = await client.evaluateResume({
697
+ screeningCriteria: "有 AI 项目经验",
698
+ candidate: {
699
+ name: "候选人A",
700
+ sourceJob: "算法工程师",
701
+ resumeText: longResume,
702
+ evidenceCorpus: longResume,
703
+ },
704
+ imagePath: null,
705
+ });
706
+ assert.equal(result.passed, true);
707
+ assert.equal(result.evaluationMode, "text");
708
+ assert.equal(result.chunkIndex, 2);
709
+ assert.equal(Number(result.chunkTotal) > 1, true);
710
+ } finally {
711
+ if (originalChunkSize === undefined) delete process.env.BOSS_CHAT_TEXT_CHUNK_SIZE_CHARS;
712
+ else process.env.BOSS_CHAT_TEXT_CHUNK_SIZE_CHARS = originalChunkSize;
713
+ if (originalChunkOverlap === undefined) delete process.env.BOSS_CHAT_TEXT_CHUNK_OVERLAP_CHARS;
714
+ else process.env.BOSS_CHAT_TEXT_CHUNK_OVERLAP_CHARS = originalChunkOverlap;
715
+ if (originalMaxChunks === undefined) delete process.env.BOSS_CHAT_TEXT_MAX_CHUNKS;
716
+ else process.env.BOSS_CHAT_TEXT_MAX_CHUNKS = originalMaxChunks;
717
+ }
718
+ }
719
+
720
+ async function testBossChatAppShouldPersistEvidenceArtifacts() {
721
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-chat-artifacts-"));
722
+ await mkdir(tempDir, { recursive: true });
723
+ const records = [];
724
+ const page = {
725
+ async closeResumeModalDomOnce() {
726
+ return {
727
+ closed: true,
728
+ method: "dom",
729
+ finalState: { scopeCount: 0, iframeCount: 0, closeCount: 0, topScopeClass: "" },
730
+ };
731
+ },
732
+ async waitForConversationReady() {
733
+ return {
734
+ hasOnlineResume: true,
735
+ hasAskResume: true,
736
+ hasAttachmentResume: false,
737
+ attachmentResumeEnabled: false,
738
+ };
739
+ },
740
+ async openOnlineResume() {
741
+ return { clicked: true, detectedOpen: true, by: "dom" };
742
+ },
743
+ async getResumeRateLimitWarning() {
744
+ return { hit: false, text: "" };
745
+ },
746
+ async getResumeProfileFromDom() {
747
+ return {
748
+ ok: true,
749
+ primarySchool: "南京大学",
750
+ schools: ["南京大学"],
751
+ major: "计算机",
752
+ majors: ["计算机"],
753
+ company: "OpenAI",
754
+ position: "工程师",
755
+ resumeText: "南京大学 计算机 PASS_MARKER_ABC",
756
+ evidenceCorpus: "南京大学 计算机 PASS_MARKER_ABC",
757
+ };
758
+ },
759
+ async getResumeModalState() {
760
+ return { open: true, iframeCount: 1, scopeCount: 1, closeCount: 1 };
761
+ },
762
+ };
763
+ const llmClient = {
764
+ async evaluateResume() {
765
+ return {
766
+ passed: false,
767
+ rawPassed: true,
768
+ reason: "模型未给出可在简历原文中校验的证据,按安全策略判为不通过。",
769
+ summary: "降级",
770
+ evidence: [],
771
+ evidenceRawCount: 1,
772
+ evidenceMatchedCount: 0,
773
+ evidenceGateDemoted: true,
774
+ evaluationMode: "text",
775
+ chunkIndex: 1,
776
+ chunkTotal: 1,
777
+ };
778
+ },
779
+ };
780
+ const interaction = {
781
+ async sleepRange() {},
782
+ async clickRect() {},
783
+ };
784
+ const resumeCaptureService = {
785
+ async captureResume({ artifactDir }) {
786
+ return {
787
+ stitchedImage: path.join(artifactDir, "resume.png"),
788
+ metadataFile: path.join(artifactDir, "chunks.json"),
789
+ chunkDir: path.join(artifactDir, "chunks"),
790
+ chunkCount: 1,
791
+ quality: { likelyBlank: false },
792
+ };
793
+ },
794
+ };
795
+ const stateStore = {
796
+ async record(_key, result) {
797
+ records.push(result);
798
+ },
799
+ };
800
+ const app = new BossChatApp({
801
+ page,
802
+ llmClient,
803
+ interaction,
804
+ resumeCaptureService,
805
+ stateStore,
806
+ reportStore: { async write() { return ""; } },
807
+ dryRun: true,
808
+ artifactRootDir: tempDir,
809
+ resumeOpenCooldownMs: 0,
810
+ logger: { log() {} },
811
+ });
812
+ app.waitResumeOpenCooldown = async () => {};
813
+
814
+ const result = await app.processCustomer(
815
+ {
816
+ customerKey: "candidate-key",
817
+ name: "候选人A",
818
+ sourceJob: "算法工程师",
819
+ domIndex: 0,
820
+ customerId: "1001",
821
+ textSnippet: "",
822
+ },
823
+ {
824
+ screeningCriteria: "有 AI 项目经验",
825
+ },
826
+ "run-test",
827
+ { skipCardClick: true },
828
+ );
829
+
830
+ assert.equal(result.passed, false);
831
+ assert.equal(result.artifacts.rawPassed, true);
832
+ assert.equal(result.artifacts.finalPassed, false);
833
+ assert.equal(result.artifacts.evidenceRawCount, 1);
834
+ assert.equal(result.artifacts.evidenceMatchedCount, 0);
835
+ assert.equal(result.artifacts.evidenceGateDemoted, true);
836
+ assert.equal(result.artifacts.evaluationMode, "text");
837
+ assert.equal(Array.isArray(records), true);
838
+ assert.equal(records.length, 1);
839
+ }
840
+
553
841
  async function main() {
554
842
  await testBossChatAdapterShouldResolveSharedConfigAndInvokeLocalCli();
843
+ await testBossChatPrepareShouldRetryWhenChatPageIsNotReady();
555
844
  await testBossChatMcpToolsShouldValidateAndRoute();
556
845
  await testBossChatCliShouldSupportRunAndFollowUpParsing();
846
+ testBossChatLlmEvidenceGateShouldDemoteMissingEvidence();
847
+ testBossChatLlmEvidenceGateShouldDemoteUnmatchedEvidence();
848
+ await testBossChatLlmTextChunkFallbackShouldWork();
849
+ await testBossChatAppShouldPersistEvidenceArtifacts();
557
850
  console.log("boss-chat tests passed");
558
851
  }
559
852
 
@@ -233,7 +233,11 @@ export class BossChatApp {
233
233
 
234
234
  let consecutiveErrors = 0;
235
235
  let exhaustedScrolls = 0;
236
- const exhaustedScrollLimit = targetCount ? 3 : 8;
236
+ let noMoreMarkerHits = 0;
237
+ let fallbackBottomHits = 0;
238
+ const noMoreMarkerConfirmations = 2;
239
+ const exhaustedScrollLimit = targetCount ? 10 : 60;
240
+ const fallbackBottomLimit = targetCount ? 4 : 12;
237
241
 
238
242
  try {
239
243
  while (shouldContinue(summary, targetCount)) {
@@ -324,6 +328,9 @@ export class BossChatApp {
324
328
  stage: 'running',
325
329
  message: `已处理候选人:${result.name || '未知'}`,
326
330
  });
331
+ exhaustedScrolls = 0;
332
+ noMoreMarkerHits = 0;
333
+ fallbackBottomHits = 0;
327
334
  if (consecutiveErrors >= 3) {
328
335
  this.logger.log('连续 3 位候选人处理失败,提前停止本轮运行。');
329
336
  break;
@@ -340,13 +347,33 @@ export class BossChatApp {
340
347
  if (!nextCustomer) {
341
348
  const ratio = 0.52 + Math.random() * 0.34;
342
349
  const scrollResult = await this.page.scrollCustomerList(ratio);
350
+ const noMoreDetected =
351
+ Boolean(scrollResult.noMoreDetectedAfter) || Boolean(scrollResult.noMoreDetectedBefore);
343
352
  this.logger.log(
344
- `列表滚动:ratio=${ratio.toFixed(2)} | didScroll=${Boolean(scrollResult.didScroll)} | top=${scrollResult.after?.top ?? 'n/a'} | scrollRetry=${exhaustedScrolls + 1}`,
353
+ `列表滚动:ratio=${ratio.toFixed(2)} | didScroll=${Boolean(scrollResult.didScroll)} | top=${scrollResult.after?.top ?? 'n/a'} | atBottom=${Boolean(scrollResult.atBottom)} | noMore=${noMoreDetected}${scrollResult.noMoreTextAfter ? `(${scrollResult.noMoreTextAfter})` : ''} | scrollRetry=${exhaustedScrolls + 1}`,
345
354
  );
355
+ if (noMoreDetected) {
356
+ noMoreMarkerHits += 1;
357
+ if (noMoreMarkerHits >= noMoreMarkerConfirmations) {
358
+ summary.exhausted = true;
359
+ this.logger.log('列表滚动终止:检测到“没有更多了”标识,判定为 exhausted。');
360
+ break;
361
+ }
362
+ await this.interaction.sleepRange(920, 260);
363
+ continue;
364
+ }
365
+
366
+ noMoreMarkerHits = 0;
346
367
  exhaustedScrolls = scrollResult.didScroll ? exhaustedScrolls + 1 : exhaustedScrolls + 2;
368
+ fallbackBottomHits = scrollResult.atBottom ? fallbackBottomHits + 1 : 0;
369
+ if (fallbackBottomHits >= fallbackBottomLimit && exhaustedScrolls >= Math.ceil(exhaustedScrollLimit / 2)) {
370
+ summary.exhausted = true;
371
+ this.logger.log('列表滚动终止:未发现“没有更多了”标识,但已多次触底且无可处理候选人,判定为 exhausted。');
372
+ break;
373
+ }
347
374
  if (exhaustedScrolls >= exhaustedScrollLimit) {
348
375
  summary.exhausted = true;
349
- this.logger.log('列表滚动终止:连续无可处理候选人,判定为 exhausted。');
376
+ this.logger.log('列表滚动终止:连续无可处理候选人达到保护上限,判定为 exhausted。');
350
377
  break;
351
378
  }
352
379
  await this.interaction.sleepRange(920, 260);
@@ -354,6 +381,8 @@ export class BossChatApp {
354
381
  }
355
382
 
356
383
  exhaustedScrolls = 0;
384
+ noMoreMarkerHits = 0;
385
+ fallbackBottomHits = 0;
357
386
  this.logger.log(
358
387
  `准备处理候选人:name=${nextCustomer.name || '未知'} | key=${nextCustomer.customerKey} | job=${nextCustomer.sourceJob || '未知'} | domIndex=${nextCustomer.domIndex}`,
359
388
  );
@@ -578,6 +607,8 @@ export class BossChatApp {
578
607
  majors: Array.isArray(resumeProfile.majors) ? resumeProfile.majors : [],
579
608
  company: resumeProfile.company || '',
580
609
  position: resumeProfile.position || '',
610
+ resumeTextLength: String(resumeProfile.resumeText || '').length,
611
+ evidenceCorpusLength: String(resumeProfile.evidenceCorpus || '').length,
581
612
  };
582
613
  } else {
583
614
  this.logger.log(`简历结构化提取未命中:${resumeProfile?.error || 'unknown'}`);
@@ -638,6 +669,8 @@ export class BossChatApp {
638
669
  company: resumeProfile.company || '',
639
670
  position: resumeProfile.position || '',
640
671
  } : null,
672
+ resumeText: resumeProfile?.ok ? String(resumeProfile.resumeText || '') : '',
673
+ evidenceCorpus: resumeProfile?.ok ? String(resumeProfile.evidenceCorpus || '') : '',
641
674
  },
642
675
  imagePath: capture.stitchedImage,
643
676
  });
@@ -647,13 +680,37 @@ export class BossChatApp {
647
680
  `评估理由学校字段已按主简历纠偏:rawReason=${evaluation.reason} | finalReason=${finalReason}`,
648
681
  );
649
682
  }
683
+ if (evaluation.evidenceGateDemoted === true) {
684
+ this.logger.log(
685
+ `证据闸门降级:rawPassed=${Boolean(evaluation.rawPassed)} | evidenceRawCount=${Number(evaluation.evidenceRawCount || 0)} | evidenceMatchedCount=${Number(evaluation.evidenceMatchedCount || 0)} | mode=${evaluation.evaluationMode || 'unknown'}`,
686
+ );
687
+ }
650
688
  this.logger.log(
651
- `LLM评估完成:passed=${evaluation.passed} | reason=${finalReason}`,
689
+ `LLM评估完成:passed=${evaluation.passed} | rawPassed=${Boolean(evaluation.rawPassed)} | mode=${evaluation.evaluationMode || 'unknown'} | reason=${finalReason}`,
652
690
  );
653
691
 
654
692
  baseResult.reason = finalReason;
655
693
  baseResult.passed = evaluation.passed;
656
694
  baseResult.decision = evaluation.passed ? 'passed' : 'skipped';
695
+ baseResult.artifacts.rawPassed = Boolean(evaluation.rawPassed);
696
+ baseResult.artifacts.finalPassed = Boolean(evaluation.passed);
697
+ baseResult.artifacts.evidenceRawCount = Number.isFinite(Number(evaluation.evidenceRawCount))
698
+ ? Number(evaluation.evidenceRawCount)
699
+ : 0;
700
+ baseResult.artifacts.evidenceMatchedCount = Number.isFinite(Number(evaluation.evidenceMatchedCount))
701
+ ? Number(evaluation.evidenceMatchedCount)
702
+ : 0;
703
+ baseResult.artifacts.evidenceGateDemoted = evaluation.evidenceGateDemoted === true;
704
+ baseResult.artifacts.evaluationMode = String(evaluation.evaluationMode || '');
705
+ baseResult.artifacts.evaluationChunkIndex = Number.isFinite(Number(evaluation.chunkIndex))
706
+ ? Number(evaluation.chunkIndex)
707
+ : null;
708
+ baseResult.artifacts.evaluationChunkTotal = Number.isFinite(Number(evaluation.chunkTotal))
709
+ ? Number(evaluation.chunkTotal)
710
+ : null;
711
+ baseResult.artifacts.evaluationEvidence = Array.isArray(evaluation.evidence)
712
+ ? evaluation.evidence.slice(0, 5).map((item) => String(item || '').trim()).filter(Boolean)
713
+ : [];
657
714
 
658
715
  await this.checkpoint();
659
716
  const closeResult =
@@ -630,6 +630,16 @@ function browserActivateCandidate(options = {}) {
630
630
  function browserScrollCustomerList(options = {}) {
631
631
  const ratio = Number(options.ratio || 0.72);
632
632
  const clamp = (value, low, high) => Math.max(low, Math.min(high, value));
633
+ const normalize = (value) => String(value || '').replace(/\s+/g, ' ').trim();
634
+ const isVisible = (el) => {
635
+ if (!(el instanceof HTMLElement)) return false;
636
+ const style = getComputedStyle(el);
637
+ if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') < 0.01) {
638
+ return false;
639
+ }
640
+ const rect = el.getBoundingClientRect();
641
+ return rect.width > 2 && rect.height > 2;
642
+ };
633
643
  const isOverflowScrollable = (el) => {
634
644
  if (!(el instanceof HTMLElement)) return false;
635
645
  const style = getComputedStyle(el);
@@ -706,6 +716,20 @@ function browserScrollCustomerList(options = {}) {
706
716
  return { ok: false, error: 'CHAT_LIST_CONTAINER_NOT_FOUND' };
707
717
  }
708
718
 
719
+ const findNoMoreTips = () => {
720
+ const host =
721
+ listContainer.closest('.chat-user, .user-container, .chat-container, .chat-main') || document;
722
+ const tips = Array.from(host.querySelectorAll('div[role="tfoot"] .load-tips, p.load-tips')).find((node) => {
723
+ if (!(node instanceof HTMLElement)) return false;
724
+ const text = normalize(node.textContent || '');
725
+ return text.includes('没有更多了') && isVisible(node);
726
+ });
727
+ return {
728
+ detected: Boolean(tips),
729
+ text: normalize(tips?.textContent || ''),
730
+ };
731
+ };
732
+
709
733
  const firstCard = listContainer.querySelector('div[role="listitem"]') || document.querySelector('div[role="listitem"]');
710
734
  if (firstCard instanceof HTMLElement) {
711
735
  const best = findBestScrollableContainer(firstCard);
@@ -714,6 +738,7 @@ function browserScrollCustomerList(options = {}) {
714
738
  }
715
739
  }
716
740
 
741
+ const noMoreBefore = findNoMoreTips();
717
742
  const before = {
718
743
  top: Number(listContainer.scrollTop || 0),
719
744
  height: Number(listContainer.scrollHeight || 0),
@@ -750,11 +775,18 @@ function browserScrollCustomerList(options = {}) {
750
775
  clientHeight: Number(listContainer.clientHeight || 0),
751
776
  cardCount: Number(listContainer.querySelectorAll('div[role="listitem"]').length || 0),
752
777
  };
778
+ const noMoreAfter = findNoMoreTips();
779
+ const atBottom = after.height <= after.clientHeight + 2 || after.top >= Math.max(0, after.height - after.clientHeight - 2);
753
780
 
754
781
  return {
755
782
  ok: true,
756
783
  before,
757
784
  after,
785
+ atBottom,
786
+ noMoreDetectedBefore: noMoreBefore.detected,
787
+ noMoreDetectedAfter: noMoreAfter.detected,
788
+ noMoreTextBefore: noMoreBefore.text,
789
+ noMoreTextAfter: noMoreAfter.text,
758
790
  didScroll:
759
791
  before.top !== after.top ||
760
792
  before.height !== after.height ||
@@ -1992,6 +2024,18 @@ function browserExtractResumeProfileFromModal() {
1992
2024
  normalized.includes('匿名牛人')
1993
2025
  );
1994
2026
  };
2027
+ const stripNoiseText = (text) => {
2028
+ let cleaned = normalize(text);
2029
+ const noisePhrases = ['其他名企大厂经历牛人', '相似牛人', '推荐牛人', '匿名牛人'];
2030
+ for (const phrase of noisePhrases) {
2031
+ cleaned = cleaned.split(phrase).join(' ');
2032
+ }
2033
+ return normalize(cleaned);
2034
+ };
2035
+ const pickSectionText = (section) => {
2036
+ if (!section) return '';
2037
+ return stripNoiseText(section.innerText || section.textContent || '');
2038
+ };
1995
2039
  const pickFirstText = (scope, selectors) => {
1996
2040
  for (const selector of selectors) {
1997
2041
  let nodes = [];
@@ -2057,6 +2101,8 @@ function browserExtractResumeProfileFromModal() {
2057
2101
  position: '',
2058
2102
  schools: [],
2059
2103
  majors: [],
2104
+ resumeText: '',
2105
+ evidenceCorpus: '',
2060
2106
  };
2061
2107
  }
2062
2108
 
@@ -2070,6 +2116,16 @@ function browserExtractResumeProfileFromModal() {
2070
2116
  root.querySelector('.geek-work-experience-wrap') ||
2071
2117
  root.querySelector('.resume-section[class*="work"]') ||
2072
2118
  root;
2119
+ const projectSection =
2120
+ root.querySelector('.resume-section.geek-project-experience-wrap') ||
2121
+ root.querySelector('.geek-project-experience-wrap') ||
2122
+ root.querySelector('.resume-section[class*="project"]') ||
2123
+ null;
2124
+ const skillSection =
2125
+ root.querySelector('.resume-section.geek-skill-wrap') ||
2126
+ root.querySelector('.geek-skill-wrap') ||
2127
+ root.querySelector('.resume-section[class*="skill"]') ||
2128
+ null;
2073
2129
  const baseSection =
2074
2130
  root.querySelector('.resume-section.geek-base-info-wrap') ||
2075
2131
  root.querySelector('.geek-base-info-wrap') ||
@@ -2102,6 +2158,21 @@ function browserExtractResumeProfileFromModal() {
2102
2158
  '.position span',
2103
2159
  '.position',
2104
2160
  ]);
2161
+ const baseText = pickSectionText(baseSection);
2162
+ const educationText = pickSectionText(educationSection);
2163
+ const workText = pickSectionText(workSection);
2164
+ const projectText = pickSectionText(projectSection);
2165
+ const skillText = pickSectionText(skillSection);
2166
+ const evidenceCorpus = stripNoiseText(root.innerText || root.textContent || '');
2167
+ const resumeText = [
2168
+ baseText ? `基础信息: ${baseText}` : '',
2169
+ educationText ? `教育经历: ${educationText}` : '',
2170
+ workText ? `工作经历: ${workText}` : '',
2171
+ projectText ? `项目经历: ${projectText}` : '',
2172
+ skillText ? `技能信息: ${skillText}` : '',
2173
+ ]
2174
+ .filter(Boolean)
2175
+ .join('\n');
2105
2176
 
2106
2177
  return {
2107
2178
  ok: true,
@@ -2112,11 +2183,15 @@ function browserExtractResumeProfileFromModal() {
2112
2183
  position,
2113
2184
  schools,
2114
2185
  majors,
2186
+ resumeText: resumeText || evidenceCorpus || '',
2187
+ evidenceCorpus: evidenceCorpus || resumeText || '',
2115
2188
  debug: {
2116
2189
  rootClass: String(root.className || ''),
2117
2190
  educationClass: String(educationSection?.className || ''),
2118
2191
  workClass: String(workSection?.className || ''),
2119
2192
  wrapperClass: String(scope?.className || ''),
2193
+ resumeTextLength: Number((resumeText || '').length),
2194
+ evidenceCorpusLength: Number((evidenceCorpus || '').length),
2120
2195
  },
2121
2196
  };
2122
2197
  }
@@ -291,7 +291,7 @@ function printUsage() {
291
291
  console.log('');
292
292
  console.log('Run options:');
293
293
  console.log(' --dry-run Evaluate and click, but do not request resume');
294
- console.log(' --no-state Do not persist processed candidate state');
294
+ console.log(' --no-state Disable in-run candidate deduplication');
295
295
  console.log(' --job <text|value|index> Select job by label/value/index');
296
296
  console.log(' --criteria <text> Screening criteria for resume evaluation');
297
297
  console.log(' --start-from <unread|all> Start from unread or all list');