@reconcrap/boss-recommend-mcp 2.0.6 → 2.0.8

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
@@ -98,6 +98,8 @@ boss-recommend-mcp list-jobs --slow-live --port 9222
98
98
  - 不会对每位候选人重复确认
99
99
  - 推荐页详情处理完成后,会强制关闭详情页并确认已关闭
100
100
  - 简历提取优先使用 Network 响应;没有可解析 Network CV 时,回退到完整滚动截图序列再交给多模态模型判断
101
+ - recommend / search / chat 正式运行默认全部使用 `screening-config.json` 配置的 LLM 筛选;deterministic/local scorer 只保留给明确测试场景,必须显式传 `debug_test_mode=true` 且 `screening_mode=deterministic` 或 `use_llm=false`。
102
+ - `detail_limit=0`、`no_filter`、`filter_enabled=false`、后置动作 dry-run、chat 求简历 dry-run 等调试路径不会在正式 live run 默认启用;需要测试时必须显式传 `debug_test_mode=true`。
101
103
  - 提供显式运维自愈工具:只在手动调用 `run_recommend_self_heal` 时运行,不会接入正常 run / doctor / preflight 自动链路
102
104
  - 运行前会自动做依赖体检(Node.js、Python、Pillow、`chrome-remote-interface`、`ws`),缺失时会在 `doctor` 与流水线失败诊断中明确提示
