@reconcrap/boss-recruit-mcp 1.0.20 → 1.0.21

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/src/parser.js CHANGED
@@ -31,8 +31,6 @@ const DEFAULT_PARAM_LABELS = {
31
31
  };
32
32
  const DEGREE_VALUES = new Set(["不限", "本科", "本科及以上", "硕士及以上", "博士"]);
33
33
  const CITY_STOP_PATTERN = /(?:筛选|搜索|查找|找|做过|从事过|有过|相关|的人选|的人|并且|且|学历|学校|目标|必须|优先|,|。|;|;|,)/;
34
- const SEARCH_INTENT_LEADING_PATTERN = /^(?:帮我|请)?(?:在boss上|在boss直聘上)?(?:找|筛选|搜索|查找)/i;
35
- const SEARCH_INTENT_META_PATTERN = /(?:城市|地点|学历|学校|目标|人数|候选人|人选|岗位|职位|做过|有过|从事过|本科|硕士|博士)/i;
36
34
 
37
35
  function normalizeText(input) {
38
36
  return String(input || "").replace(/\s+/g, " ").trim();
@@ -93,19 +91,6 @@ function extractCity(text) {
93
91
  }
94
92
  }
95
93
 
96
- const implicitPatterns = [
97
- /(?:^|[,,。;;\n])(?:帮我|请)?(?:在boss上|在boss直聘上)?(?:找|筛选|搜索|查找)\s*([^\s,。;;、]{2,12}?)(?=(?:本科|硕士|博士|做过|有过|从事过|的人选|的人|候选人|工程师|研究员|岗位|职位|,|。|;|;|,|$))/i,
98
- /(?:^|[,,。;;\n])在\s*([^\s,。;;、]{2,12}?)(?=(?:招|招聘|找|筛选|搜索|查找|本科|硕士|博士|做过|有过|从事过|的人选|的人|候选人|工程师|研究员|岗位|职位|,|。|;|;|,|$))/i
99
- ];
100
-
101
- for (const pattern of implicitPatterns) {
102
- const m = text.match(pattern);
103
- if (m && m[1]) {
104
- const city = sanitizeCityCandidate(m[1]);
105
- if (city) return city;
106
- }
107
- }
108
-
109
94
  return null;
110
95
  }
111
96
 
