@reconcrap/boss-recommend-mcp 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
package/src/adapters.js CHANGED
@@ -16,6 +16,7 @@ const screenConfigTemplateDefaults = {
16
16
  apiKey: "replace-with-openai-api-key",
17
17
  model: "gpt-4.1-mini"
18
18
  };
19
+ const DEFAULT_RECOMMEND_SCREEN_TIMEOUT_MS = 24 * 60 * 60 * 1000;
19
20
 
20
21
  function getCodexHome() {
21
22
  return process.env.CODEX_HOME
@@ -830,7 +831,39 @@ function parseJsonOutput(text) {
830
831
  return null;
831
832
  }
832
833
 
833
- function parseScreenProgressLine(line, currentProgress = {}) {
834
+ function createScreenProgressTracker(currentTracker = {}) {
835
+ const outcome = String(currentTracker.outcome || "").trim();
836
+ return {
837
+ candidate_index: Number.isInteger(currentTracker.candidate_index) ? currentTracker.candidate_index : null,
838
+ outcome: outcome === "pass" || outcome === "skip" ? outcome : null,
839
+ action_failed: currentTracker.action_failed === true
840
+ };
841
+ }
842
+
843
+ function finalizeCandidateProgress(progress, tracker) {
844
+ if (!Number.isInteger(tracker.candidate_index)) {
845
+ return false;
846
+ }
847
+
848
+ let changed = false;
849
+ if (tracker.action_failed === true) {
850
+ progress.skipped += 1;
851
+ changed = true;
852
+ } else if (tracker.outcome === "pass") {
853
+ progress.passed += 1;
854
+ changed = true;
855
+ } else if (tracker.outcome === "skip") {
856
+ progress.skipped += 1;
857
+ changed = true;
858
+ }
859
+
860
+ tracker.candidate_index = null;
861
+ tracker.outcome = null;
862
+ tracker.action_failed = false;
863
+ return changed;
864
+ }
865
+
866
+ function parseScreenProgressLine(line, currentProgress = {}, currentTracker = {}) {
834
867
  const normalizedLine = String(line || "").replace(/\s+/g, " ").trim();
835
868
  if (!normalizedLine) return null;
836
869
 
@@ -840,23 +873,56 @@ function parseScreenProgressLine(line, currentProgress = {}) {
840
873
  skipped: Number.isInteger(currentProgress.skipped) ? currentProgress.skipped : 0,
841
874
  greet_count: Number.isInteger(currentProgress.greet_count) ? currentProgress.greet_count : 0
842
875
  };
876
+ const nextTracker = createScreenProgressTracker(currentTracker);
843
877
 
844
878
  let changed = false;
845
879
  const processedMatch = normalizedLine.match(/处理第\s*(\d+)\s*位候选人/u);
846
880
  if (processedMatch) {
881
+ if (finalizeCandidateProgress(nextProgress, nextTracker)) {
882
+ changed = true;
883
+ }
847
884
  const processed = Number.parseInt(processedMatch[1], 10);
848
885
  if (Number.isInteger(processed) && processed >= 0 && processed !== nextProgress.processed) {
849
886
  nextProgress.processed = processed;
850
887
  changed = true;
851
888
  }
889
+ nextTracker.candidate_index = processed;
890
+ nextTracker.outcome = null;
891
+ nextTracker.action_failed = false;
852
892
  }
853
893
 
854
894
  if (/筛选结果:\s*通过/u.test(normalizedLine)) {
855
- nextProgress.passed += 1;
856
- changed = true;
895
+ if (nextTracker.outcome !== "pass" || nextTracker.action_failed) {
896
+ changed = true;
897
+ }
898
+ nextTracker.outcome = "pass";
899
+ nextTracker.action_failed = false;
857
900
  } else if (/筛选结果:\s*不通过/u.test(normalizedLine)) {
858
- nextProgress.skipped += 1;
859
- changed = true;
901
+ if (nextTracker.outcome !== "skip" || nextTracker.action_failed) {
902
+ changed = true;
903
+ }
904
+ nextTracker.outcome = "skip";
905
+ nextTracker.action_failed = false;
906
+ }
907
+
908
+ if (/候选人处理失败\s*:/u.test(normalizedLine)) {
909
+ if (!nextTracker.action_failed) {
910
+ changed = true;
911
+ }
912
+ nextTracker.action_failed = true;
913
+ }
914
+
915
+ if (/^\[关闭详情\].*成功/u.test(normalizedLine)) {
916
+ if (finalizeCandidateProgress(nextProgress, nextTracker)) {
917
+ changed = true;
918
+ }
919
+ }
920
+
921
+ const finalStateLine = /Process timed out after|status"\s*:\s*"(?:COMPLETED|PAUSED|FAILED)"/iu.test(normalizedLine);
922
+ if (finalStateLine) {
923
+ if (finalizeCandidateProgress(nextProgress, nextTracker)) {
924
+ changed = true;
925
+ }
860
926
  }
861
927
 
862
928
  const greetMatch = normalizedLine.match(/greet[_\s-]*count\s*[:=]\s*(\d+)/iu);
@@ -871,7 +937,34 @@ function parseScreenProgressLine(line, currentProgress = {}) {
871
937
  if (!changed) return null;
872
938
  return {
873
939
  line: normalizedLine,
874
- progress: nextProgress
940
+ progress: nextProgress,
941
+ tracker: nextTracker
942
+ };
943
+ }
944
+
945
+ function resolveRecommendScreenTimeoutMs(runtime = null) {
946
+ const runtimeTimeoutMs = parsePositiveInteger(runtime?.timeoutMs);
947
+ const envTimeoutMs = parsePositiveInteger(process.env.BOSS_RECOMMEND_SCREEN_TIMEOUT_MS);
948
+ return runtimeTimeoutMs || envTimeoutMs || DEFAULT_RECOMMEND_SCREEN_TIMEOUT_MS;
949
+ }
950
+
951
+ function buildRecommendScreenProcessError(result, screenTimeoutMs) {
952
+ if (result.code === 0) return null;
953
+ if (result.error_code === "TIMEOUT") {
954
+ return {
955
+ code: "TIMEOUT",
956
+ message: `推荐页筛选命令执行超时(${screenTimeoutMs}ms)。`
957
+ };
958
+ }
959
+ if (result.error_code === "ABORTED") {
960
+ return {
961
+ code: "PROCESS_ABORTED",
962
+ message: "推荐页筛选命令已取消。"
963
+ };
964
+ }
965
+ return {
966
+ code: "RECOMMEND_SCREEN_FAILED",
967
+ message: "推荐页筛选命令执行失败。"
875
968
  };
876
969
  }
877
970
 
@@ -1683,21 +1776,24 @@ export async function runRecommendScreenCli({ workspaceRoot, screenParams, resum
1683
1776
  skipped: 0,
1684
1777
  greet_count: 0
1685
1778
  };
1779
+ let inferredTracker = createScreenProgressTracker();
1780
+ const screenTimeoutMs = resolveRecommendScreenTimeoutMs(runtime);
1686
1781
 
1687
1782
  const result = await runProcess({
1688
1783
  command: "node",
1689
1784
  args,
1690
1785
  cwd: screenDir,
1691
- timeoutMs: 60 * 60 * 1000,
1786
+ timeoutMs: screenTimeoutMs,
1692
1787
  heartbeatIntervalMs: runtime?.heartbeatIntervalMs,
1693
1788
  signal: runtime?.signal,
1694
1789
  onOutput: (event) => {
1695
1790
  safeInvokeCallback(runtime?.onOutput, event);
1696
1791
  },
1697
1792
  onLine: (event) => {
1698
- const parsed = parseScreenProgressLine(event?.line, inferredProgress);
1793
+ const parsed = parseScreenProgressLine(event?.line, inferredProgress, inferredTracker);
1699
1794
  if (!parsed) return;
1700
1795
  inferredProgress = parsed.progress;
1796
+ inferredTracker = parsed.tracker;
1701
1797
  safeInvokeCallback(runtime?.onProgress, {
1702
1798
  ...inferredProgress,
1703
1799
  line: parsed.line
@@ -1731,24 +1827,14 @@ export async function runRecommendScreenCli({ workspaceRoot, screenParams, resum
1731
1827
  stderr: result.stderr,
1732
1828
  structured,
1733
1829
  summary,
1734
- error: structured?.error || missingOutputError || (
1735
- result.code === 0
1736
- ? null
1737
- : result.error_code === "ABORTED"
1738
- ? {
1739
- code: "PROCESS_ABORTED",
1740
- message: "推荐页筛选命令已取消。"
1741
- }
1742
- : {
1743
- code: "RECOMMEND_SCREEN_FAILED",
1744
- message: "推荐页筛选命令执行失败。"
1745
- }
1746
- )
1830
+ error: structured?.error || missingOutputError || buildRecommendScreenProcessError(result, screenTimeoutMs)
1747
1831
  };
1748
1832
  }
1749
1833
 
1750
1834
  export const __testables = {
1751
1835
  runProcess,
1752
1836
  parseJsonOutput,
1753
- parseScreenProgressLine
1837
+ parseScreenProgressLine,
1838
+ resolveRecommendScreenTimeoutMs,
1839
+ buildRecommendScreenProcessError
1754
1840
  };
package/src/parser.js CHANGED
@@ -192,7 +192,10 @@ function expandDegreeAtOrAbove(value) {
192
192
  function parseDegreeSelectionsFromText(text) {
193
193
  const normalizedText = normalizeText(text);
194
194
  if (!normalizedText) return [];
195
- if (/(?:学历|学位|教育)[^。;;\n]{0,6}不限|不限[^。;;\n]{0,6}(?:学历|学位|教育)/i.test(normalizedText)) {
195
+ if (
196
+ /(?:学历|学位|教育)(?:要求)?\s*(?:[::]\s*)?(?:不限|不限制|无要求)|(?:不限|不限制)\s*(?:[::]\s*)?(?:学历|学位|教育)(?:要求)?/i
197
+ .test(normalizedText)
198
+ ) {
196
199
  return ["不限"];
197
200
  }
198
201
 
@@ -4,7 +4,13 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { runRecommendScreenCli, __testables as adapterTestables } from "./adapters.js";
6
6
 
7
- const { runProcess, parseJsonOutput } = adapterTestables;
7
+ const {
8
+ runProcess,
9
+ parseJsonOutput,
10
+ parseScreenProgressLine,
11
+ resolveRecommendScreenTimeoutMs,
12
+ buildRecommendScreenProcessError
13
+ } = adapterTestables;
8
14
 
9
15
  async function testRunProcessHeartbeatAndOutput() {
10
16
  const heartbeats = [];
@@ -57,6 +63,50 @@ function testParsePausedStructuredOutput() {
57
63
  assert.equal(parsed?.result?.processed_count, 3);
58
64
  }
59
65
 
66
+ function testParseScreenProgressLineShouldCountFavoriteFailureAsSkipped() {
67
+ let progress = { processed: 0, passed: 0, skipped: 0, greet_count: 0 };
68
+ let tracker = {};
69
+ const feed = (line) => {
70
+ const parsed = parseScreenProgressLine(line, progress, tracker);
71
+ if (!parsed) return;
72
+ progress = parsed.progress;
73
+ tracker = parsed.tracker;
74
+ };
75
+
76
+ feed("处理第 1 位候选人: 甲");
77
+ feed("筛选结果: 通过");
78
+ feed("[关闭详情] 成功: no popup or detail signal visible");
79
+ feed("处理第 2 位候选人: 乙");
80
+ feed("筛选结果: 通过");
81
+ feed("候选人处理失败: FAVORITE_BUTTON_FAILED");
82
+ feed("[关闭详情] 成功: no popup or detail signal visible");
83
+
84
+ assert.equal(progress.processed, 2);
85
+ assert.equal(progress.passed, 1);
86
+ assert.equal(progress.skipped, 1);
87
+ }
88
+
89
+ function testResolveScreenTimeoutDefaultsTo24Hours() {
90
+ const previous = process.env.BOSS_RECOMMEND_SCREEN_TIMEOUT_MS;
91
+ delete process.env.BOSS_RECOMMEND_SCREEN_TIMEOUT_MS;
92
+ try {
93
+ assert.equal(resolveRecommendScreenTimeoutMs(null), 24 * 60 * 60 * 1000);
94
+ assert.equal(resolveRecommendScreenTimeoutMs({ timeoutMs: 1234 }), 1234);
95
+ } finally {
96
+ if (previous === undefined) {
97
+ delete process.env.BOSS_RECOMMEND_SCREEN_TIMEOUT_MS;
98
+ } else {
99
+ process.env.BOSS_RECOMMEND_SCREEN_TIMEOUT_MS = previous;
100
+ }
101
+ }
102
+ }
103
+
104
+ function testBuildRecommendScreenProcessErrorMapsTimeout() {
105
+ const error = buildRecommendScreenProcessError({ code: -1, error_code: "TIMEOUT" }, 86400000);
106
+ assert.equal(error?.code, "TIMEOUT");
107
+ assert.equal(String(error?.message || "").includes("86400000"), true);
108
+ }
109
+
60
110
  async function testResumeRequiresCheckpointFile() {
61
111
  const previousHome = process.env.BOSS_RECOMMEND_HOME;
62
112
  const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-resume-"));
@@ -103,6 +153,9 @@ async function main() {
103
153
  await testRunProcessHeartbeatAndOutput();
104
154
  await testRunProcessAbortSignal();
105
155
  testParsePausedStructuredOutput();
156
+ testParseScreenProgressLineShouldCountFavoriteFailureAsSkipped();
157
+ testResolveScreenTimeoutDefaultsTo24Hours();
158
+ testBuildRecommendScreenProcessErrorMapsTimeout();
106
159
  await testResumeRequiresCheckpointFile();
107
160
  console.log("adapters runtime tests passed");
108
161
  }
@@ -172,6 +172,17 @@ function testDegreeAtOrAboveExpansion() {
172
172
  assert.deepEqual(result.searchParams.degree, ["大专", "本科", "硕士", "博士"]);
173
173
  }
174
174
 
175
+ function testDegreeShouldNotBeOverwrittenBySchoolTagUnlimitedClause() {
176
+ const result = parseRecommendInstruction({
177
+ instruction: "学校标签不限,学历要求大专及以上,性别不限,过滤近14天已看",
178
+ confirmation: null,
179
+ overrides: null
180
+ });
181
+
182
+ assert.deepEqual(result.searchParams.school_tag, ["不限"]);
183
+ assert.deepEqual(result.searchParams.degree, ["大专", "本科", "硕士", "博士"]);
184
+ }
185
+
175
186
  function testDegreeExplicitListOnly() {
176
187
  const result = parseRecommendInstruction({
177
188
  instruction: "推荐页筛选大专、本科,近14天没有,有Agent经验",
@@ -420,6 +431,7 @@ function main() {
420
431
  testMultipleSchoolTagsMarkedSuspicious();
421
432
  testDegreeCanBeExtracted();
422
433
  testDegreeAtOrAboveExpansion();
434
+ testDegreeShouldNotBeOverwrittenBySchoolTagUnlimitedClause();
423
435
  testDegreeExplicitListOnly();
424
436
  testDegreeOverrideCanBeArray();
425
437
  testSchoolTagOverrideCanBeArray();
@@ -9,6 +9,9 @@ const { captureFullResumeCanvas } = require("./scripts/capture-full-resume-canva
9
9
  const DEFAULT_PORT = 9222;
10
10
  const RECOMMEND_URL_FRAGMENT = "/web/chat/recommend";
11
11
  const CSV_HEADER = ["姓名", "最高学历学校", "最高学历专业", "最近工作公司", "最近工作职位", "评估通过详细原因"].join(",");
12
+ const RESUME_CAPTURE_WAIT_MS = 60000;
13
+ const RESUME_CAPTURE_MAX_ATTEMPTS = 4;
14
+ const RESUME_CAPTURE_RETRY_DELAY_MS = 1200;
12
15
 
13
16
  function log(...args) {
14
17
  console.error(...args);
@@ -1531,18 +1534,40 @@ class RecommendScreenCli {
1531
1534
  }
1532
1535
 
1533
1536
  async captureResumeImage(candidate) {
1534
- const outPrefix = path.join(this.debugDir, `${candidate.geek_id}_${Date.now()}`);
1535
- try {
1536
- return await captureFullResumeCanvas({
1537
- port: this.args.port,
1538
- outPrefix,
1539
- targetPattern: RECOMMEND_URL_FRAGMENT,
1540
- waitResumeMs: 30000,
1541
- scrollSettleMs: 500
1542
- });
1543
- } catch (error) {
1544
- throw this.buildError("RESUME_CAPTURE_FAILED", error.message || "Failed to capture resume image.");
1537
+ const candidateLabel = normalizeText(candidate?.geek_id || candidate?.name || "unknown");
1538
+ const candidateKey = String(candidate?.geek_id || candidate?.name || "candidate")
1539
+ .replace(/[^\w.-]+/g, "_")
1540
+ .slice(0, 80) || "candidate";
1541
+ const attemptSummaries = [];
1542
+ let lastError = null;
1543
+
1544
+ for (let attempt = 1; attempt <= RESUME_CAPTURE_MAX_ATTEMPTS; attempt += 1) {
1545
+ const outPrefix = path.join(this.debugDir, `${candidateKey}_${Date.now()}_a${attempt}`);
1546
+ try {
1547
+ return await captureFullResumeCanvas({
1548
+ port: this.args.port,
1549
+ outPrefix,
1550
+ targetPattern: RECOMMEND_URL_FRAGMENT,
1551
+ waitResumeMs: RESUME_CAPTURE_WAIT_MS,
1552
+ scrollSettleMs: 500
1553
+ });
1554
+ } catch (error) {
1555
+ lastError = error;
1556
+ const message = normalizeText(error?.message || String(error) || "Failed to capture resume image.");
1557
+ attemptSummaries.push(`a${attempt}/${RESUME_CAPTURE_MAX_ATTEMPTS}:${message.slice(0, 320)}`);
1558
+ log(`[简历截图失败] candidate=${candidateLabel} attempt=${attempt}/${RESUME_CAPTURE_MAX_ATTEMPTS} error=${message}`);
1559
+ if (attempt < RESUME_CAPTURE_MAX_ATTEMPTS) {
1560
+ await sleep(RESUME_CAPTURE_RETRY_DELAY_MS);
1561
+ }
1562
+ }
1545
1563
  }
1564
+
1565
+ const lastMessage = normalizeText(lastError?.message || "Failed to capture resume image.");
1566
+ const attemptsText = attemptSummaries.join(" | ");
1567
+ throw this.buildError(
1568
+ "RESUME_CAPTURE_FAILED",
1569
+ `Resume capture failed after ${RESUME_CAPTURE_MAX_ATTEMPTS} attempts; last_error=${lastMessage}; attempts=${attemptsText}`
1570
+ );
1546
1571
  }
1547
1572
 
1548
1573
  async callVisionModel(imagePath) {
@@ -39,6 +39,36 @@ function pickTarget(targets, targetPattern) {
39
39
  );
40
40
  }
41
41
 
42
+ function oneLineJson(value, maxLength = 1200) {
43
+ try {
44
+ const text = JSON.stringify(value);
45
+ if (text.length <= maxLength) return text;
46
+ return `${text.slice(0, maxLength)}...`;
47
+ } catch {
48
+ return "\"<unserializable>\"";
49
+ }
50
+ }
51
+
52
+ function summarizeProbeReason(probe) {
53
+ if (!probe || typeof probe !== "object") return "NO_PROBE";
54
+ if (probe.ok === true) return "INVALID_CLIP";
55
+ return String(probe.reason || "UNKNOWN");
56
+ }
57
+
58
+ function buildResumeProbeTimeoutMessage(waitResumeMs, probe) {
59
+ const reason = summarizeProbeReason(probe);
60
+ const payload = {
61
+ reason,
62
+ clip: probe?.clip || null,
63
+ scroll_top: Number.isFinite(Number(probe?.scrollTop)) ? Number(probe.scrollTop) : null,
64
+ client_height: Number.isFinite(Number(probe?.clientHeight)) ? Number(probe.clientHeight) : null,
65
+ scroll_height: Number.isFinite(Number(probe?.scrollHeight)) ? Number(probe.scrollHeight) : null,
66
+ max_scroll: Number.isFinite(Number(probe?.maxScroll)) ? Number(probe.maxScroll) : null,
67
+ debug: probe?.debug || null
68
+ };
69
+ return `Resume canvas not found: wait_resume_ms=${waitResumeMs}; last_reason=${reason}; probe=${oneLineJson(payload)}`;
70
+ }
71
+
42
72
  function buildResumeProbeExpr({ init, targetScroll }) {
43
73
  const initLiteral = init ? "true" : "false";
44
74
  const scrollLiteral = typeof targetScroll === "number" && Number.isFinite(targetScroll)
@@ -110,12 +140,22 @@ function buildResumeProbeExpr({ init, targetScroll }) {
110
140
  || document.querySelector('iframe');
111
141
  const recommendDoc = recommendFrame && recommendFrame.contentDocument;
112
142
  if (!recommendFrame || !recommendDoc) {
113
- return { ok: false, reason: 'NO_RECOMMEND_IFRAME' };
143
+ return {
144
+ ok: false,
145
+ reason: 'NO_RECOMMEND_IFRAME',
146
+ debug: {
147
+ topIframeCount: document.querySelectorAll('iframe').length
148
+ }
149
+ };
114
150
  }
115
151
 
116
152
  const scopes = Array.from(
117
153
  recommendDoc.querySelectorAll('.dialog-wrap.active, .boss-popup__wrapper.boss-dialog, .boss-dialog__wrapper')
118
154
  ).filter(isVisible);
155
+ const allResumeFrames = Array.from(
156
+ recommendDoc.querySelectorAll('iframe[src*="/web/frame/c-resume/"], iframe[name*="resume"]')
157
+ );
158
+ const visibleResumeFrames = allResumeFrames.filter(isVisible);
119
159
 
120
160
  let resumeFrame = null;
121
161
  for (const scope of scopes) {
@@ -127,13 +167,22 @@ function buildResumeProbeExpr({ init, targetScroll }) {
127
167
  }
128
168
 
129
169
  if (!resumeFrame) {
130
- resumeFrame = Array.from(
131
- recommendDoc.querySelectorAll('iframe[src*="/web/frame/c-resume/"], iframe[name*="resume"]')
132
- ).find(isVisible) || null;
170
+ resumeFrame = visibleResumeFrames[0] || null;
133
171
  }
134
172
 
135
173
  if (!resumeFrame) {
136
- return { ok: false, reason: 'NO_CRESUME_IFRAME' };
174
+ return {
175
+ ok: false,
176
+ reason: 'NO_CRESUME_IFRAME',
177
+ debug: {
178
+ activeScopeCount: scopes.length,
179
+ totalResumeIframes: allResumeFrames.length,
180
+ visibleResumeIframes: visibleResumeFrames.length,
181
+ recommendFrameUrl: (() => {
182
+ try { return String(recommendFrame.contentWindow.location.href || ''); } catch { return ''; }
183
+ })()
184
+ }
185
+ };
137
186
  }
138
187
 
139
188
  const resumeDoc = resumeFrame.contentDocument;
@@ -143,7 +192,19 @@ function buildResumeProbeExpr({ init, targetScroll }) {
143
192
  || chooseScrollableAncestor(resumeFrame)
144
193
  || null;
145
194
  if (!scroller || !isVisible(scroller)) {
146
- return { ok: false, reason: 'NO_SCROLL_CONTAINER' };
195
+ return {
196
+ ok: false,
197
+ reason: 'NO_SCROLL_CONTAINER',
198
+ debug: {
199
+ activeScopeCount: scopes.length,
200
+ totalResumeIframes: allResumeFrames.length,
201
+ visibleResumeIframes: visibleResumeFrames.length,
202
+ resumeFrameSrc: String(resumeFrame.src || ''),
203
+ scrollerFound: Boolean(scroller),
204
+ scrollerVisible: Boolean(scroller && isVisible(scroller)),
205
+ scrollerClass: scroller ? String(scroller.className || '') : ''
206
+ }
207
+ };
147
208
  }
148
209
 
149
210
  return {
@@ -285,9 +346,23 @@ async function captureFullResumeCanvas(options = {}) {
285
346
  await send("Page.bringToFront");
286
347
 
287
348
  let probe = null;
349
+ let lastProbe = null;
288
350
  const startTime = Date.now();
289
351
  while (Date.now() - startTime < waitResumeMs) {
290
- probe = await evaluate(buildResumeProbeExpr({ init: true, targetScroll: 0 }));
352
+ try {
353
+ probe = await evaluate(buildResumeProbeExpr({ init: true, targetScroll: 0 }));
354
+ } catch (error) {
355
+ probe = {
356
+ ok: false,
357
+ reason: "PROBE_EVALUATE_FAILED",
358
+ debug: {
359
+ message: String(error?.message || error || "unknown")
360
+ }
361
+ };
362
+ }
363
+ if (probe && typeof probe === "object") {
364
+ lastProbe = probe;
365
+ }
291
366
  if (probe?.ok && probe.clip?.height > 80 && probe.clip?.width > 120) {
292
367
  break;
293
368
  }
@@ -295,7 +370,7 @@ async function captureFullResumeCanvas(options = {}) {
295
370
  }
296
371
 
297
372
  if (!probe?.ok) {
298
- throw new Error(`Resume canvas not found in ${waitResumeMs}ms.`);
373
+ throw new Error(buildResumeProbeTimeoutMessage(waitResumeMs, lastProbe || probe));
299
374
  }
300
375
 
301
376
  const maxScroll = Math.max(0, Number(probe.maxScroll || 0));