@reconcrap/boss-recommend-mcp 2.0.7 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "2.0.7",
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
@@ -872,6 +872,7 @@ function getRunOptions(args, normalized, session, { workspaceRoot = "", configRe
872
872
  listWheelDeltaY: parsePositiveInteger(args.list_wheel_delta_y, 850),
873
873
  listSettleMs: parsePositiveInteger(args.list_settle_ms, slowLive ? 1800 : 1200),
874
874
  listFallbackPoint: null,
875
+ imageOutputDir: resolveBossConfiguredOutputDir("", getChatRunsDir()),
875
876
  name: "mcp-boss-chat-run"
876
877
  };
877
878
  }
@@ -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 "";
@@ -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,
@@ -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 = {}) {
@@ -24,6 +24,11 @@ import {
24
24
  } from "../../core/infinite-list/index.js";
25
25
  import { createViewportRunGuard } from "../../core/self-heal/index.js";
26
26
  import { createRunLifecycleManager } from "../../core/run/index.js";
27
+ import {
28
+ addTiming,
29
+ imageEvidenceFilePath,
30
+ measureTiming
31
+ } from "../../core/run/timing.js";
27
32
  import {
28
33
  callScreeningLlm,
29
34
  normalizeText,
@@ -369,7 +374,8 @@ export async function runChatWorkflow({
369
374
  listStableSignatureLimit = 2,
370
375
  listWheelDeltaY = 850,
371
376
  listSettleMs = 1200,
372
- listFallbackPoint = null
377
+ listFallbackPoint = null,
378
+ imageOutputDir = ""
373
379
  } = {}, runControl) {
374
380
  if (!client) throw new Error("runChatWorkflow requires a guarded CDP client");
375
381
  const normalizedDetailSource = normalizeDetailSource(detailSource);
@@ -584,6 +590,8 @@ export async function runChatWorkflow({
584
590
  || results.filter((item) => item.screening?.passed).length < passTarget
585
591
  )
586
592
  ) {
593
+ const candidateStarted = Date.now();
594
+ const timings = {};
587
595
  await runControl.waitIfPaused();
588
596
  runControl.throwIfCanceled();
589
597
  runControl.setPhase("chat:candidate");
@@ -596,7 +604,7 @@ export async function runChatWorkflow({
596
604
  continue;
597
605
  }
598
606
 
599
- const nextCandidateResult = await getNextInfiniteListCandidate({
607
+ const nextCandidateResult = await measureTiming(timings, "card_read_ms", () => getNextInfiniteListCandidate({
600
608
  client,
601
609
  state: listState,
602
610
  maxScrolls: listMaxScrolls,
@@ -623,7 +631,7 @@ export async function runChatWorkflow({
623
631
  visible_index: visibleIndex
624
632
  }
625
633
  })
626
- });
634
+ }));
627
635
  if (!nextCandidateResult.ok) {
628
636
  const endTopLevelState = await getChatTopLevelState(client);
629
637
  if (!endTopLevelState.is_chat_shell) {
@@ -665,11 +673,11 @@ export async function runChatWorkflow({
665
673
 
666
674
  detailStep = "select_candidate";
667
675
  networkRecorder.clear();
668
- const selected = await selectFreshChatCandidate(client, {
676
+ const selected = await measureTiming(timings, "candidate_click_ms", () => selectFreshChatCandidate(client, {
669
677
  cardNodeId,
670
678
  candidate: cardCandidate,
671
679
  timeoutMs: onlineResumeButtonTimeoutMs
672
- });
680
+ }));
673
681
  if (selected.ready?.forbidden_top_level_navigation) {
674
682
  throw makeForbiddenChatResumeNavigationError(selected.ready.top_level_state);
675
683
  }
@@ -696,13 +704,13 @@ export async function runChatWorkflow({
696
704
  if (!detailResult) {
697
705
  detailStep = "open_online_resume";
698
706
  networkRecorder.clear();
699
- const openedResume = await openChatOnlineResume(client, {
707
+ const openedResume = await measureTiming(timings, "detail_open_ms", () => openChatOnlineResume(client, {
700
708
  timeoutMs: readyTimeoutMs
701
- });
709
+ }));
702
710
  const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
703
711
  detailStep = "wait_network";
704
712
  const networkWait = ["network", "cascade"].includes(normalizedDetailSource)
705
- ? await waitForCvNetworkEvents(
713
+ ? await measureTiming(timings, "network_cv_wait_ms", () => waitForCvNetworkEvents(
706
714
  waitForChatProfileNetworkEvents,
707
715
  networkRecorder,
708
716
  {
@@ -711,8 +719,11 @@ export async function runChatWorkflow({
711
719
  requireLoaded: true,
712
720
  intervalMs: 200
713
721
  }
714
- )
722
+ ))
715
723
  : null;
724
+ if (networkWait?.elapsed_ms != null) {
725
+ timings.network_cv_wait_ms = Math.round(Number(networkWait.elapsed_ms) || 0);
726
+ }
716
727
  let contentWait = {
717
728
  ok: false,
718
729
  skipped: false,
@@ -759,10 +770,10 @@ export async function runChatWorkflow({
759
770
 
760
771
  if (!detailResult) {
761
772
  detailStep = "wait_resume_content";
762
- contentWait = await waitForChatResumeContent(client, {
773
+ contentWait = await measureTiming(timings, "dom_fallback_ms", () => waitForChatResumeContent(client, {
763
774
  timeoutMs: resumeDomTimeoutMs,
764
775
  intervalMs: 300
765
- });
776
+ }));
766
777
  resumeState = contentWait.resume_state || openedResume.resume_state;
767
778
  resumeHtml = contentWait.resume_html || null;
768
779
  resumeNetworkEvents = networkRecorder.events.slice();
@@ -792,7 +803,13 @@ export async function runChatWorkflow({
792
803
  if (shouldCaptureImage) {
793
804
  if (captureNodeId) {
794
805
  detailStep = "capture_image_fallback";
795
- imageEvidence = await captureScrolledNodeScreenshots(client, captureNodeId, {
806
+ imageEvidence = await measureTiming(timings, "screenshot_capture_ms", () => captureScrolledNodeScreenshots(client, captureNodeId, {
807
+ filePath: imageEvidenceFilePath({
808
+ imageOutputDir,
809
+ domain: "chat",
810
+ runId: runControl?.runId,
811
+ index
812
+ }),
796
813
  padding: 8,
797
814
  maxScreenshots: maxImagePages,
798
815
  wheelDeltaY: imageWheelDeltaY,
@@ -806,7 +823,7 @@ export async function runChatWorkflow({
806
823
  run_candidate_index: index,
807
824
  candidate_key: candidateKey
808
825
  }
809
- });
826
+ }));
810
827
  source = "image";
811
828
  recordCvImageFallback(cvAcquisitionState, {
812
829
  parsedNetworkProfileCount,
@@ -819,7 +836,7 @@ export async function runChatWorkflow({
819
836
  llmResult = createMissingLlmConfigResult();
820
837
  } else {
821
838
  try {
822
- llmResult = await callScreeningLlm({
839
+ llmResult = await measureTiming(timings, "vision_model_ms", () => callScreeningLlm({
823
840
  candidate: detailResult.candidate,
824
841
  criteria,
825
842
  config: llmConfig,
@@ -827,7 +844,7 @@ export async function runChatWorkflow({
827
844
  imageEvidence,
828
845
  maxImages: llmImageLimit,
829
846
  imageDetail: llmImageDetail
830
- });
847
+ }));
831
848
  } catch (error) {
832
849
  llmResult = createFailedLlmResult(error);
833
850
  }
@@ -861,7 +878,10 @@ export async function runChatWorkflow({
861
878
  llmResult = createMissingLlmConfigResult();
862
879
  } else {
863
880
  try {
864
- llmResult = await callScreeningLlm({
881
+ const llmTimingKey = imageEvidence?.file_paths?.length
882
+ ? "vision_model_ms"
883
+ : "text_model_ms";
884
+ llmResult = await measureTiming(timings, llmTimingKey, () => callScreeningLlm({
865
885
  candidate: detailResult.candidate,
866
886
  criteria,
867
887
  config: llmConfig,
@@ -869,7 +889,7 @@ export async function runChatWorkflow({
869
889
  imageEvidence,
870
890
  maxImages: llmImageLimit,
871
891
  imageDetail: llmImageDetail
872
- });
892
+ }));
873
893
  } catch (error) {
874
894
  llmResult = createFailedLlmResult(error);
875
895
  }
@@ -879,7 +899,7 @@ export async function runChatWorkflow({
879
899
  let closeResult = null;
880
900
  if (closeResume) {
881
901
  detailStep = "close_resume_modal";
882
- closeResult = await closeChatResumeModal(client);
902
+ closeResult = await measureTiming(timings, "close_detail_ms", () => closeChatResumeModal(client));
883
903
  }
884
904
  detailResult.close_result = closeResult;
885
905
  detailResult.image_evidence = imageEvidence;
@@ -927,14 +947,14 @@ export async function runChatWorkflow({
927
947
  cardOnlyLlmResult = createMissingLlmConfigResult();
928
948
  } else {
929
949
  try {
930
- cardOnlyLlmResult = await callScreeningLlm({
950
+ cardOnlyLlmResult = await measureTiming(timings, "text_model_ms", () => callScreeningLlm({
931
951
  candidate: screeningCandidate,
932
952
  criteria,
933
953
  config: llmConfig,
934
954
  timeoutMs: llmTimeoutMs,
935
955
  maxImages: llmImageLimit,
936
956
  imageDetail: llmImageDetail
937
- });
957
+ }));
938
958
  } catch (error) {
939
959
  cardOnlyLlmResult = createFailedLlmResult(error);
940
960
  }
@@ -954,10 +974,10 @@ export async function runChatWorkflow({
954
974
  : screenCandidate(screeningCandidate, { criteria });
955
975
  let postAction = null;
956
976
  if (requestResumeForPassed && screening.passed) {
957
- postAction = await requestChatResumeForPassedCandidate(client, {
977
+ postAction = await measureTiming(timings, "post_action_ms", () => requestChatResumeForPassedCandidate(client, {
958
978
  greetingText,
959
979
  dryRun: dryRunRequestCv
960
- });
980
+ }));
961
981
  if (postAction?.requested) requestSatisfiedCount += 1;
962
982
  if (postAction?.skipped) requestSkippedCount += 1;
963
983
  if (postAction?.requested && !postAction?.skipped) requestedCount += 1;
@@ -965,6 +985,7 @@ export async function runChatWorkflow({
965
985
  throw new Error(`REQUEST_CV_NOT_VERIFIED:${postAction?.reason || "unknown"}`);
966
986
  }
967
987
  }
988
+ timings.total_ms = Date.now() - candidateStarted;
968
989
  const compactResult = {
969
990
  index,
970
991
  candidate_key: candidateKey,
@@ -974,7 +995,8 @@ export async function runChatWorkflow({
974
995
  llm_screening: detailResult ? null : compactLlmResult(cardOnlyLlmResult),
975
996
  screening: compactScreening(screening),
976
997
  post_action: postAction,
977
- pre_action_state: preActionState
998
+ pre_action_state: preActionState,
999
+ timings
978
1000
  };
979
1001
  results.push(compactResult);
980
1002
  markInfiniteListCandidateProcessed(listState, candidateKey, {
@@ -1006,6 +1028,7 @@ export async function runChatWorkflow({
1006
1028
  last_candidate_key: candidateKey,
1007
1029
  last_score: screening.score
1008
1030
  });
1031
+ const checkpointStarted = Date.now();
1009
1032
  runControl.checkpoint({
1010
1033
  results,
1011
1034
  last_candidate: {
@@ -1020,9 +1043,13 @@ export async function runChatWorkflow({
1020
1043
  llm_screening: compactLlmResult(effectiveLlmResult)
1021
1044
  }
1022
1045
  });
1046
+ addTiming(compactResult.timings, "checkpoint_save_ms", Date.now() - checkpointStarted);
1023
1047
 
1024
1048
  if (delayMs > 0) {
1049
+ const sleepStarted = Date.now();
1025
1050
  await runControl.sleep(delayMs);
1051
+ addTiming(compactResult.timings, "sleep_ms", Date.now() - sleepStarted);
1052
+ compactResult.timings.total_ms = Date.now() - candidateStarted;
1026
1053
  }
1027
1054
  }
1028
1055
 
@@ -1095,6 +1122,7 @@ export function createChatRunService({
1095
1122
  listWheelDeltaY = 850,
1096
1123
  listSettleMs = 1200,
1097
1124
  listFallbackPoint = null,
1125
+ imageOutputDir = "",
1098
1126
  name = "chat-domain-run"
1099
1127
  } = {}) {
1100
1128
  if (!client) throw new Error("startChatRun requires a guarded CDP client");
@@ -1130,7 +1158,8 @@ export function createChatRunService({
1130
1158
  list_wheel_delta_y: listWheelDeltaY,
1131
1159
  list_settle_ms: listSettleMs,
1132
1160
  list_fallback_point: listFallbackPoint,
1133
- online_resume_button_timeout_ms: onlineResumeButtonTimeoutMs
1161
+ online_resume_button_timeout_ms: onlineResumeButtonTimeoutMs,
1162
+ image_output_dir: imageOutputDir || ""
1134
1163
  },
1135
1164
  progress: {
1136
1165
  card_count: 0,
@@ -1180,7 +1209,8 @@ export function createChatRunService({
1180
1209
  listStableSignatureLimit,
1181
1210
  listWheelDeltaY,
1182
1211
  listSettleMs,
1183
- listFallbackPoint
1212
+ listFallbackPoint,
1213
+ imageOutputDir
1184
1214
  }, runControl)
1185
1215
  });
1186
1216
  }
@@ -6,6 +6,10 @@ import {
6
6
  querySelectorAll,
7
7
  sleep
8
8
  } from "../../core/browser/index.js";
9
+ import {
10
+ mergeBossCandidateCardFields,
11
+ parseBossCandidateCardFieldsFromHtml
12
+ } from "../../core/boss-cards/index.js";
9
13
  import {
10
14
  htmlToText,
11
15
  normalizeCandidateFromHtml,
@@ -24,6 +28,16 @@ function normalizeRefreshButtonLabel(outerHTML = "") {
24
28
  return normalizeText(htmlToText(outerHTML)).replace(/\s+/g, "");
25
29
  }
26
30
 
31
+ export function parseRecommendCardFieldsFromHtml(html = "") {
32
+ return parseBossCandidateCardFieldsFromHtml(html);
33
+ }
34
+
35
+ function enrichRecommendCardCandidate(candidate, outerHTML = "") {
36
+ return mergeBossCandidateCardFields(candidate, outerHTML, {
37
+ metadataKey: "recommend_card_fields"
38
+ });
39
+ }
40
+
27
41
  function isRefreshButtonLabel(label = "") {
28
42
  const normalized = String(label || "").trim();
29
43
  if (!normalized || normalized.length > 80) return false;
@@ -91,7 +105,7 @@ export async function readRecommendCardCandidate(client, cardNodeId, {
91
105
  getAttributesMap(client, cardNodeId),
92
106
  getOuterHTML(client, cardNodeId)
93
107
  ]);
94
- return normalizeCandidateFromHtml({
108
+ const candidate = normalizeCandidateFromHtml({
95
109
  domain: "recommend",
96
110
  source,
97
111
  html: outerHTML,
@@ -102,6 +116,7 @@ export async function readRecommendCardCandidate(client, cardNodeId, {
102
116
  ...metadata
103
117
  }
104
118
  });
119
+ return enrichRecommendCardCandidate(candidate, outerHTML);
105
120
  }
106
121
 
107
122
  export async function readFirstRecommendCardCandidate(client, frameNodeId, options = {}) {
@@ -279,15 +279,25 @@ export async function openRecommendCardDetail(client, cardNodeId, {
279
279
  timeoutMs = 12000,
280
280
  scrollIntoView = true
281
281
  } = {}) {
282
+ const started = Date.now();
283
+ const clickStarted = Date.now();
282
284
  const cardBox = await clickNodeCenter(client, cardNodeId, { scrollIntoView });
285
+ const candidateClickMs = Date.now() - clickStarted;
286
+ const detailStarted = Date.now();
283
287
  const detailState = await waitForRecommendDetail(client, { timeoutMs });
288
+ const detailOpenMs = Date.now() - detailStarted;
284
289
  if (!detailState?.popup && !detailState?.resumeIframe) {
285
290
  throw new Error("Candidate detail did not open or no known detail selectors mounted");
286
291
  }
287
292
 
288
293
  return {
289
294
  card_box: cardBox,
290
- detail_state: detailState
295
+ detail_state: detailState,
296
+ timings: {
297
+ candidate_click_ms: candidateClickMs,
298
+ detail_open_ms: detailOpenMs,
299
+ open_total_ms: Date.now() - started
300
+ }
291
301
  };
292
302
  }
293
303
 
@@ -1,4 +1,9 @@
1
1
  import { createRunLifecycleManager } from "../../core/run/index.js";
2
+ import {
3
+ addTiming,
4
+ imageEvidenceFilePath,
5
+ measureTiming
6
+ } from "../../core/run/timing.js";
2
7
  import { captureScrolledNodeScreenshots } from "../../core/capture/index.js";
3
8
  import { sleep } from "../../core/browser/index.js";
4
9
  import { GREET_CREDITS_EXHAUSTED_CODE } from "../../core/greet-quota/index.js";
@@ -377,7 +382,8 @@ export async function runRecommendWorkflow({
377
382
  llmConfig = null,
378
383
  llmTimeoutMs = 120000,
379
384
  llmImageLimit = 8,
380
- llmImageDetail = "high"
385
+ llmImageDetail = "high",
386
+ imageOutputDir = ""
381
387
  } = {}, runControl) {
382
388
  if (!client) throw new Error("runRecommendWorkflow requires a guarded CDP client");
383
389
  const normalizedFilter = normalizeFilter(filter);
@@ -520,12 +526,14 @@ export async function runRecommendWorkflow({
520
526
  });
521
527
 
522
528
  while (results.length < limit) {
529
+ const candidateStarted = Date.now();
530
+ const timings = {};
523
531
  await runControl.waitIfPaused();
524
532
  runControl.throwIfCanceled();
525
533
  runControl.setPhase("recommend:candidate");
526
534
  rootState = await ensureRecommendViewport(rootState, "candidate_loop");
527
535
 
528
- const nextCandidateResult = await getNextInfiniteListCandidate({
536
+ const nextCandidateResult = await measureTiming(timings, "card_read_ms", () => getNextInfiniteListCandidate({
529
537
  client,
530
538
  state: listState,
531
539
  maxScrolls: listMaxScrolls,
@@ -552,7 +560,7 @@ export async function runRecommendWorkflow({
552
560
  visible_index: visibleIndex
553
561
  }
554
562
  })
555
- });
563
+ }));
556
564
  if (!nextCandidateResult.ok) {
557
565
  listEndReason = nextCandidateResult.reason || "list_exhausted";
558
566
  if (
@@ -644,11 +652,13 @@ export async function runRecommendWorkflow({
644
652
  targetUrl,
645
653
  maxAttempts: 2
646
654
  });
655
+ addTiming(timings, "candidate_click_ms", openedDetail.timings?.candidate_click_ms);
656
+ addTiming(timings, "detail_open_ms", openedDetail.timings?.detail_open_ms);
647
657
  cardNodeId = openedDetail.card_node_id || cardNodeId;
648
658
  cardCandidate = openedDetail.card_candidate || cardCandidate;
649
659
  screeningCandidate = cardCandidate;
650
660
  const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
651
- const networkWait = await waitForCvNetworkEvents(
661
+ const networkWait = await measureTiming(timings, "network_cv_wait_ms", () => waitForCvNetworkEvents(
652
662
  waitForRecommendDetailNetworkEvents,
653
663
  networkRecorder,
654
664
  {
@@ -657,7 +667,10 @@ export async function runRecommendWorkflow({
657
667
  requireLoaded: true,
658
668
  intervalMs: 120
659
669
  }
660
- );
670
+ ));
671
+ if (networkWait?.elapsed_ms != null) {
672
+ timings.network_cv_wait_ms = Math.round(Number(networkWait.elapsed_ms) || 0);
673
+ }
661
674
  detailResult = await extractRecommendDetailCandidate(client, {
662
675
  cardCandidate,
663
676
  cardNodeId,
@@ -680,7 +693,13 @@ export async function runRecommendWorkflow({
680
693
  || openedDetail.detail_state?.resumeIframe?.node_id
681
694
  || null;
682
695
  if (captureNodeId) {
683
- imageEvidence = await captureScrolledNodeScreenshots(client, captureNodeId, {
696
+ imageEvidence = await measureTiming(timings, "screenshot_capture_ms", () => captureScrolledNodeScreenshots(client, captureNodeId, {
697
+ filePath: imageEvidenceFilePath({
698
+ imageOutputDir,
699
+ domain: "recommend",
700
+ runId: runControl?.runId,
701
+ index
702
+ }),
684
703
  padding: 4,
685
704
  maxScreenshots: maxImagePages,
686
705
  wheelDeltaY: imageWheelDeltaY,
@@ -692,7 +711,7 @@ export async function runRecommendWorkflow({
692
711
  run_candidate_index: index,
693
712
  candidate_key: candidateKey
694
713
  }
695
- });
714
+ }));
696
715
  source = "image";
697
716
  recordCvImageFallback(cvAcquisitionState, {
698
717
  parsedNetworkProfileCount,
@@ -730,7 +749,10 @@ export async function runRecommendWorkflow({
730
749
  llmResult = createMissingLlmConfigResult();
731
750
  } else {
732
751
  try {
733
- llmResult = await callScreeningLlm({
752
+ const llmTimingKey = detailResult?.image_evidence?.file_paths?.length
753
+ ? "vision_model_ms"
754
+ : "text_model_ms";
755
+ llmResult = await measureTiming(timings, llmTimingKey, () => callScreeningLlm({
734
756
  candidate: screeningCandidate,
735
757
  criteria,
736
758
  config: llmConfig,
@@ -738,7 +760,7 @@ export async function runRecommendWorkflow({
738
760
  imageEvidence: detailResult?.image_evidence || null,
739
761
  maxImages: llmImageLimit,
740
762
  imageDetail: llmImageDetail
741
- });
763
+ }));
742
764
  } catch (error) {
743
765
  llmResult = createFailedLlmScreeningResult(error);
744
766
  }
@@ -751,6 +773,7 @@ export async function runRecommendWorkflow({
751
773
  let actionDiscovery = null;
752
774
  let postActionResult = null;
753
775
  if (postActionEnabled && detailResult) {
776
+ const postActionStarted = Date.now();
754
777
  await runControl.waitIfPaused();
755
778
  runControl.throwIfCanceled();
756
779
  runControl.setPhase("recommend:post-action");
@@ -772,10 +795,12 @@ export async function runRecommendWorkflow({
772
795
  if (postActionResult.counted_as_greet && postActionResult.action_clicked) {
773
796
  greetCount += 1;
774
797
  }
798
+ addTiming(timings, "post_action_ms", Date.now() - postActionStarted);
775
799
  }
776
800
  if (detailResult && closeDetail) {
777
- detailResult.close_result = await closeRecommendDetail(client);
801
+ detailResult.close_result = await measureTiming(timings, "close_detail_ms", () => closeRecommendDetail(client));
778
802
  }
803
+ timings.total_ms = Date.now() - candidateStarted;
779
804
  const compactResult = {
780
805
  index,
781
806
  candidate_key: candidateKey,
@@ -785,7 +810,8 @@ export async function runRecommendWorkflow({
785
810
  llm_screening: detailResult ? null : compactScreeningLlmResult(llmResult),
786
811
  screening: compactScreening(screening),
787
812
  action_discovery: compactActionDiscovery(actionDiscovery),
788
- post_action: postActionResult
813
+ post_action: postActionResult,
814
+ timings
789
815
  };
790
816
  results.push(compactResult);
791
817
  markInfiniteListCandidateProcessed(listState, candidateKey, {
@@ -816,6 +842,7 @@ export async function runRecommendWorkflow({
816
842
  last_candidate_key: candidateKey,
817
843
  last_score: screening.score
818
844
  });
845
+ const checkpointStarted = Date.now();
819
846
  runControl.checkpoint({
820
847
  results,
821
848
  last_candidate: {
@@ -831,6 +858,7 @@ export async function runRecommendWorkflow({
831
858
  post_action: postActionResult
832
859
  }
833
860
  });
861
+ addTiming(compactResult.timings, "checkpoint_save_ms", Date.now() - checkpointStarted);
834
862
 
835
863
  if (postActionResult?.stop_run) {
836
864
  listEndReason = postActionResult.reason || "post_action_stop";
@@ -838,7 +866,10 @@ export async function runRecommendWorkflow({
838
866
  }
839
867
 
840
868
  if (delayMs > 0) {
869
+ const sleepStarted = Date.now();
841
870
  await runControl.sleep(delayMs);
871
+ addTiming(compactResult.timings, "sleep_ms", Date.now() - sleepStarted);
872
+ compactResult.timings.total_ms = Date.now() - candidateStarted;
842
873
  }
843
874
  }
844
875
 
@@ -912,6 +943,7 @@ export function createRecommendRunService({
912
943
  llmTimeoutMs = 120000,
913
944
  llmImageLimit = 8,
914
945
  llmImageDetail = "high",
946
+ imageOutputDir = "",
915
947
  name = "recommend-domain-run"
916
948
  } = {}) {
917
949
  if (!client) throw new Error("startRecommendRun requires a guarded CDP client");
@@ -955,7 +987,8 @@ export function createRecommendRunService({
955
987
  llm_configured: Boolean(llmConfig),
956
988
  llm_timeout_ms: llmTimeoutMs,
957
989
  llm_image_limit: llmImageLimit,
958
- llm_image_detail: llmImageDetail
990
+ llm_image_detail: llmImageDetail,
991
+ image_output_dir: imageOutputDir || ""
959
992
  },
960
993
  progress: {
961
994
  card_count: 0,
@@ -1004,7 +1037,8 @@ export function createRecommendRunService({
1004
1037
  llmConfig,
1005
1038
  llmTimeoutMs,
1006
1039
  llmImageLimit,
1007
- llmImageDetail
1040
+ llmImageDetail,
1041
+ imageOutputDir
1008
1042
  }, runControl)
1009
1043
  });
1010
1044
  }
@@ -4,9 +4,16 @@ import {
4
4
  querySelectorAll,
5
5
  sleep
6
6
  } from "../../core/browser/index.js";
7
+ import { mergeBossCandidateCardFields } from "../../core/boss-cards/index.js";
7
8
  import { normalizeCandidateFromHtml } from "../../core/screening/index.js";
8
9
  import { RECRUIT_CARD_SELECTOR } from "./constants.js";
9
10
 
11
+ function mergeRecruitCardFields(candidate, outerHTML = "") {
12
+ return mergeBossCandidateCardFields(candidate, outerHTML, {
13
+ metadataKey: "search_card_fields"
14
+ });
15
+ }
16
+
10
17
  export async function findRecruitCardNodeIds(client, frameNodeId, {
11
18
  selector = RECRUIT_CARD_SELECTOR
12
19
  } = {}) {
@@ -37,7 +44,7 @@ export async function readRecruitCardCandidate(client, cardNodeId, {
37
44
  getAttributesMap(client, cardNodeId),
38
45
  getOuterHTML(client, cardNodeId)
39
46
  ]);
40
- return normalizeCandidateFromHtml({
47
+ const candidate = normalizeCandidateFromHtml({
41
48
  domain: "recruit",
42
49
  source,
43
50
  html: outerHTML,
@@ -48,6 +55,7 @@ export async function readRecruitCardCandidate(client, cardNodeId, {
48
55
  ...metadata
49
56
  }
50
57
  });
58
+ return mergeRecruitCardFields(candidate, outerHTML);
51
59
  }
52
60
 
53
61
  export async function readFirstRecruitCardCandidate(client, frameNodeId, options = {}) {
@@ -213,17 +213,22 @@ export async function waitForRecruitDetailContent(client, {
213
213
  export async function openRecruitCardDetail(client, cardNodeId, {
214
214
  timeoutMs = 12000
215
215
  } = {}) {
216
+ const openedStarted = Date.now();
216
217
  const attempts = [];
218
+ const clickStarted = Date.now();
217
219
  const cardBox = await clickNodeCenter(client, cardNodeId, {
218
220
  scrollIntoView: true
219
221
  });
222
+ let candidateClickMs = Date.now() - clickStarted;
220
223
  attempts.push({
221
224
  mode: "card-center",
222
225
  center: cardBox.center
223
226
  });
227
+ const detailStarted = Date.now();
224
228
  let detailState = await waitForRecruitDetail(client, { timeoutMs });
225
229
 
226
230
  if (!detailState?.popup && !detailState?.resumeIframe) {
231
+ const fallbackClickStarted = Date.now();
227
232
  const leftTitlePoint = {
228
233
  x: cardBox.rect.x + Math.min(140, Math.max(40, cardBox.rect.width * 0.2)),
229
234
  y: cardBox.rect.y + Math.min(42, Math.max(24, cardBox.rect.height * 0.28))
@@ -232,6 +237,7 @@ export async function openRecruitCardDetail(client, cardNodeId, {
232
237
  clickCount: 2,
233
238
  delayMs: 120
234
239
  });
240
+ candidateClickMs += Date.now() - fallbackClickStarted;
235
241
  attempts.push({
236
242
  mode: "card-left-title-double-click",
237
243
  center: leftTitlePoint
@@ -248,7 +254,12 @@ export async function openRecruitCardDetail(client, cardNodeId, {
248
254
  return {
249
255
  card_box: cardBox,
250
256
  open_attempts: attempts,
251
- detail_state: detailState
257
+ detail_state: detailState,
258
+ timings: {
259
+ candidate_click_ms: candidateClickMs,
260
+ detail_open_ms: Date.now() - detailStarted,
261
+ open_total_ms: Date.now() - openedStarted
262
+ }
252
263
  };
253
264
  }
254
265
 
@@ -1,4 +1,9 @@
1
1
  import { createRunLifecycleManager } from "../../core/run/index.js";
2
+ import {
3
+ addTiming,
4
+ imageEvidenceFilePath,
5
+ measureTiming
6
+ } from "../../core/run/timing.js";
2
7
  import { captureScrolledNodeScreenshots } from "../../core/capture/index.js";
3
8
  import {
4
9
  compactCvAcquisitionState,
@@ -150,7 +155,8 @@ export async function runRecruitWorkflow({
150
155
  llmConfig = null,
151
156
  llmTimeoutMs = 120000,
152
157
  llmImageLimit = 8,
153
- llmImageDetail = "high"
158
+ llmImageDetail = "high",
159
+ imageOutputDir = ""
154
160
  } = {}, runControl) {
155
161
  if (!client) throw new Error("runRecruitWorkflow requires a guarded CDP client");
156
162
  const normalizedSearchParams = normalizeSearchParams(searchParams);
@@ -258,12 +264,14 @@ export async function runRecruitWorkflow({
258
264
  });
259
265
 
260
266
  while (results.length < limit) {
267
+ const candidateStarted = Date.now();
268
+ const timings = {};
261
269
  await runControl.waitIfPaused();
262
270
  runControl.throwIfCanceled();
263
271
  runControl.setPhase("recruit:candidate");
264
272
  rootState = await ensureRecruitViewport(rootState, "candidate_loop");
265
273
 
266
- const nextCandidateResult = await getNextInfiniteListCandidate({
274
+ const nextCandidateResult = await measureTiming(timings, "card_read_ms", () => getNextInfiniteListCandidate({
267
275
  client,
268
276
  state: listState,
269
277
  maxScrolls: listMaxScrolls,
@@ -291,7 +299,7 @@ export async function runRecruitWorkflow({
291
299
  search_params: normalizedSearchParams
292
300
  }
293
301
  })
294
- });
302
+ }));
295
303
  if (!nextCandidateResult.ok) {
296
304
  listEndReason = nextCandidateResult.reason || "list_exhausted";
297
305
  if (
@@ -373,8 +381,10 @@ export async function runRecruitWorkflow({
373
381
  rootState = await ensureRecruitViewport(rootState, "detail");
374
382
  networkRecorder.clear();
375
383
  const openedDetail = await openRecruitCardDetail(client, cardNodeId);
384
+ addTiming(timings, "candidate_click_ms", openedDetail.timings?.candidate_click_ms);
385
+ addTiming(timings, "detail_open_ms", openedDetail.timings?.detail_open_ms);
376
386
  const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
377
- const networkWait = await waitForCvNetworkEvents(
387
+ const networkWait = await measureTiming(timings, "network_cv_wait_ms", () => waitForCvNetworkEvents(
378
388
  waitForRecruitDetailNetworkEvents,
379
389
  networkRecorder,
380
390
  {
@@ -383,7 +393,10 @@ export async function runRecruitWorkflow({
383
393
  requireLoaded: true,
384
394
  intervalMs: 120
385
395
  }
386
- );
396
+ ));
397
+ if (networkWait?.elapsed_ms != null) {
398
+ timings.network_cv_wait_ms = Math.round(Number(networkWait.elapsed_ms) || 0);
399
+ }
387
400
  detailResult = await extractRecruitDetailCandidate(client, {
388
401
  cardCandidate,
389
402
  cardNodeId,
@@ -405,7 +418,13 @@ export async function runRecruitWorkflow({
405
418
  || openedDetail.detail_state?.resumeIframe?.node_id
406
419
  || null;
407
420
  if (captureNodeId) {
408
- imageEvidence = await captureScrolledNodeScreenshots(client, captureNodeId, {
421
+ imageEvidence = await measureTiming(timings, "screenshot_capture_ms", () => captureScrolledNodeScreenshots(client, captureNodeId, {
422
+ filePath: imageEvidenceFilePath({
423
+ imageOutputDir,
424
+ domain: "recruit",
425
+ runId: runControl?.runId,
426
+ index
427
+ }),
409
428
  padding: 4,
410
429
  maxScreenshots: maxImagePages,
411
430
  wheelDeltaY: imageWheelDeltaY,
@@ -417,7 +436,7 @@ export async function runRecruitWorkflow({
417
436
  run_candidate_index: index,
418
437
  candidate_key: candidateKey
419
438
  }
420
- });
439
+ }));
421
440
  source = "image";
422
441
  recordCvImageFallback(cvAcquisitionState, {
423
442
  parsedNetworkProfileCount,
@@ -436,7 +455,7 @@ export async function runRecruitWorkflow({
436
455
 
437
456
  let closeResult = null;
438
457
  if (closeDetail) {
439
- closeResult = await closeRecruitDetail(client);
458
+ closeResult = await measureTiming(timings, "close_detail_ms", () => closeRecruitDetail(client));
440
459
  }
441
460
  detailResult.close_result = closeResult;
442
461
  detailResult.image_evidence = imageEvidence;
@@ -460,7 +479,10 @@ export async function runRecruitWorkflow({
460
479
  llmResult = createMissingLlmConfigResult();
461
480
  } else {
462
481
  try {
463
- llmResult = await callScreeningLlm({
482
+ const llmTimingKey = detailResult?.image_evidence?.file_paths?.length
483
+ ? "vision_model_ms"
484
+ : "text_model_ms";
485
+ llmResult = await measureTiming(timings, llmTimingKey, () => callScreeningLlm({
464
486
  candidate: screeningCandidate,
465
487
  criteria,
466
488
  config: llmConfig,
@@ -468,7 +490,7 @@ export async function runRecruitWorkflow({
468
490
  imageEvidence: detailResult?.image_evidence || null,
469
491
  maxImages: llmImageLimit,
470
492
  imageDetail: llmImageDetail
471
- });
493
+ }));
472
494
  } catch (error) {
473
495
  llmResult = createFailedLlmScreeningResult(error);
474
496
  }
@@ -478,6 +500,7 @@ export async function runRecruitWorkflow({
478
500
  const screening = useLlmScreening
479
501
  ? llmResultToScreening(llmResult, screeningCandidate)
480
502
  : screenCandidate(screeningCandidate, { criteria });
503
+ timings.total_ms = Date.now() - candidateStarted;
481
504
  const compactResult = {
482
505
  index,
483
506
  candidate_key: candidateKey,
@@ -485,7 +508,8 @@ export async function runRecruitWorkflow({
485
508
  candidate: compactCandidate(screeningCandidate),
486
509
  detail: compactDetail(detailResult),
487
510
  llm_screening: detailResult ? null : compactScreeningLlmResult(llmResult),
488
- screening: compactScreening(screening)
511
+ screening: compactScreening(screening),
512
+ timings
489
513
  };
490
514
  results.push(compactResult);
491
515
  markInfiniteListCandidateProcessed(listState, candidateKey, {
@@ -514,6 +538,7 @@ export async function runRecruitWorkflow({
514
538
  last_candidate_key: candidateKey,
515
539
  last_score: screening.score
516
540
  });
541
+ const checkpointStarted = Date.now();
517
542
  runControl.checkpoint({
518
543
  results,
519
544
  last_candidate: {
@@ -528,9 +553,13 @@ export async function runRecruitWorkflow({
528
553
  llm_screening: compactScreeningLlmResult(llmResult)
529
554
  }
530
555
  });
556
+ addTiming(compactResult.timings, "checkpoint_save_ms", Date.now() - checkpointStarted);
531
557
 
532
558
  if (delayMs > 0) {
559
+ const sleepStarted = Date.now();
533
560
  await runControl.sleep(delayMs);
561
+ addTiming(compactResult.timings, "sleep_ms", Date.now() - sleepStarted);
562
+ compactResult.timings.total_ms = Date.now() - candidateStarted;
534
563
  }
535
564
  }
536
565
 
@@ -593,6 +622,7 @@ export function createRecruitRunService({
593
622
  llmTimeoutMs = 120000,
594
623
  llmImageLimit = 8,
595
624
  llmImageDetail = "high",
625
+ imageOutputDir = "",
596
626
  name = "recruit-domain-run"
597
627
  } = {}) {
598
628
  if (!client) throw new Error("startRecruitRun requires a guarded CDP client");
@@ -628,7 +658,8 @@ export function createRecruitRunService({
628
658
  llm_configured: Boolean(llmConfig),
629
659
  llm_timeout_ms: llmTimeoutMs,
630
660
  llm_image_limit: llmImageLimit,
631
- llm_image_detail: llmImageDetail
661
+ llm_image_detail: llmImageDetail,
662
+ image_output_dir: imageOutputDir || ""
632
663
  },
633
664
  progress: {
634
665
  card_count: 0,
@@ -668,7 +699,8 @@ export function createRecruitRunService({
668
699
  llmConfig,
669
700
  llmTimeoutMs,
670
701
  llmImageLimit,
671
- llmImageDetail
702
+ llmImageDetail,
703
+ imageOutputDir
672
704
  }, runControl)
673
705
  });
674
706
  }
@@ -1048,6 +1048,7 @@ function getRunOptions(args, parsed, normalized, session, configResolution = nul
1048
1048
  llmTimeoutMs: parsePositiveInteger(args.llm_timeout_ms, slowLive ? 180000 : 120000),
1049
1049
  llmImageLimit: parsePositiveInteger(args.llm_image_limit, 8),
1050
1050
  llmImageDetail: normalizeText(args.llm_image_detail) || "high",
1051
+ imageOutputDir: resolveBossConfiguredOutputDir("", getRunsDir()),
1051
1052
  name: "mcp-recommend-pipeline-run",
1052
1053
  parsed
1053
1054
  };
@@ -837,6 +837,7 @@ function getRunOptions(args, parsed, session, configResolution = null) {
837
837
  llmTimeoutMs: parsePositiveInteger(args.llm_timeout_ms, slowLive ? 180000 : 120000),
838
838
  llmImageLimit: parsePositiveInteger(args.llm_image_limit, 8),
839
839
  llmImageDetail: normalizeText(args.llm_image_detail) || "high",
840
+ imageOutputDir: resolveBossConfiguredOutputDir("", getRecruitRunsDir()),
840
841
  name: "mcp-recruit-pipeline-run"
841
842
  };
842
843
  }