@reconcrap/boss-recommend-mcp 1.3.2 → 1.3.4

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.2",
3
+ "version": "1.3.4",
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
@@ -30,6 +30,69 @@ function parsePositiveInteger(value, fallback = null) {
30
30
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
31
31
  }
32
32
 
33
+ function isUnlimitedTargetCountToken(value) {
34
+ const token = normalizeText(value).toLowerCase();
35
+ if (!token) return false;
36
+ return [
37
+ "all",
38
+ "unlimited",
39
+ "infinity",
40
+ "inf",
41
+ "max",
42
+ "full",
43
+ "全部",
44
+ "全量",
45
+ "不限",
46
+ "扫到底",
47
+ "直到完成所有人选"
48
+ ].includes(token);
49
+ }
50
+
51
+ function parseBossChatTargetCount(value) {
52
+ if (value === undefined || value === null) {
53
+ return {
54
+ provided: false,
55
+ targetCount: null,
56
+ cliArg: null
57
+ };
58
+ }
59
+ const raw = normalizeText(value);
60
+ if (!raw) {
61
+ return {
62
+ provided: false,
63
+ targetCount: null,
64
+ cliArg: null
65
+ };
66
+ }
67
+ if (isUnlimitedTargetCountToken(raw)) {
68
+ return {
69
+ provided: true,
70
+ targetCount: null,
71
+ cliArg: "-1"
72
+ };
73
+ }
74
+ const parsed = Number.parseInt(String(raw), 10);
75
+ if (Number.isFinite(parsed) && parsed === -1) {
76
+ return {
77
+ provided: true,
78
+ targetCount: null,
79
+ cliArg: "-1"
80
+ };
81
+ }
82
+ if (Number.isFinite(parsed) && parsed > 0) {
83
+ return {
84
+ provided: true,
85
+ targetCount: parsed,
86
+ cliArg: String(parsed)
87
+ };
88
+ }
89
+ return {
90
+ provided: false,
91
+ targetCount: null,
92
+ cliArg: null
93
+ };
94
+ }
95
+
33
96
  function parseJsonOutput(text) {
34
97
  const trimmed = String(text || "").trim();
35
98
  if (!trimmed) return null;
@@ -152,14 +215,16 @@ function normalizeBossChatStartInput(input = {}) {
152
215
  const startFromRaw = normalizeText(input.startFrom || input.start_from).toLowerCase();
153
216
  const startFrom = startFromRaw === "all" ? "all" : startFromRaw === "unread" ? "unread" : "";
154
217
  const criteria = normalizeText(input.criteria);
155
- const targetCount = parsePositiveInteger(input.targetCount ?? input.target_count);
218
+ const parsedTarget = parseBossChatTargetCount(input.targetCount ?? input.target_count);
156
219
  const port = parsePositiveInteger(input.port);
157
220
  return {
158
221
  profile,
159
222
  job,
160
223
  startFrom,
161
224
  criteria,
162
- targetCount,
225
+ targetCount: parsedTarget.targetCount,
226
+ targetCountArg: parsedTarget.cliArg,
227
+ targetCountProvided: parsedTarget.provided,
163
228
  port,
164
229
  dryRun: input.dryRun === true || input.dry_run === true,
165
230
  noState: input.noState === true || input.no_state === true,
@@ -181,7 +246,7 @@ function getMissingBossChatStartFields(input = {}) {
181
246
  const missing = [];
182
247
  if (!normalized.job) missing.push("job");
183
248
  if (!normalized.startFrom) missing.push("start_from");
184
- if (!normalized.targetCount) missing.push("target_count");
249
+ if (!normalized.targetCountProvided) missing.push("target_count");
185
250
  if (!normalized.criteria) missing.push("criteria");
186
251
  return missing;
187
252
  }
@@ -194,7 +259,7 @@ function buildBossChatCliArgs(command, input, resolvedConfig) {
194
259
  if (normalized.job) args.push("--job", normalized.job);
195
260
  if (normalized.startFrom) args.push("--start-from", normalized.startFrom);
196
261
  if (normalized.criteria) args.push("--criteria", normalized.criteria);
197
- if (normalized.targetCount) args.push("--targetCount", String(normalized.targetCount));
262
+ if (normalized.targetCountArg) args.push("--targetCount", normalized.targetCountArg);
198
263
  args.push("--port", String(normalized.port || resolvedConfig.debugPort || 9222));
199
264
  args.push("--baseurl", resolvedConfig.baseUrl);
200
265
  args.push("--apikey", resolvedConfig.apiKey);
@@ -210,8 +275,8 @@ function buildBossChatCliArgs(command, input, resolvedConfig) {
210
275
  args.push("--job", normalized.job);
211
276
  args.push("--start-from", normalized.startFrom);
212
277
  args.push("--criteria", normalized.criteria);
213
- if (normalized.targetCount) {
214
- args.push("--targetCount", String(normalized.targetCount));
278
+ if (normalized.targetCountArg) {
279
+ args.push("--targetCount", normalized.targetCountArg);
215
280
  }
216
281
  args.push("--baseurl", resolvedConfig.baseUrl);
217
282
  args.push("--apikey", resolvedConfig.apiKey);
package/src/index.js CHANGED
@@ -70,9 +70,27 @@ let runSelfHealImpl = runRecommendSelfHeal;
70
70
  let spawnProcessImpl = spawn;
71
71
  const TERMINAL_RUN_STATES = new Set([RUN_STATE_COMPLETED, RUN_STATE_FAILED, RUN_STATE_CANCELED]);
72
72
 
73
- function normalizeText(value) {
74
- return String(value || "").replace(/\s+/g, " ").trim();
75
- }
73
+ function normalizeText(value) {
74
+ return String(value || "").replace(/\s+/g, " ").trim();
75
+ }
76
+
77
+ function isUnlimitedTargetCountToken(value) {
78
+ const token = normalizeText(value).toLowerCase();
79
+ if (!token) return false;
80
+ return [
81
+ "all",
82
+ "unlimited",
83
+ "infinity",
84
+ "inf",
85
+ "max",
86
+ "full",
87
+ "全部",
88
+ "全量",
89
+ "不限",
90
+ "扫到底",
91
+ "直到完成所有人选"
92
+ ].includes(token);
93
+ }
76
94
 
77
95
  function parsePositiveInteger(raw, fallback) {
78
96
  const value = Number.parseInt(String(raw || ""), 10);
@@ -358,8 +376,16 @@ function createRunInputSchema() {
358
376
  enum: ["unread", "all"]
359
377
  },
360
378
  target_count: {
361
- type: "integer",
362
- minimum: 1
379
+ oneOf: [
380
+ {
381
+ type: "integer",
382
+ minimum: 1
383
+ },
384
+ {
385
+ type: "string",
386
+ enum: ["all", "unlimited", "全部", "不限", "扫到底", "全量"]
387
+ }
388
+ ]
363
389
  },
364
390
  dry_run: { type: "boolean" },
365
391
  no_state: { type: "boolean" },
@@ -399,9 +425,17 @@ function createBossChatStartInputSchema() {
399
425
  description: "boss-chat 的筛选 criteria"
400
426
  },
401
427
  target_count: {
402
- type: "integer",
403
- minimum: 1,
404
- description: "本次处理人数上限;chat-only 模式必填(可先不传,服务会返回 NEED_INPUT 引导补齐)"
428
+ oneOf: [
429
+ {
430
+ type: "integer",
431
+ minimum: 1
432
+ },
433
+ {
434
+ type: "string",
435
+ enum: ["all", "unlimited", "全部", "不限", "扫到底", "全量"]
436
+ }
437
+ ],
438
+ description: "本次处理人数上限;支持正整数或 all/不限(扫到底)"
405
439
  },
406
440
  port: {
407
441
  type: "integer",
@@ -673,9 +707,13 @@ function validateBossChatStartArgs(args) {
673
707
  }
674
708
  }
675
709
  if (Object.prototype.hasOwnProperty.call(args, "target_count")) {
676
- const targetCount = Number.parseInt(String(args.target_count), 10);
677
- if (!Number.isFinite(targetCount) || targetCount <= 0) {
678
- return "target_count must be a positive integer";
710
+ const rawTargetCount = args.target_count;
711
+ const targetCount = Number.parseInt(String(rawTargetCount), 10);
712
+ const tokenAllowed =
713
+ typeof rawTargetCount === "string" && isUnlimitedTargetCountToken(rawTargetCount);
714
+ const numericUnlimited = Number.isFinite(targetCount) && targetCount === -1;
715
+ if ((!Number.isFinite(targetCount) || targetCount <= 0) && !tokenAllowed && !numericUnlimited) {
716
+ return "target_count must be a positive integer or one of: all, unlimited, 全部, 不限, 扫到底, 全量";
679
717
  }
680
718
  }
681
719
  if (Object.prototype.hasOwnProperty.call(args, "port")) {
@@ -226,6 +226,7 @@ export class BossChatApp {
226
226
 
227
227
  let consecutiveErrors = 0;
228
228
  let exhaustedScrolls = 0;
229
+ const exhaustedScrollLimit = targetCount ? 3 : 8;
229
230
 
230
231
  try {
231
232
  while (shouldContinue(summary, targetCount)) {
@@ -336,7 +337,7 @@ export class BossChatApp {
336
337
  `列表滚动:ratio=${ratio.toFixed(2)} | didScroll=${Boolean(scrollResult.didScroll)} | top=${scrollResult.after?.top ?? 'n/a'} | scrollRetry=${exhaustedScrolls + 1}`,
337
338
  );
338
339
  exhaustedScrolls = scrollResult.didScroll ? exhaustedScrolls + 1 : exhaustedScrolls + 2;
339
- if (exhaustedScrolls >= 3) {
340
+ if (exhaustedScrolls >= exhaustedScrollLimit) {
340
341
  summary.exhausted = true;
341
342
  this.logger.log('列表滚动终止:连续无可处理候选人,判定为 exhausted。');
342
343
  break;
@@ -629,6 +629,41 @@ function browserActivateCandidate(options = {}) {
629
629
 
630
630
  function browserScrollCustomerList(options = {}) {
631
631
  const ratio = Number(options.ratio || 0.72);
632
+ const clamp = (value, low, high) => Math.max(low, Math.min(high, value));
633
+ const isOverflowScrollable = (el) => {
634
+ if (!(el instanceof HTMLElement)) return false;
635
+ const style = getComputedStyle(el);
636
+ return /(auto|scroll|overlay)/i.test(String(style.overflowY || ''));
637
+ };
638
+ const findBestScrollableContainer = (seedCard) => {
639
+ const candidates = [];
640
+ const pushCandidate = (node) => {
641
+ if (node instanceof HTMLElement) candidates.push(node);
642
+ };
643
+ if (seedCard instanceof HTMLElement) {
644
+ let current = seedCard.parentElement;
645
+ let depth = 0;
646
+ while (current && depth < 30) {
647
+ pushCandidate(current);
648
+ current = current.parentElement;
649
+ depth += 1;
650
+ }
651
+ }
652
+ const unique = Array.from(new Set(candidates));
653
+ let best = null;
654
+ let bestScore = -Infinity;
655
+ for (const node of unique) {
656
+ const scrollRange = Number(node.scrollHeight || 0) - Number(node.clientHeight || 0);
657
+ const styleBonus = isOverflowScrollable(node) ? 80 : 0;
658
+ const classBonus = /user|list|chat/i.test(String(node.className || '')) ? 24 : 0;
659
+ const score = scrollRange + styleBonus + classBonus;
660
+ if (score > bestScore) {
661
+ best = node;
662
+ bestScore = score;
663
+ }
664
+ }
665
+ return best;
666
+ };
632
667
  const isScrollable = (el) =>
633
668
  el instanceof HTMLElement &&
634
669
  Number(el.scrollHeight || 0) > Number(el.clientHeight || 0) + 16 &&
@@ -671,27 +706,59 @@ function browserScrollCustomerList(options = {}) {
671
706
  return { ok: false, error: 'CHAT_LIST_CONTAINER_NOT_FOUND' };
672
707
  }
673
708
 
709
+ const firstCard = listContainer.querySelector('div[role="listitem"]') || document.querySelector('div[role="listitem"]');
710
+ if (firstCard instanceof HTMLElement) {
711
+ const best = findBestScrollableContainer(firstCard);
712
+ if (best instanceof HTMLElement) {
713
+ listContainer = best;
714
+ }
715
+ }
716
+
674
717
  const before = {
675
718
  top: Number(listContainer.scrollTop || 0),
676
719
  height: Number(listContainer.scrollHeight || 0),
677
720
  clientHeight: Number(listContainer.clientHeight || 0),
721
+ cardCount: Number(listContainer.querySelectorAll('div[role="listitem"]').length || 0),
678
722
  };
679
723
  const amount = Math.max(120, Math.round(before.clientHeight * Math.max(0.35, Math.min(0.95, ratio))));
680
724
  const maxScroll = Math.max(0, before.height - before.clientHeight);
681
- listContainer.scrollTop = clamp(before.top + amount, 0, maxScroll);
725
+ if (maxScroll > 0) {
726
+ listContainer.scrollTop = clamp(before.top + amount, 0, maxScroll);
727
+ } else {
728
+ const cards = Array.from(listContainer.querySelectorAll('div[role="listitem"]')).filter(
729
+ (node) => node instanceof HTMLElement,
730
+ );
731
+ const tail = cards[cards.length - 1];
732
+ if (tail instanceof HTMLElement) {
733
+ tail.scrollIntoView({ block: 'end', inline: 'nearest' });
734
+ try {
735
+ listContainer.dispatchEvent(
736
+ new WheelEvent('wheel', {
737
+ deltaY: amount,
738
+ bubbles: true,
739
+ cancelable: true,
740
+ }),
741
+ );
742
+ } catch {}
743
+ }
744
+ }
682
745
  listContainer.dispatchEvent(new Event('scroll', { bubbles: true }));
683
746
 
684
747
  const after = {
685
748
  top: Number(listContainer.scrollTop || 0),
686
749
  height: Number(listContainer.scrollHeight || 0),
687
750
  clientHeight: Number(listContainer.clientHeight || 0),
751
+ cardCount: Number(listContainer.querySelectorAll('div[role="listitem"]').length || 0),
688
752
  };
689
753
 
690
754
  return {
691
755
  ok: true,
692
756
  before,
693
757
  after,
694
- didScroll: before.top !== after.top || before.height !== after.height,
758
+ didScroll:
759
+ before.top !== after.top ||
760
+ before.height !== after.height ||
761
+ before.cardCount !== after.cardCount,
695
762
  };
696
763
  }
697
764
 
@@ -724,29 +791,46 @@ function browserConversationReadyState() {
724
791
  const disabledAttr = Boolean(el?.hasAttribute?.('disabled'));
725
792
  return classText.includes('disabled') || ariaDisabled || disabledAttr;
726
793
  };
794
+ const isDisabledDeep = (el) => {
795
+ if (!(el instanceof HTMLElement)) return true;
796
+ let current = el;
797
+ let depth = 0;
798
+ while (current && depth < 5) {
799
+ if (isDisabled(current)) return true;
800
+ current = current.parentElement;
801
+ depth += 1;
802
+ }
803
+ return false;
804
+ };
805
+ const resolveAttachmentButton = () => {
806
+ const candidates = Array.from(
807
+ document.querySelectorAll(
808
+ '.resume-btn-file, .btn.resume-btn-file, [class*="resume-btn-file"]',
809
+ ),
810
+ ).filter((el) => isVisible(el));
811
+ const match = candidates.find((el) => {
812
+ const text = normalize(el.textContent || '');
813
+ if (!text) return false;
814
+ if (!text.includes('附件简历')) return false;
815
+ if (text.includes('求附件简历')) return false;
816
+ return true;
817
+ });
818
+ return match || null;
819
+ };
727
820
  const onlineResume = Array.from(
728
821
  document.querySelectorAll(
729
822
  'a.btn.resume-btn-online, a.resume-btn-online, .resume-btn-online, .btn.resume-btn-online',
730
823
  ),
731
824
  ).find((el) => {
732
825
  if (!isVisible(el)) return false;
733
- if (!normalize(el.textContent || '').includes('在线简历')) return false;
734
- return !isDisabled(el);
735
- });
736
- const attachmentResume = Array.from(
737
- document.querySelectorAll(
738
- '.btn.resume-btn-file, .resume-btn-file, [class*="resume-btn-file"], button, a, div, span',
739
- ),
740
- ).find((el) => {
741
- if (!isVisible(el)) return false;
742
- if (!normalize(el.textContent || '').includes('附件简历')) return false;
743
- const classText = String(el.className || '').toLowerCase();
744
- return classText.includes('resume-btn-file') || classText.includes('resume') || classText.includes('btn');
826
+ if (!normalize(el.textContent || '').includes('在线简历')) return false;
827
+ return !isDisabled(el);
745
828
  });
829
+ const attachmentResume = resolveAttachmentButton();
746
830
  const askResume = Array.from(document.querySelectorAll('span.operate-btn, button, a, span')).find(
747
831
  (el) => isVisible(el) && isAskResumeText(el.textContent || ''),
748
832
  );
749
- const attachmentResumeEnabled = Boolean(attachmentResume) && !isDisabled(attachmentResume);
833
+ const attachmentResumeEnabled = Boolean(attachmentResume) && !isDisabledDeep(attachmentResume);
750
834
  return {
751
835
  hasOnlineResume: Boolean(onlineResume),
752
836
  hasAskResume: Boolean(askResume),
@@ -131,11 +131,35 @@ function parseStartFrom(value, fallback = 'unread') {
131
131
  return fallback;
132
132
  }
133
133
 
134
+ function isUnlimitedTargetCountToken(value) {
135
+ const token = String(value || '').trim().toLowerCase();
136
+ if (!token) return false;
137
+ return [
138
+ 'all',
139
+ 'unlimited',
140
+ 'infinity',
141
+ 'inf',
142
+ 'max',
143
+ 'full',
144
+ '全部',
145
+ '全量',
146
+ '不限',
147
+ '扫到底',
148
+ '直到完成所有人选',
149
+ ].includes(token);
150
+ }
151
+
134
152
  function parseTargetCount(value) {
135
153
  if (value === undefined || value === null || String(value).trim() === '') {
136
154
  return null;
137
155
  }
156
+ if (isUnlimitedTargetCountToken(value)) {
157
+ return -1;
158
+ }
138
159
  const parsed = Number.parseInt(String(value), 10);
160
+ if (Number.isFinite(parsed) && parsed === -1) {
161
+ return -1;
162
+ }
139
163
  return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
140
164
  }
141
165
 
@@ -266,7 +290,7 @@ function printUsage() {
266
290
  console.log(' --job <text|value|index> Select job by label/value/index');
267
291
  console.log(' --criteria <text> Screening criteria for resume evaluation');
268
292
  console.log(' --start-from <unread|all> Start from unread or all list');
269
- console.log(' --targetCount <n> Maximum candidates to process; empty means unlimited');
293
+ console.log(' --targetCount <n|all> Maximum candidates to process; all means unlimited');
270
294
  console.log(' --baseurl <url> Override LLM base URL');
271
295
  console.log(' --apikey <key> Override LLM API key');
272
296
  console.log(' --model <name> Override LLM model');
@@ -540,7 +564,9 @@ function validateStartRunArgs(args) {
540
564
  const missing = [];
541
565
  if (!args?.overrides?.jobSelection) missing.push('--job');
542
566
  if (!args?.overrides?.startFrom) missing.push('--start-from');
543
- if (!args?.overrides?.targetCount) missing.push('--targetCount');
567
+ if (args?.overrides?.targetCount === undefined || args?.overrides?.targetCount === null) {
568
+ missing.push('--targetCount');
569
+ }
544
570
  if (!args?.overrides?.screeningCriteria) missing.push('--criteria');
545
571
 
546
572
  if (missing.length === 0) return null;
@@ -557,7 +583,12 @@ function validateStartRunArgs(args) {
557
583
  function buildPreparePendingQuestions(args, jobs = []) {
558
584
  const pendingQuestions = [];
559
585
  const startFromValue = String(args?.overrides?.startFrom || '').trim().toLowerCase();
560
- const targetCountValue = Number.parseInt(String(args?.overrides?.targetCount || ''), 10);
586
+ const targetCountValue = Number.parseInt(String(args?.overrides?.targetCount ?? ''), 10);
587
+ const hasTargetCount =
588
+ args?.overrides?.targetCount !== undefined &&
589
+ args?.overrides?.targetCount !== null &&
590
+ Number.isFinite(targetCountValue) &&
591
+ (targetCountValue > 0 || targetCountValue === -1);
561
592
  const criteriaValue = String(args?.overrides?.screeningCriteria || '').trim();
562
593
  const jobValue = String(args?.overrides?.jobSelection || '').trim();
563
594
  const jobOptions = jobs.map((job, index) => ({
@@ -586,10 +617,10 @@ function buildPreparePendingQuestions(args, jobs = []) {
586
617
  ],
587
618
  });
588
619
  }
589
- if (!Number.isFinite(targetCountValue) || targetCountValue <= 0) {
620
+ if (!hasTargetCount) {
590
621
  pendingQuestions.push({
591
622
  field: 'target_count',
592
- question: '请输入目标数量(正整数)',
623
+ question: '请输入目标数量(正整数)或 all(扫到底)',
593
624
  required: true,
594
625
  });
595
626
  }
@@ -1198,8 +1229,12 @@ async function executeRunCommand(args, dataDir) {
1198
1229
  cleanupRuntimeControls = setupRuntimeControls(runControl);
1199
1230
 
1200
1231
  logger.log('开始处理 Boss 聊天候选人列表...');
1232
+ const targetCountLabel =
1233
+ Number.isFinite(Number(runProfile.targetCount)) && Number(runProfile.targetCount) > 0
1234
+ ? String(runProfile.targetCount)
1235
+ : '扫到底';
1201
1236
  logger.log(
1202
- `本次设置: 岗位=${runProfile.jobSelection.label}, 范围=${runProfile.startFrom === 'all' ? '全部' : '未读'}, 上限=${runProfile.targetCount || '扫到底'}`,
1237
+ `本次设置: 岗位=${runProfile.jobSelection.label}, 范围=${runProfile.startFrom === 'all' ? '全部' : '未读'}, 上限=${targetCountLabel}`,
1203
1238
  );
1204
1239
  logger.log('运行中快捷键: p=暂停/继续, r=继续, q=停止, Ctrl+C=停止');
1205
1240