103
105
  - 若 preflight 失败,返回 `diagnostics.recovery`(含有序修复步骤与 `agent_prompt`),可直接交给 AI agent 自动按顺序安装依赖
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "2.0.6",
3
+ "version": "2.0.8",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
package/src/chat-mcp.js CHANGED
@@ -308,8 +308,15 @@ function ensureChatRunArtifacts(snapshot) {
308
308
  if (meta) meta.checkpointPath = artifacts.checkpoint_path;
309
309
 
310
310
  const summary = snapshot?.summary && typeof snapshot.summary === "object" ? snapshot.summary : null;
311
- if (summary) {
312
- const rows = Array.isArray(summary.results) ? summary.results : [];
311
+ const checkpointResults = Array.isArray(checkpoint.results) ? checkpoint.results : [];
312
+ const artifactSummary = summary || (checkpointResults.length ? {
313
+ domain: "chat",
314
+ partial: true,
315
+ partial_reason: snapshot?.status || snapshot?.state || "non_terminal",
316
+ results: checkpointResults
317
+ } : null);
318
+ if (artifactSummary) {
319
+ const rows = Array.isArray(artifactSummary.results) ? artifactSummary.results : [];
313
320
  writeChatLegacyCsvAtomic(artifacts.output_csv, rows, snapshot, meta);
314
321
  writeJsonAtomic(artifacts.report_json, {
315
322
  run_id: snapshot.runId || snapshot.run_id,
@@ -318,7 +325,7 @@ function ensureChatRunArtifacts(snapshot) {
318
325
  progress: snapshot.progress || {},
319
326
  context: snapshot.context || {},
320
327
  checkpoint,
321
- summary,
328
+ summary: artifactSummary,
322
329
  generated_at: new Date().toISOString()
323
330
  });
324
331
  if (meta) {
@@ -335,6 +342,12 @@ function buildLegacyChatResult(snapshot) {
335
342
  const artifacts = ensureChatRunArtifacts(snapshot);
336
343
  const meta = getChatRunMeta(snapshot.runId);
337
344
  const summary = snapshot.summary && typeof snapshot.summary === "object" ? snapshot.summary : null;
345
+ const checkpoint = snapshot.checkpoint && typeof snapshot.checkpoint === "object" ? snapshot.checkpoint : {};
346
+ const resultRows = Array.isArray(summary?.results)
347
+ ? summary.results
348
+ : Array.isArray(checkpoint.results)
349
+ ? checkpoint.results
350
+ : [];
338
351
  const progress = normalizeLegacyProgress(snapshot.progress, summary);
339
352
  return {
340
353
  run_id: snapshot.runId,
@@ -358,7 +371,7 @@ function buildLegacyChatResult(snapshot) {
358
371
  completed_at: snapshot.completedAt || null,
359
372
  duration_sec: secondsBetween(snapshot.startedAt, snapshot.completedAt),
360
373
  error: snapshot.error || null,
361
- results: Array.isArray(summary?.results) ? summary.results : []
374
+ results: resultRows
362
375
  };
363
376
  }
364
377
 
@@ -788,16 +801,31 @@ function shouldRequestChatResume(args = {}) {
788
801
  );
789
802
  }
790
803
 
791
- function shouldUseChatLlm(args = {}, shouldRequestResume = false) {
792
- if (args.use_llm === false) return false;
793
- return (
794
- args.use_llm === true
795
- || shouldRequestResume
796
- || parseNonNegativeInteger(args.detail_limit, 0) > 0
797
- );
804
+ function isDebugTestMode(args = {}) {
805
+ return args.debug_test_mode === true || args.allow_debug_test_mode === true;
806
+ }
807
+
808
+ function normalizeScreeningModeArg(args = {}) {
809
+ const raw = normalizeText(args.screening_mode || args.screeningMode || "");
810
+ if (args.use_llm === false) return "deterministic";
811
+ return ["deterministic", "local", "local_scorer"].includes(raw.toLowerCase())
812
+ ? "deterministic"
813
+ : "llm";
814
+ }
815
+
816
+ function collectChatDebugTestOptions(args = {}) {
817
+ const reasons = [];
818
+ if (normalizeScreeningModeArg(args) === "deterministic") reasons.push("deterministic_screening");
819
+ if (parseNonNegativeInteger(args.detail_limit, null) === 0) reasons.push("detail_limit=0");
820
+ if (args.dry_run === true || args.dry_run_request_cv === true) reasons.push("dry_run_request_cv");
821
+ return reasons;
822
+ }
823
+
824
+ function shouldUseChatLlm(args = {}) {
825
+ return normalizeScreeningModeArg(args) !== "deterministic";
798
826
  }
799
827
 
800
- function getRunOptions(args, normalized, session, { workspaceRoot = "" } = {}) {
828
+ function getRunOptions(args, normalized, session, { workspaceRoot = "", configResolution = null } = {}) {
801
829
  const slowLive = args.slow_live === true;
802
830
  const isAllTarget = normalized.publicTargetCount === "all";
803
831
  const processedLimit = parsePositiveInteger(
@@ -805,8 +833,8 @@ function getRunOptions(args, normalized, session, { workspaceRoot = "" } = {}) {
805
833
  isAllTarget ? CHAT_ALL_MAX_CANDIDATES : CHAT_ALL_MAX_CANDIDATES
806
834
  );
807
835
  const shouldRequestResume = shouldRequestChatResume(args);
808
- const useLlm = shouldUseChatLlm(args, shouldRequestResume);
809
- const configResolution = useLlm ? resolveBossScreeningConfig(workspaceRoot) : { ok: false };
836
+ const useLlm = shouldUseChatLlm(args);
837
+ const resolvedConfig = configResolution || (useLlm ? resolveBossScreeningConfig(workspaceRoot) : { ok: false });
810
838
  return {
811
839
  client: session.client,
812
840
  targetUrl: CHAT_TARGET_URL,
@@ -832,17 +860,19 @@ function getRunOptions(args, normalized, session, { workspaceRoot = "" } = {}) {
832
860
  resumeDomTimeoutMs: slowLive ? 120000 : 60000,
833
861
  maxImagePages: parsePositiveInteger(args.max_image_pages, 8),
834
862
  imageWheelDeltaY: parsePositiveInteger(args.image_wheel_delta_y, 650),
835
- llmConfig: configResolution.ok ? {
836
- ...configResolution.config
863
+ llmConfig: resolvedConfig.ok ? {
864
+ ...resolvedConfig.config
837
865
  } : null,
838
866
  llmTimeoutMs: parsePositiveInteger(args.llm_timeout_ms, slowLive ? 180000 : 120000),
839
867
  llmImageLimit: parsePositiveInteger(args.llm_image_limit, 8),
840
868
  llmImageDetail: normalizeText(args.llm_image_detail) || "high",
869
+ screeningMode: normalizeScreeningModeArg(args),
841
870
  listMaxScrolls: parsePositiveInteger(args.list_max_scrolls, 200),
842
871
  listStableSignatureLimit: parsePositiveInteger(args.list_stable_signature_limit, 2),
843
872
  listWheelDeltaY: parsePositiveInteger(args.list_wheel_delta_y, 850),
844
873
  listSettleMs: parsePositiveInteger(args.list_settle_ms, slowLive ? 1800 : 1200),
845
874
  listFallbackPoint: null,
875
+ imageOutputDir: resolveBossConfiguredOutputDir("", getChatRunsDir()),
846
876
  name: "mcp-boss-chat-run"
847
877
  };
848
878
  }
@@ -905,7 +935,19 @@ async function startBossChatRunInternal(args = {}, { workspaceRoot = "" } = {})
905
935
  }
906
936
 
907
937
  const shouldRequestResume = shouldRequestChatResume(args);
908
- const useLlm = shouldUseChatLlm(args, shouldRequestResume);
938
+ const useLlm = shouldUseChatLlm(args);
939
+ const debugTestOptions = collectChatDebugTestOptions(args);
940
+ if (debugTestOptions.length && !isDebugTestMode(args)) {
941
+ return {
942
+ status: "FAILED",
943
+ error: {
944
+ code: "DEBUG_TEST_MODE_REQUIRED",
945
+ message: `这些参数属于调试/测试路径,正式 live run 不会默认启用:${debugTestOptions.join(", ")}。如确需测试,请显式传 debug_test_mode=true。`,
946
+ retryable: false
947
+ },
948
+ debug_test_options: debugTestOptions
949
+ };
950
+ }
909
951
  const configResolution = useLlm ? resolveBossScreeningConfig(workspaceRoot) : null;
910
952
  if (useLlm && !configResolution?.ok) {
911
953
  return {
@@ -948,7 +990,7 @@ async function startBossChatRunInternal(args = {}, { workspaceRoot = "" } = {})
948
990
 
949
991
  let started;
950
992
  try {
951
- started = chatRunService.startChatRun(getRunOptions(args, normalized, session, { workspaceRoot }));
993
+ started = chatRunService.startChatRun(getRunOptions(args, normalized, session, { workspaceRoot, configResolution }));
952
994
  } catch (error) {
953
995
  await session.close?.();
954
996
  return {
@@ -0,0 +1,199 @@
1
+ import { htmlToText, normalizeText } from "../screening/index.js";
2
+
3
+ function uniqueTexts(values = []) {
4
+ return Array.from(new Set(values.map((value) => normalizeText(value)).filter(Boolean)));
5
+ }
6
+
7
+ function classList(value = "") {
8
+ return String(value || "").split(/\s+/).map((item) => item.trim()).filter(Boolean);
9
+ }
10
+
11
+ function hasAllClasses(classValue = "", requiredClasses = []) {
12
+ const classes = classList(classValue);
13
+ return requiredClasses.every((required) => classes.includes(required));
14
+ }
15
+
16
+ function findClassAttributeIndex(html = "", requiredClasses = [], startIndex = 0) {
17
+ const regex = /class=(["'])(.*?)\1/gi;
18
+ regex.lastIndex = Math.max(0, Number(startIndex) || 0);
19
+ let match;
20
+ while ((match = regex.exec(String(html || "")))) {
21
+ if (hasAllClasses(match[2], requiredClasses)) return match.index;
22
+ }
23
+ return -1;
24
+ }
25
+
26
+ function sectionByClasses(html = "", startClasses = [], endClassGroups = []) {
27
+ const source = String(html || "");
28
+ const classIndex = findClassAttributeIndex(source, startClasses);
29
+ if (classIndex < 0) return "";
30
+ const start = Math.max(0, source.lastIndexOf("<", classIndex));
31
+ let end = source.length;
32
+ for (const group of endClassGroups) {
33
+ const found = findClassAttributeIndex(source, group, classIndex + 1);
34
+ if (found >= 0) {
35
+ const tagStart = source.lastIndexOf("<", found);
36
+ end = Math.min(end, tagStart >= 0 ? tagStart : found);
37
+ }
38
+ }
39
+ return source.slice(start, end);
40
+ }
41
+
42
+ function textFromHtmlFragment(fragment = "") {
43
+ return normalizeText(htmlToText(fragment).replace(/\n+/g, " "));
44
+ }
45
+
46
+ function stripNameSuffixes(value = "") {
47
+ return normalizeText(value)
48
+ .replace(/\s*(在线|刚刚活跃|今日活跃|本周活跃|本月活跃)$/u, "")
49
+ .trim();
50
+ }
51
+
52
+ function extractFirstSpanWithClass(html = "", className = "") {
53
+ const regex = /<span\b[^>]*class=(["'])(.*?)\1[^>]*>([\s\S]*?)<\/span>/gi;
54
+ let match;
55
+ while ((match = regex.exec(String(html || "")))) {
56
+ if (classList(match[2]).includes(className)) {
57
+ return textFromHtmlFragment(match[3]);
58
+ }
59
+ }
60
+ return "";
61
+ }
62
+
63
+ function extractSpanTexts(fragment = "") {
64
+ const values = [];
65
+ const regex = /<span\b[^>]*>([\s\S]*?)<\/span>/gi;
66
+ let match;
67
+ while ((match = regex.exec(String(fragment || "")))) {
68
+ values.push(textFromHtmlFragment(match[1]));
69
+ }
70
+ return uniqueTexts(values);
71
+ }
72
+
73
+ function extractDivTextsWithClasses(fragment = "", requiredClasses = []) {
74
+ const values = [];
75
+ const regex = /<div\b[^>]*class=(["'])(.*?)\1[^>]*>([\s\S]*?)<\/div>/gi;
76
+ let match;
77
+ while ((match = regex.exec(String(fragment || "")))) {
78
+ if (hasAllClasses(match[2], requiredClasses)) {
79
+ values.push(extractSpanTexts(match[3]));
80
+ }
81
+ }
82
+ return values.filter((items) => items.length);
83
+ }
84
+
85
+ function parseAgeValue(value = "") {
86
+ const match = normalizeText(value).match(/^(\d{2})岁$/u);
87
+ if (!match) return null;
88
+ const age = Number.parseInt(match[1], 10);
89
+ return Number.isFinite(age) ? age : null;
90
+ }
91
+
92
+ function parseDegreeValue(value = "") {
93
+ const normalized = normalizeText(value);
94
+ const match = normalized.match(/博士|硕士|本科|大专|专科|高中|中专\/中技|中专|中技|初中及以下/u);
95
+ return match ? match[0] : "";
96
+ }
97
+
98
+ function isSalaryLike(value = "") {
99
+ const normalized = normalizeText(value);
100
+ return Boolean(
101
+ /^(?:面议|薪资面议)$/i.test(normalized)
102
+ || /^\d+(?:\.\d+)?(?:\s*-\s*\d+(?:\.\d+)?)?\s*[kK](?:\s*[·xX*]\s*\d+\s*薪?)?$/.test(normalized)
103
+ || /^\d+\s*-\s*\d+\s*元\s*\/\s*天$/.test(normalized)
104
+ );
105
+ }
106
+
107
+ function extractSalary(html = "") {
108
+ const section = sectionByClasses(html, ["salary-wrap"], [
109
+ ["name-wrap"],
110
+ ["col-2"]
111
+ ]);
112
+ return extractSpanTexts(section).find(isSalaryLike) || "";
113
+ }
114
+
115
+ function extractBaseInfo(html = "") {
116
+ const section = sectionByClasses(html, ["base-info"], [
117
+ ["expect-wrap"],
118
+ ["geek-desc"],
119
+ ["timeline-wrap"]
120
+ ]);
121
+ const parts = extractSpanTexts(section);
122
+ return {
123
+ parts,
124
+ age: parts.map(parseAgeValue).find((value) => value != null) ?? null,
125
+ degree: parts.map(parseDegreeValue).find(Boolean) || ""
126
+ };
127
+ }
128
+
129
+ function extractFirstTimelineContent(html = "", timelineClass = "") {
130
+ const section = sectionByClasses(html, ["timeline-wrap", timelineClass], [
131
+ timelineClass === "work-exps" ? ["timeline-wrap", "edu-exps"] : ["card-btns"],
132
+ ["action-wrap"]
133
+ ]);
134
+ const contentRows = extractDivTextsWithClasses(section, ["join-text-wrap", "content"]);
135
+ return contentRows[0] || [];
136
+ }
137
+
138
+ function extractTagTexts(html = "") {
139
+ const tags = [];
140
+ const regex = /<span\b[^>]*class=(["'])(.*?)\1[^>]*>([\s\S]*?)<\/span>/gi;
141
+ let match;
142
+ while ((match = regex.exec(String(html || "")))) {
143
+ if (classList(match[2]).includes("tag-item")) {
144
+ tags.push(textFromHtmlFragment(match[3]));
145
+ }
146
+ }
147
+ return uniqueTexts(tags);
148
+ }
149
+
150
+ export function parseBossCandidateCardFieldsFromHtml(html = "") {
151
+ const name = stripNameSuffixes(extractFirstSpanWithClass(html, "name"));
152
+ const baseInfo = extractBaseInfo(html);
153
+ const work = extractFirstTimelineContent(html, "work-exps");
154
+ const education = extractFirstTimelineContent(html, "edu-exps");
155
+ const educationDegree = education.map(parseDegreeValue).find(Boolean) || "";
156
+ return {
157
+ identity: {
158
+ name: name && !isSalaryLike(name) ? name : "",
159
+ current_company: work[0] || "",
160
+ current_position: work[1] || "",
161
+ school: education[0] || "",
162
+ major: education[1] || "",
163
+ degree: educationDegree || baseInfo.degree || "",
164
+ age: baseInfo.age
165
+ },
166
+ salary: extractSalary(html),
167
+ base_info: baseInfo.parts,
168
+ work,
169
+ education,
170
+ tags: extractTagTexts(html)
171
+ };
172
+ }
173
+
174
+ export function mergeBossCandidateCardFields(candidate, outerHTML = "", {
175
+ metadataKey = "boss_card_fields"
176
+ } = {}) {
177
+ const parsed = parseBossCandidateCardFieldsFromHtml(outerHTML);
178
+ const identity = { ...(candidate.identity || {}) };
179
+ for (const [key, value] of Object.entries(parsed.identity || {})) {
180
+ if (value !== "" && value !== null && value !== undefined) {
181
+ identity[key] = value;
182
+ }
183
+ }
184
+ return {
185
+ ...candidate,
186
+ identity,
187
+ tags: uniqueTexts([...(candidate.tags || []), ...(parsed.tags || [])]),
188
+ metadata: {
189
+ ...(candidate.metadata || {}),
190
+ [metadataKey]: {
191
+ salary: parsed.salary || "",
192
+ base_info: parsed.base_info || [],
193
+ work: parsed.work || [],
194
+ education: parsed.education || [],
195
+ tags: parsed.tags || []
196
+ }
197
+ }
198
+ };
199
+ }
@@ -163,11 +163,13 @@ export async function captureScrolledNodeScreenshots(client, nodeId, {
163
163
  metadata = {}
164
164
  } = {}) {
165
165
  if (!nodeId) throw new Error("captureScrolledNodeScreenshots requires nodeId");
166
+ const sequenceStarted = Date.now();
166
167
  const screenshots = [];
167
168
  let consecutiveDuplicates = 0;
168
169
  let previousHash = "";
169
170
 
170
171
  for (let index = 0; index < Math.max(1, Number(maxScreenshots) || 1); index += 1) {
172
+ const captureStarted = Date.now();
171
173
  const box = await getNodeBox(client, nodeId);
172
174
  const clip = withPadding(box.rect, padding);
173
175
  const captureOptions = {
@@ -202,6 +204,7 @@ export async function captureScrolledNodeScreenshots(client, nodeId, {
202
204
  format,
203
205
  mime_type: `image/${format === "jpeg" ? "jpeg" : "png"}`,
204
206
  byte_length: buffer.length,
207
+ elapsed_ms: Date.now() - captureStarted,
205
208
  file_path: outputPath,
206
209
  sha256: hash,
207
210
  duplicate_of_previous: Boolean(duplicateOfPrevious),
@@ -238,6 +241,7 @@ export async function captureScrolledNodeScreenshots(client, nodeId, {
238
241
  source: "image-scroll-sequence",
239
242
  captured_at: nowIso(),
240
243
  node_id: nodeId,
244
+ elapsed_ms: Date.now() - sequenceStarted,
241
245
  screenshot_count: screenshots.length,
242
246
  unique_screenshot_count: new Set(screenshots.map((item) => item.sha256)).size,
243
247
  file_paths: screenshots.map((item) => item.file_path).filter(Boolean),
@@ -125,6 +125,7 @@ export function summarizeImageEvidence(imageEvidence = null) {
125
125
  if (!imageEvidence) return null;
126
126
  return {
127
127
  source: imageEvidence.source || "",
128
+ elapsed_ms: imageEvidence.elapsed_ms || 0,
128
129
  screenshot_count: imageEvidence.screenshot_count || 0,
129
130
  unique_screenshot_count: imageEvidence.unique_screenshot_count || 0,
130
131
  file_paths: imageEvidence.file_paths || [],
@@ -227,8 +227,17 @@ function pickCandidate(row = {}) {
227
227
 
228
228
  function timingValue(row = {}, ...keys) {
229
229
  const timings = row.timings || row.timing || {};
230
+ const detail = row.detail || {};
231
+ const acquisition = detail.cv_acquisition || {};
232
+ const fallbackByKey = {
233
+ network_cv_wait_ms: acquisition.network_wait?.elapsed_ms,
234
+ screenshot_capture_ms: acquisition.image_evidence?.elapsed_ms || detail.image_evidence?.elapsed_ms,
235
+ dom_fallback_ms: acquisition.content_wait?.elapsed_ms,
236
+ close_detail_ms: detail.close_result?.elapsed_ms,
237
+ post_action_ms: row.post_action?.elapsed_ms
238
+ };
230
239
  for (const key of keys) {
231
- const value = firstDefined(row[key], timings[key]);
240
+ const value = firstDefined(row[key], timings[key], fallbackByKey[key]);
232
241
  if (value !== "") return value;
233
242
  }
234
243
  return "";
@@ -284,8 +293,8 @@ export function legacyScreenResultRow(row = {}) {
284
293
  totalEvidence,
285
294
  totalEvidence,
286
295
  "",
287
- row.error_code || error.code || error.name || "",
288
- row.error_message || error.message || "",
296
+ row.error_code || error.code || error.name || (llm.error ? "LLM_SCREENING_ERROR" : ""),
297
+ row.error_message || error.message || llm.error || "",
289
298
  candidate.id || row.candidate_id || "",
290
299
  timingValue(row, "total_ms"),
291
300
  timingValue(row, "card_read_ms"),
@@ -0,0 +1,33 @@
1
+ import path from "node:path";
2
+
3
+ export function addTiming(timings, key, value) {
4
+ if (!timings || !key) return;
5
+ const numeric = Number(value);
6
+ if (!Number.isFinite(numeric) || numeric < 0) return;
7
+ timings[key] = (Number(timings[key]) || 0) + Math.round(numeric);
8
+ }
9
+
10
+ export async function measureTiming(timings, key, task) {
11
+ const started = Date.now();
12
+ try {
13
+ return await task();
14
+ } finally {
15
+ addTiming(timings, key, Date.now() - started);
16
+ }
17
+ }
18
+
19
+ export function imageEvidenceFilePath({
20
+ imageOutputDir = "",
21
+ domain = "candidate",
22
+ runId = "",
23
+ index = 0,
24
+ extension = "png"
25
+ } = {}) {
26
+ const dir = String(imageOutputDir || "").trim();
27
+ if (!dir) return "";
28
+ const safeDomain = String(domain || "candidate").replace(/[^\w.-]+/g, "_");
29
+ const safeRunId = String(runId || `${safeDomain}-run`).replace(/[^\w.-]+/g, "_");
30
+ const safeIndex = String((Number(index) || 0) + 1).padStart(3, "0");
31
+ const safeExt = String(extension || "png").replace(/^\./, "") || "png";
32
+ return path.join(dir, safeRunId, `${safeDomain}-candidate-${safeIndex}.${safeExt}`);
33
+ }
@@ -206,11 +206,52 @@ function parseDateLike(value) {
206
206
  return normalized;
207
207
  }
208
208
 
209
+ function isLikelySalaryLine(value = "") {
210
+ const normalized = normalizeText(value);
211
+ return Boolean(
212
+ /^(?:面议|薪资面议)$/i.test(normalized)
213
+ || /^\d+(?:\.\d+)?(?:\s*-\s*\d+(?:\.\d+)?)?\s*[kK](?:\s*[·xX*]\s*\d+\s*薪?)?$/.test(normalized)
214
+ || /^\d+\s*-\s*\d+\s*元\s*\/\s*天$/.test(normalized)
215
+ );
216
+ }
217
+
218
+ function isLikelyStatusLine(value = "") {
219
+ const normalized = normalizeText(value);
220
+ return Boolean(
221
+ !normalized
222
+ || /^沟通|^收藏|^查看|^不合适/.test(normalized)
223
+ || /^(?:在线|刚刚活跃|今日活跃|本周活跃|本月活跃|继续沟通|打招呼)$/.test(normalized)
224
+ );
225
+ }
226
+
227
+ function stripLeadingSalaryToken(value = "") {
228
+ return normalizeText(value)
229
+ .replace(/^(?:面议|薪资面议)\s+/i, "")
230
+ .replace(/^\d+(?:\.\d+)?(?:\s*-\s*\d+(?:\.\d+)?)?\s*[kK](?:\s*[·xX*]\s*\d+\s*薪?)?\s+/, "")
231
+ .replace(/^\d+\s*-\s*\d+\s*元\s*\/\s*天\s+/, "")
232
+ .trim();
233
+ }
234
+
235
+ function stripTrailingStatusToken(value = "") {
236
+ return normalizeText(value)
237
+ .replace(/\s*(?:在线|刚刚活跃|今日活跃|本周活跃|本月活跃|继续沟通|打招呼)$/u, "")
238
+ .trim();
239
+ }
240
+
241
+ function cleanInferredNameLine(value = "") {
242
+ const withoutSalary = stripLeadingSalaryToken(value);
243
+ const withoutStatus = stripTrailingStatusToken(withoutSalary);
244
+ return withoutStatus && !isLikelyStatusLine(withoutStatus) && !isLikelySalaryLine(withoutStatus)
245
+ ? withoutStatus
246
+ : "";
247
+ }
248
+
209
249
  function firstUsefulLine(lines) {
210
- return lines.find((line) => {
211
- const normalized = normalizeText(line);
212
- return normalized && !/^沟通|^收藏|^查看|^不合适/.test(normalized);
213
- }) || null;
250
+ for (const line of lines) {
251
+ const cleaned = cleanInferredNameLine(line);
252
+ if (cleaned) return cleaned;
253
+ }
254
+ return null;
214
255
  }
215
256
 
216
257
  function parseNetworkBodyText(networkBody = {}) {
@@ -834,7 +875,8 @@ export function normalizeCandidateProfile(input = {}) {
834
875
  || attrs.href
835
876
  || ""
836
877
  ) || null;
837
- const inferredName = normalizeText(input.identity?.name || input.name || firstUsefulLine(lines) || "") || null;
878
+ const explicitName = cleanInferredNameLine(input.identity?.name || input.name || "");
879
+ const inferredName = explicitName || firstUsefulLine(lines) || null;
838
880
  const fullText = collectTextParts({
839
881
  ...input,
840
882
  text: rawText,
@@ -1003,6 +1045,54 @@ export function screenCandidate(candidateInput, criteria = {}) {
1003
1045
  };
1004
1046
  }
1005
1047
 
1048
+ export function compactScreeningLlmResult(llmResult) {
1049
+ if (!llmResult) return null;
1050
+ return {
1051
+ ok: Boolean(llmResult.ok),
1052
+ provider: llmResult.provider || null,
1053
+ passed: llmResult.passed,
1054
+ cot: llmResult.cot || llmResult.decision_cot || "",
1055
+ reasoning_content: llmResult.reasoning_content || "",
1056
+ raw_model_output: llmResult.raw_model_output || "",
1057
+ evidence_count: Array.isArray(llmResult.evidence) ? llmResult.evidence.length : 0,
1058
+ usage: llmResult.usage || null,
1059
+ finish_reason: llmResult.finish_reason || null,
1060
+ image_input_count: llmResult.image_input_count || 0,
1061
+ error: llmResult.error || null,
1062
+ screened_at: llmResult.screened_at || null
1063
+ };
1064
+ }
1065
+
1066
+ export function llmResultToScreening(llmResult, candidate) {
1067
+ return {
1068
+ status: llmResult?.passed ? "pass" : "fail",
1069
+ passed: Boolean(llmResult?.passed),
1070
+ score: llmResult?.passed ? 100 : 0,
1071
+ reasons: llmResult?.error ? ["llm_invalid_response"] : [],
1072
+ candidate
1073
+ };
1074
+ }
1075
+
1076
+ export function isRecoverableLlmScreeningError(error) {
1077
+ return /(?:LLM response missing boolean passed decision|LLM response was not valid JSON)/i
1078
+ .test(String(error?.message || error || ""));
1079
+ }
1080
+
1081
+ export function createFailedLlmScreeningResult(error) {
1082
+ return {
1083
+ ok: false,
1084
+ passed: false,
1085
+ reason: "",
1086
+ evidence: [],
1087
+ cot: "",
1088
+ decision_cot: "",
1089
+ reasoning_content: "",
1090
+ raw_model_output: "",
1091
+ error: error?.message || String(error || "unknown"),
1092
+ screened_at: nowIso()
1093
+ };
1094
+ }
1095
+
1006
1096
  export function buildScreeningLlmMessages({
1007
1097
  candidate,
1008
1098
  criteria,
@@ -5,6 +5,7 @@ import {
5
5
  querySelectorAll,
6
6
  sleep
7
7
  } from "../../core/browser/index.js";
8
+ import { mergeBossCandidateCardFields } from "../../core/boss-cards/index.js";
8
9
  import {
9
10
  htmlToText,
10
11
  normalizeCandidateProfile,
@@ -24,6 +25,12 @@ function firstCandidateId(attributes = {}) {
24
25
  ) || null;
25
26
  }
26
27
 
28
+ function mergeChatCardFields(candidate, outerHTML = "") {
29
+ return mergeBossCandidateCardFields(candidate, outerHTML, {
30
+ metadataKey: "chat_card_fields"
31
+ });
32
+ }
33
+
27
34
  export async function findChatCandidateNodeIds(client, rootNodeId, {
28
35
  selectors = CHAT_CARD_SELECTORS
29
36
  } = {}) {
@@ -97,7 +104,7 @@ export async function readChatCardCandidate(client, cardNodeId, {
97
104
  getAttributesMap(client, cardNodeId),
98
105
  getOuterHTML(client, cardNodeId)
99
106
  ]);
100
- return normalizeCandidateProfile({
107
+ const candidate = normalizeCandidateProfile({
101
108
  domain: "chat",
102
109
  source,
103
110
  id: firstCandidateId(attributes),
@@ -110,6 +117,7 @@ export async function readChatCardCandidate(client, cardNodeId, {
110
117
  ...metadata
111
118
  }
112
119
  });
120
+ return mergeChatCardFields(candidate, outerHTML);
113
121
  }
114
122
 
115
123
  export async function readFirstChatCardCandidate(client, rootNodeId, options = {}) {