@@ -198,9 +183,9 @@ function extractKeywordExplicit(text) {
198
183
 
199
184
  function extractKeywordAuto(text) {
200
185
  const patterns = [
201
- /做(?:过)?\s*([\u4e00-\u9fa5A-Za-z0-9+#./\-\s]{2,40}?)(?:的人选|的人|相关|并且|且|,|。|,|$)/i,
202
- /有过\s*([\u4e00-\u9fa5A-Za-z0-9+#./\-\s]{2,40}?)(?:经验|背景|的人选|并且|且|,|。|,|$)/i,
203
- /从事过\s*([\u4e00-\u9fa5A-Za-z0-9+#./\-\s]{2,40}?)(?:相关|的人选|并且|且|,|。|,|$)/i
186
+ /做过\s*([A-Za-z0-9+#./\-\s]{2,40}?)(?:的人选|的人|相关|并且|且|,|。|,|$)/i,
187
+ /有过\s*([A-Za-z0-9+#./\-\s]{2,40}?)(?:经验|背景|的人选|并且|且|,|。|,|$)/i,
188
+ /从事过\s*([A-Za-z0-9+#./\-\s]{2,40}?)(?:相关|的人选|并且|且|,|。|,|$)/i
204
189
  ];
205
190
 
206
191
  for (const pattern of patterns) {
@@ -231,21 +216,6 @@ function extractTargetCount(text) {
231
216
  return null;
232
217
  }
233
218
 
234
- function extractExplicitCriteria(text) {
235
- const patterns = [
236
- /(?:筛选条件|筛选要求|硬性条件|筛选标准|要求)(?:为|是|:|:)\s*([^\n]+)/i
237
- ];
238
-
239
- for (const pattern of patterns) {
240
- const m = text.match(pattern);
241
- if (m && m[1]) {
242
- const criteria = m[1].trim();
243
- if (criteria) return criteria;
244
- }
245
- }
246
- return null;
247
- }
248
-
249
219
  function sanitizeClause(clause) {
250
220
  return clause
251
221
  .replace(/^使用boss-recruit-pipeline skills/i, "")
@@ -262,11 +232,6 @@ function isCountPlanningClause(clause) {
262
232
  }
263
233
 
264
234
  function buildScreenCriteria(text, searchParams) {
265
- const explicitCriteria = extractExplicitCriteria(text);
266
- if (explicitCriteria) {
267
- return explicitCriteria;
268
- }
269
-
270
235
  const clauses = text
271
236
  .split(/[,,。;;\n]/)
272
237
  .map((s) => sanitizeClause(s))
@@ -277,7 +242,6 @@ function buildScreenCriteria(text, searchParams) {
277
242
  if (/地点|城市/.test(clause)) return false;
278
243
  if (/近?14天(?:内)?查看(?:过)?|过滤近14天查看/.test(clause)) return false;
279
244
  if (isCountPlanningClause(clause)) return false;
280
- if (SEARCH_INTENT_LEADING_PATTERN.test(clause) && SEARCH_INTENT_META_PATTERN.test(clause)) return false;
281
245
  return true;
282
246
  });
283
247
 
@@ -479,7 +443,7 @@ export function parseRecruitInstruction({ instruction, confirmation, overrides }
479
443
  if (!baseSearchParams.city) missingBeforeDefaults.push("city");
480
444
  if (!baseSearchParams.degree) missingBeforeDefaults.push("degree");
481
445
  if (!baseSearchParams.schools || baseSearchParams.schools.length === 0) missingBeforeDefaults.push("schools");
482
- if (!baseSearchParams.keyword && !keywordResolution.needsConfirmation) missingBeforeDefaults.push("keyword");
446
+ if (!baseSearchParams.keyword) missingBeforeDefaults.push("keyword");
483
447
  if (!baseScreenParams.target_count) missingBeforeDefaults.push("target_count");
484
448
 
485
449
  const useDefaultForMissing = confirmation?.use_default_for_missing === true;
package/src/pipeline.js CHANGED
@@ -8,10 +8,16 @@ import {
8
8
  runScreenCli
9
9
  } from "./adapters.js";
10
10
 
11
+ export const PIPELINE_STATUS_READY_TO_START_ASYNC = "READY_TO_START_ASYNC";
12
+
11
13
  function dedupe(values = []) {
12
14
  return [...new Set(values.filter(Boolean))];
13
15
  }
14
16
 
17
+ function normalizeText(value) {
18
+ return String(value || "").replace(/\s+/g, " ").trim();
19
+ }
20
+
15
21
  function failedCheckSet(checks = []) {
16
22
  const failed = checks
17
23
  .filter((item) => item && item.ok === false && typeof item.key === "string")
@@ -195,6 +201,99 @@ function buildFailedResponse(code, message, extra = {}) {
195
201
  };
196
202
  }
197
203
 
204
+ class PipelineAbortError extends Error {
205
+ constructor(message = "Pipeline execution aborted") {
206
+ super(message);
207
+ this.name = "PipelineAbortError";
208
+ this.code = "PIPELINE_ABORTED";
209
+ }
210
+ }
211
+
212
+ function isAbortSignalTriggered(signal) {
213
+ return Boolean(signal && signal.aborted);
214
+ }
215
+
216
+ function ensurePipelineNotAborted(signal) {
217
+ if (isAbortSignalTriggered(signal)) {
218
+ throw new PipelineAbortError("Pipeline execution aborted by caller.");
219
+ }
220
+ }
221
+
222
+ function safeInvokeRuntimeCallback(callback, payload) {
223
+ if (typeof callback !== "function") return;
224
+ try {
225
+ callback(payload);
226
+ } catch {
227
+ // Keep pipeline stable even if runtime callback fails.
228
+ }
229
+ }
230
+
231
+ function createPipelineRuntime(runtime = null) {
232
+ const signal = runtime?.signal;
233
+ const heartbeatIntervalMs = Number.isFinite(runtime?.heartbeatIntervalMs) && runtime.heartbeatIntervalMs > 0
234
+ ? runtime.heartbeatIntervalMs
235
+ : 10_000;
236
+ const precheckOnly = runtime?.precheckOnly === true;
237
+
238
+ function setStage(stage, message = null) {
239
+ safeInvokeRuntimeCallback(runtime?.onStage, {
240
+ stage,
241
+ message: normalizeText(message || "") || null,
242
+ at: new Date().toISOString()
243
+ });
244
+ }
245
+
246
+ function heartbeat(stage, details = null) {
247
+ safeInvokeRuntimeCallback(runtime?.onHeartbeat, {
248
+ stage,
249
+ details: details || null,
250
+ at: new Date().toISOString()
251
+ });
252
+ }
253
+
254
+ function output(stage, event) {
255
+ safeInvokeRuntimeCallback(runtime?.onOutput, {
256
+ stage,
257
+ ...(event || {}),
258
+ at: new Date().toISOString()
259
+ });
260
+ }
261
+
262
+ function progress(stage, payload) {
263
+ safeInvokeRuntimeCallback(runtime?.onProgress, {
264
+ stage,
265
+ ...(payload || {}),
266
+ at: new Date().toISOString()
267
+ });
268
+ }
269
+
270
+ function adapterRuntime(stage) {
271
+ return {
272
+ signal,
273
+ heartbeatIntervalMs,
274
+ onOutput: (event) => output(stage, event),
275
+ onHeartbeat: (event) => heartbeat(stage, event),
276
+ onProgress: (payload) => progress(stage, payload)
277
+ };
278
+ }
279
+
280
+ return {
281
+ signal,
282
+ heartbeatIntervalMs,
283
+ precheckOnly,
284
+ setStage,
285
+ heartbeat,
286
+ output,
287
+ progress,
288
+ adapterRuntime
289
+ };
290
+ }
291
+
292
+ function isProcessAbortError(errorLike) {
293
+ const code = normalizeText(errorLike?.code || errorLike?.error_code || "").toUpperCase();
294
+ return code === "PROCESS_ABORTED" || code === "ABORTED";
295
+ }
296
+
198
297
  function normalizeCsvPath(csvPath) {
199
298
  if (typeof csvPath !== "string") return null;
200
299
  const trimmed = csvPath.trim();
@@ -365,12 +464,16 @@ const defaultDependencies = {
365
464
  runScreenCli
366
465
  };
367
466
 
368
- export async function runRecruitPipeline({
369
- workspaceRoot,
370
- instruction,
371
- confirmation,
372
- overrides
373
- }, dependencies = defaultDependencies) {
467
+ export async function runRecruitPipeline(
468
+ {
469
+ workspaceRoot,
470
+ instruction,
471
+ confirmation,
472
+ overrides
473
+ },
474
+ dependencies = defaultDependencies,
475
+ runtime = null
476
+ ) {
374
477
  const {
375
478
  parseRecruitInstruction: parseInstruction,
376
479
  ensureBossSearchPageReady: ensureSearchPageReady,
@@ -378,6 +481,8 @@ export async function runRecruitPipeline({
378
481
  runSearchCli: searchCli,
379
482
  runScreenCli: screenCli
380
483
  } = dependencies;
484
+ const runtimeHooks = createPipelineRuntime(runtime);
485
+ ensurePipelineNotAborted(runtimeHooks.signal);
381
486
  const startedAt = Date.now();
382
487
  const parsed = parseInstruction({
383
488
  instruction,
@@ -398,8 +503,14 @@ export async function runRecruitPipeline({
398
503
  return buildNeedConfirmationResponse(parsed);
399
504
  }
400
505
 
506
+ ensurePipelineNotAborted(runtimeHooks.signal);
507
+ runtimeHooks.setStage("preflight", "开始执行 preflight 检查。");
508
+ runtimeHooks.heartbeat("preflight");
401
509
  const preflight = runPreflight(workspaceRoot);
402
510
  if (!preflight.ok) {
511
+ runtimeHooks.heartbeat("preflight", {
512
+ status: "failed"
513
+ });
403
514
  const recovery = buildPreflightRecovery(preflight.checks, workspaceRoot);
404
515
  return buildFailedResponse(
405
516
  "PIPELINE_PREFLIGHT_FAILED",
@@ -429,12 +540,70 @@ export async function runRecruitPipeline({
429
540
  );
430
541
  }
431
542
 
543
+ ensurePipelineNotAborted(runtimeHooks.signal);
544
+ runtimeHooks.setStage("page_ready", "preflight 完成,开始检查 search 页面就绪状态。");
545
+ runtimeHooks.heartbeat("page_ready");
546
+ const initialPageCheck = await ensureSearchPageReady(workspaceRoot, {
547
+ port: preflight.debug_port
548
+ });
549
+ if (!initialPageCheck.ok) {
550
+ if (
551
+ initialPageCheck.state === "LOGIN_REQUIRED"
552
+ || initialPageCheck.state === "LOGIN_REQUIRED_AFTER_REDIRECT"
553
+ ) {
554
+ return buildFailedResponse(
555
+ "BOSS_LOGIN_REQUIRED",
556
+ "Boss 页面未稳定停留在 search 页面,疑似未登录或登录态失效。请先在当前 Chrome 窗口手动登录 Boss,登录完成后再继续搜索和筛选。",
557
+ {
558
+ search_params: parsed.searchParams,
559
+ screen_params: parsed.screenParams,
560
+ diagnostics: buildProgressDiagnostics({
561
+ preflight,
562
+ totalProcessedCount: 0,
563
+ totalPassedCount: 0,
564
+ roundCount: 0,
565
+ extra: {
566
+ page_state: initialPageCheck.page_state
567
+ }
568
+ })
569
+ }
570
+ );
571
+ }
572
+ return buildFailedResponse(
573
+ "BOSS_SEARCH_PAGE_NOT_READY",
574
+ "无法确认 Boss search 页面已就绪。请先确保 Chrome 调试端口可连,并且页面能稳定停留在 https://www.zhipin.com/web/chat/search。",
575
+ {
576
+ search_params: parsed.searchParams,
577
+ screen_params: parsed.screenParams,
578
+ diagnostics: buildProgressDiagnostics({
579
+ preflight,
580
+ totalProcessedCount: 0,
581
+ totalPassedCount: 0,
582
+ roundCount: 0,
583
+ extra: {
584
+ page_state: initialPageCheck.page_state
585
+ }
586
+ })
587
+ }
588
+ );
589
+ }
590
+
591
+ if (runtimeHooks.precheckOnly) {
592
+ return {
593
+ status: PIPELINE_STATUS_READY_TO_START_ASYNC,
594
+ search_params: parsed.searchParams,
595
+ screen_params: parsed.screenParams,
596
+ message: "前置门禁检查通过,可启动异步流水线。"
597
+ };
598
+ }
599
+
432
600
  let totalProcessedCount = 0;
433
601
  let totalPassedCount = 0;
434
602
  let roundCount = 0;
435
603
  const roundOutputCsvPaths = [];
436
604
 
437
605
  while (totalProcessedCount < initialTargetCount) {
606
+ ensurePipelineNotAborted(runtimeHooks.signal);
438
607
  roundCount += 1;
439
608
 
440
609
  const remainingTargetCount = Math.max(0, initialTargetCount - totalProcessedCount);
@@ -447,9 +616,17 @@ export async function runRecruitPipeline({
447
616
  target_count: remainingTargetCount
448
617
  };
449
618
 
450
- const pageCheck = await ensureSearchPageReady(workspaceRoot, {
451
- port: preflight.debug_port
452
- });
619
+ if (roundCount > 1) {
620
+ runtimeHooks.setStage("page_ready", `第 ${roundCount} 轮:检查 search 页面就绪状态。`);
621
+ runtimeHooks.heartbeat("page_ready", {
622
+ round: roundCount
623
+ });
624
+ }
625
+ const pageCheck = roundCount === 1
626
+ ? initialPageCheck
627
+ : await ensureSearchPageReady(workspaceRoot, {
628
+ port: preflight.debug_port
629
+ });
453
630
  if (!pageCheck.ok) {
454
631
  if (
455
632
  pageCheck.state === "LOGIN_REQUIRED"
@@ -493,10 +670,20 @@ export async function runRecruitPipeline({
493
670
  );
494
671
  }
495
672
 
673
+ ensurePipelineNotAborted(runtimeHooks.signal);
674
+ runtimeHooks.setStage("search", `第 ${roundCount} 轮:开始执行 search。`);
675
+ runtimeHooks.heartbeat("search", {
676
+ round: roundCount
677
+ });
496
678
  const searchResult = await searchCli({
497
679
  workspaceRoot,
498
- searchParams: roundSearchParams
680
+ searchParams: roundSearchParams,
681
+ runtime: runtimeHooks.adapterRuntime("search")
499
682
  });
683
+ ensurePipelineNotAborted(runtimeHooks.signal);
684
+ if (isProcessAbortError(searchResult)) {
685
+ throw new PipelineAbortError("搜索流程已取消。");
686
+ }
500
687
 
501
688
  if (!searchResult.ok) {
502
689
  const failure = classifySearchFailure(searchResult);
@@ -545,6 +732,14 @@ export async function runRecruitPipeline({
545
732
 
546
733
  const exhaustedByTipNoData = searchResult.no_data_tip_present === true;
547
734
  if (exhaustedByTipNoData || searchResult.candidate_count === 0) {
735
+ runtimeHooks.setStage("finalize", "候选池已耗尽,正在汇总结果。");
736
+ runtimeHooks.heartbeat("finalize");
737
+ runtimeHooks.progress("finalize", {
738
+ processed: totalProcessedCount,
739
+ passed: totalPassedCount,
740
+ skipped: Math.max(totalProcessedCount - totalPassedCount, 0),
741
+ greet_count: 0
742
+ });
548
743
  const durationSec = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
549
744
  const mergedCsvPath = mergeRoundCsvFiles(roundOutputCsvPaths);
550
745
  return {
@@ -568,10 +763,20 @@ export async function runRecruitPipeline({
568
763
  };
569
764
  }
570
765
 
766
+ ensurePipelineNotAborted(runtimeHooks.signal);
767
+ runtimeHooks.setStage("screen", `第 ${roundCount} 轮:开始执行 screen。`);
768
+ runtimeHooks.heartbeat("screen", {
769
+ round: roundCount
770
+ });
571
771
  const screenResult = await screenCli({
572
772
  workspaceRoot,
573
- screenParams: roundScreenParams
773
+ screenParams: roundScreenParams,
774
+ runtime: runtimeHooks.adapterRuntime("screen")
574
775
  });
776
+ ensurePipelineNotAborted(runtimeHooks.signal);
777
+ if (isProcessAbortError(screenResult)) {
778
+ throw new PipelineAbortError("筛选流程已取消。");
779
+ }
575
780
 
576
781
  if (!screenResult.ok) {
577
782
  const failure = classifyScreenFailure(screenResult);
@@ -634,10 +839,24 @@ export async function runRecruitPipeline({
634
839
  : 0;
635
840
  totalProcessedCount += roundProcessedCount;
636
841
  totalPassedCount += roundPassedCount;
842
+ runtimeHooks.progress("screen", {
843
+ processed: totalProcessedCount,
844
+ passed: totalPassedCount,
845
+ skipped: Math.max(totalProcessedCount - totalPassedCount, 0),
846
+ greet_count: 0
847
+ });
637
848
  }
638
849
 
850
+ runtimeHooks.setStage("finalize", "筛选完成,正在汇总结果。");
851
+ runtimeHooks.heartbeat("finalize");
639
852
  const durationSec = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
640
853
  const mergedCsvPath = mergeRoundCsvFiles(roundOutputCsvPaths);
854
+ runtimeHooks.progress("finalize", {
855
+ processed: totalProcessedCount,
856
+ passed: totalPassedCount,
857
+ skipped: Math.max(totalProcessedCount - totalPassedCount, 0),
858
+ greet_count: 0
859
+ });
641
860
  return {
642
861
  status: "COMPLETED",
643
862
  search_params: parsed.searchParams,