@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 +2 -0
- package/package.json +1 -1
- package/src/chat-mcp.js +60 -18
- package/src/core/boss-cards/index.js +199 -0
- package/src/core/capture/index.js +4 -0
- package/src/core/cv-acquisition/index.js +1 -0
- package/src/core/reporting/legacy-csv.js +12 -3
- package/src/core/run/timing.js +33 -0
- package/src/core/screening/index.js +95 -5
- package/src/domains/chat/cards.js +9 -1
- package/src/domains/chat/run-service.js +142 -59
- package/src/domains/recommend/cards.js +16 -1
- package/src/domains/recommend/detail.js +11 -1
- package/src/domains/recommend/run-service.js +120 -13
- package/src/domains/recruit/cards.js +9 -1
- package/src/domains/recruit/detail.js +12 -1
- package/src/domains/recruit/run-service.js +127 -20
- package/src/index.js +50 -5
- package/src/recommend-mcp.js +82 -7
- package/src/recruit-mcp.js +104 -8
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
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
|
-
|
|
312
|
-
|
|
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:
|
|
374
|
+
results: resultRows
|
|
362
375
|
};
|
|
363
376
|
}
|
|
364
377
|
|
|
@@ -788,16 +801,31 @@ function shouldRequestChatResume(args = {}) {
|
|
|
788
801
|
);
|
|
789
802
|
}
|
|
790
803
|
|
|
791
|
-
function
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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
|
|
809
|
-
const
|
|
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:
|
|
836
|
-
...
|
|
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
|
|
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
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
}
|
|
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
|
|
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
|
-
|
|
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 = {}) {
|