@reconcrap/boss-recommend-mcp 1.1.4 → 1.1.6

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.
@@ -450,6 +450,20 @@ const jsDetectBottom = `(() => {
450
450
  return { isBottom: false, error: 'NO_RECOMMEND_IFRAME' };
451
451
  }
452
452
  const doc = frame.contentDocument;
453
+ const isVisible = (el) => {
454
+ if (!el) return false;
455
+ const win = doc.defaultView;
456
+ if (!win) return el.offsetParent !== null;
457
+ const style = win.getComputedStyle(el);
458
+ if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') < 0.02) {
459
+ return false;
460
+ }
461
+ const rect = el.getBoundingClientRect();
462
+ return rect.width > 2 && rect.height > 2 && el.offsetParent !== null;
463
+ };
464
+ const finishedWrap = Array.from(doc.querySelectorAll('.finished-wrap')).find((el) => isVisible(el)) || null;
465
+ const refreshButton = Array.from(doc.querySelectorAll('.finished-wrap .btn.btn-refresh, .finished-wrap .btn-refresh, .no-data-refresh .btn-refresh'))
466
+ .find((el) => isVisible(el)) || null;
453
467
  const keywords = ['没有更多', '已显示全部', '已经到底', '暂无更多', '推荐完了', '没有更多人选'];
454
468
  const elements = Array.from(doc.querySelectorAll('div,span,p'));
455
469
  for (const el of elements) {
@@ -458,12 +472,24 @@ const jsDetectBottom = `(() => {
458
472
  if (!text || text.length > 40) continue;
459
473
  for (const keyword of keywords) {
460
474
  if (text.includes(keyword)) {
461
- return { isBottom: true, reason: keyword };
475
+ return {
476
+ isBottom: true,
477
+ reason: keyword,
478
+ finished_wrap_visible: Boolean(finishedWrap),
479
+ refresh_button_visible: Boolean(refreshButton),
480
+ refresh_button_text: refreshButton ? String(refreshButton.textContent || '').replace(/\s+/g, ' ').trim() : null
481
+ };
462
482
  }
463
483
  }
464
484
  }
465
- return { isBottom: false };
466
- })()`;
485
+ return {
486
+ isBottom: Boolean(finishedWrap),
487
+ reason: finishedWrap ? 'finished-wrap' : null,
488
+ finished_wrap_visible: Boolean(finishedWrap),
489
+ refresh_button_visible: Boolean(refreshButton),
490
+ refresh_button_text: refreshButton ? String(refreshButton.textContent || '').replace(/\s+/g, ' ').trim() : null
491
+ };
492
+ })()`;
467
493
  const jsWaitForDetail = `(() => {
468
494
  const frame = document.querySelector('iframe[name="recommendFrame"]')
469
495
  || document.querySelector('iframe[src*="/web/frame/recommend/"]')
@@ -1113,6 +1139,24 @@ class RecommendScreenCli {
1113
1139
  };
1114
1140
  }
1115
1141
 
1142
+ buildProgressSnapshot(completionReason = null) {
1143
+ const snapshot = {
1144
+ processed_count: this.processedCount,
1145
+ passed_count: this.passedCandidates.length,
1146
+ skipped_count: this.skippedCount,
1147
+ output_csv: this.args.output,
1148
+ checkpoint_path: this.checkpointPath,
1149
+ post_action: this.args.postAction,
1150
+ max_greet_count: this.args.postAction === "greet" ? this.args.maxGreetCount : null,
1151
+ greet_count: this.greetCount,
1152
+ greet_limit_fallback_count: this.greetLimitFallbackCount
1153
+ };
1154
+ if (completionReason) {
1155
+ snapshot.completion_reason = completionReason;
1156
+ }
1157
+ return snapshot;
1158
+ }
1159
+
1116
1160
  resetResumeCaptureFailureStreak() {
1117
1161
  this.consecutiveResumeCaptureFailures = 0;
1118
1162
  this.resumeCaptureFailureStreakKeys = [];
@@ -1916,24 +1960,14 @@ class RecommendScreenCli {
1916
1960
  this.sortCandidateQueue();
1917
1961
  }
1918
1962
 
1963
+ let pageExhaustion = null;
1919
1964
  while (!this.args.targetCount || this.processedCount < this.args.targetCount) {
1920
1965
  if (this.shouldPauseAtBoundary()) {
1921
1966
  this.saveCsv();
1922
1967
  this.saveCheckpoint();
1923
1968
  return {
1924
1969
  status: "PAUSED",
1925
- result: {
1926
- processed_count: this.processedCount,
1927
- passed_count: this.passedCandidates.length,
1928
- skipped_count: this.skippedCount,
1929
- output_csv: this.args.output,
1930
- checkpoint_path: this.checkpointPath,
1931
- completion_reason: "paused",
1932
- post_action: this.args.postAction,
1933
- max_greet_count: this.args.postAction === "greet" ? this.args.maxGreetCount : null,
1934
- greet_count: this.greetCount,
1935
- greet_limit_fallback_count: this.greetLimitFallbackCount
1936
- }
1970
+ result: this.buildProgressSnapshot("paused")
1937
1971
  };
1938
1972
  }
1939
1973
  const periodicDiscovery = await this.discoverCandidates();
@@ -1958,6 +1992,18 @@ class RecommendScreenCli {
1958
1992
  const didScroll = Number(scroll.after?.scrollTop || 0) !== Number(scroll.before?.scrollTop || 0)
1959
1993
  || Number(scroll.after?.scrollHeight || 0) !== Number(scroll.before?.scrollHeight || 0);
1960
1994
  if (scroll.bottom?.isBottom) {
1995
+ pageExhaustion = {
1996
+ reason: "bottom_reached",
1997
+ bottom: scroll.bottom || null,
1998
+ scroll: scroll.scrollResult || null,
1999
+ before: scroll.before || null,
2000
+ after: scroll.after || null,
2001
+ discovery: {
2002
+ added: discovery.added ?? 0,
2003
+ candidate_count: discovery.candidate_count ?? null,
2004
+ total_cards: discovery.total_cards ?? null
2005
+ }
2006
+ };
1961
2007
  break;
1962
2008
  }
1963
2009
  if (didGrow || didDiscover) {
@@ -1967,12 +2013,36 @@ class RecommendScreenCli {
1967
2013
  if (!didScroll) {
1968
2014
  this.scrollRetryCount += 1;
1969
2015
  if (this.scrollRetryCount >= this.maxScrollRetries) {
2016
+ pageExhaustion = {
2017
+ reason: "scroll_stalled",
2018
+ bottom: scroll.bottom || null,
2019
+ scroll: scroll.scrollResult || null,
2020
+ before: scroll.before || null,
2021
+ after: scroll.after || null,
2022
+ discovery: {
2023
+ added: discovery.added ?? 0,
2024
+ candidate_count: discovery.candidate_count ?? null,
2025
+ total_cards: discovery.total_cards ?? null
2026
+ }
2027
+ };
1970
2028
  break;
1971
2029
  }
1972
2030
  continue;
1973
2031
  }
1974
2032
  this.scrollRetryCount += 1;
1975
2033
  if (this.scrollRetryCount >= this.maxScrollRetries) {
2034
+ pageExhaustion = {
2035
+ reason: "scroll_retry_exhausted",
2036
+ bottom: scroll.bottom || null,
2037
+ scroll: scroll.scrollResult || null,
2038
+ before: scroll.before || null,
2039
+ after: scroll.after || null,
2040
+ discovery: {
2041
+ added: discovery.added ?? 0,
2042
+ candidate_count: discovery.candidate_count ?? null,
2043
+ total_cards: discovery.total_cards ?? null
2044
+ }
2045
+ };
1976
2046
  break;
1977
2047
  }
1978
2048
  continue;
@@ -2076,6 +2146,18 @@ class RecommendScreenCli {
2076
2146
  }
2077
2147
  }
2078
2148
 
2149
+ if (this.args.targetCount && this.processedCount < this.args.targetCount) {
2150
+ throw this.buildError(
2151
+ "TARGET_COUNT_NOT_REACHED_PAGE_EXHAUSTED",
2152
+ `推荐列表已到底,但当前仅处理 ${this.processedCount} 位,尚未达到目标 ${this.args.targetCount} 位。`,
2153
+ true,
2154
+ {
2155
+ partial_result: this.buildProgressSnapshot("page_exhausted_before_target_count"),
2156
+ page_exhaustion: pageExhaustion
2157
+ }
2158
+ );
2159
+ }
2160
+
2079
2161
  this.saveCsv();
2080
2162
  try {
2081
2163
  this.saveCheckpoint();
@@ -2085,17 +2167,14 @@ class RecommendScreenCli {
2085
2167
  return {
2086
2168
  status: "COMPLETED",
2087
2169
  result: {
2088
- processed_count: this.processedCount,
2089
- passed_count: this.passedCandidates.length,
2090
- skipped_count: this.skippedCount,
2091
- output_csv: this.args.output,
2170
+ ...this.buildProgressSnapshot(
2171
+ this.args.targetCount && this.processedCount >= this.args.targetCount
2172
+ ? "target_count_reached"
2173
+ : "page_exhausted"
2174
+ ),
2092
2175
  completion_reason: this.args.targetCount && this.processedCount >= this.args.targetCount
2093
2176
  ? "target_count_reached"
2094
2177
  : "page_exhausted",
2095
- post_action: this.args.postAction,
2096
- max_greet_count: this.args.postAction === "greet" ? this.args.maxGreetCount : null,
2097
- greet_count: this.greetCount,
2098
- greet_limit_fallback_count: this.greetLimitFallbackCount
2099
2178
  }
2100
2179
  };
2101
2180
  } catch (error) {
@@ -2110,12 +2189,7 @@ class RecommendScreenCli {
2110
2189
  log(`[保存checkpoint失败] ${checkpointError.message || checkpointError}`);
2111
2190
  }
2112
2191
  if (!error.partial_result) {
2113
- error.partial_result = {
2114
- processed_count: this.processedCount,
2115
- passed_count: this.passedCandidates.length,
2116
- skipped_count: this.skippedCount,
2117
- output_csv: this.args.output
2118
- };
2192
+ error.partial_result = this.buildProgressSnapshot();
2119
2193
  }
2120
2194
  throw error;
2121
2195
  } finally {
@@ -2144,13 +2218,18 @@ async function main() {
2144
2218
 
2145
2219
  if (require.main === module) {
2146
2220
  main().catch((error) => {
2221
+ const errorPayload = {
2222
+ code: error.code || "RECOMMEND_SCREEN_FAILED",
2223
+ message: error.message || "推荐页筛选执行失败。",
2224
+ retryable: error.retryable !== false
2225
+ };
2226
+ for (const [key, value] of Object.entries(error || {})) {
2227
+ if (["code", "message", "retryable", "partial_result", "stack"].includes(key)) continue;
2228
+ errorPayload[key] = value;
2229
+ }
2147
2230
  const payload = {
2148
2231
  status: "FAILED",
2149
- error: {
2150
- code: error.code || "RECOMMEND_SCREEN_FAILED",
2151
- message: error.message || "推荐页筛选执行失败。",
2152
- retryable: error.retryable !== false
2153
- },
2232
+ error: errorPayload,
2154
2233
  result: error.partial_result || null
2155
2234
  };
2156
2235
  console.log(JSON.stringify(payload));
@@ -4,13 +4,25 @@ const path = require("node:path");
4
4
  const http = require("node:http");
5
5
  const { spawnSync } = require("node:child_process");
6
6
  const WebSocket = require("ws");
7
+ let sharpFactory = null;
7
8
 
8
- function sleep(ms) {
9
- return new Promise((resolve) => setTimeout(resolve, ms));
10
- }
11
-
12
- const EARLY_FAIL_NO_RESUME_IFRAME_MIN_WAIT_MS = 5000;
13
- const EARLY_FAIL_NO_RESUME_IFRAME_STABLE_POLLS = 4;
9
+ function sleep(ms) {
10
+ return new Promise((resolve) => setTimeout(resolve, ms));
11
+ }
12
+
13
+ const EARLY_FAIL_NO_RESUME_IFRAME_MIN_WAIT_MS = 5000;
14
+ const EARLY_FAIL_NO_RESUME_IFRAME_STABLE_POLLS = 4;
15
+
16
+ function clampInteger(value, low, high) {
17
+ return Math.max(low, Math.min(high, value));
18
+ }
19
+
20
+ function loadSharp() {
21
+ if (!sharpFactory) {
22
+ sharpFactory = require("sharp");
23
+ }
24
+ return sharpFactory;
25
+ }
14
26
 
15
27
  function getJson(url) {
16
28
  return new Promise((resolve, reject) => {
@@ -58,9 +70,9 @@ function summarizeProbeReason(probe) {
58
70
  return String(probe.reason || "UNKNOWN");
59
71
  }
60
72
 
61
- function buildResumeProbeTimeoutMessage(waitResumeMs, probe) {
62
- const reason = summarizeProbeReason(probe);
63
- const payload = {
73
+ function buildResumeProbeTimeoutMessage(waitResumeMs, probe) {
74
+ const reason = summarizeProbeReason(probe);
75
+ const payload = {
64
76
  reason,
65
77
  clip: probe?.clip || null,
66
78
  scroll_top: Number.isFinite(Number(probe?.scrollTop)) ? Number(probe.scrollTop) : null,
@@ -68,28 +80,202 @@ function buildResumeProbeTimeoutMessage(waitResumeMs, probe) {
68
80
  scroll_height: Number.isFinite(Number(probe?.scrollHeight)) ? Number(probe.scrollHeight) : null,
69
81
  max_scroll: Number.isFinite(Number(probe?.maxScroll)) ? Number(probe.maxScroll) : null,
70
82
  debug: probe?.debug || null
71
- };
72
- return `Resume canvas not found: wait_resume_ms=${waitResumeMs}; last_reason=${reason}; probe=${oneLineJson(payload)}`;
73
- }
74
-
75
- function isStableNoResumeIframeProbe(probe) {
76
- if (!probe || probe.ok === true || probe.reason !== "NO_CRESUME_IFRAME") {
77
- return false;
78
- }
79
- const activeScopeCount = Number(probe?.debug?.activeScopeCount ?? -1);
80
- const totalResumeIframes = Number(probe?.debug?.totalResumeIframes ?? -1);
81
- const visibleResumeIframes = Number(probe?.debug?.visibleResumeIframes ?? -1);
82
- return activeScopeCount === 0 && totalResumeIframes === 0 && visibleResumeIframes === 0;
83
- }
84
-
85
- function shouldAbortResumeProbeEarly({ probe, stableNoResumeIframePolls, elapsedMs, waitResumeMs }) {
86
- if (!isStableNoResumeIframeProbe(probe)) {
87
- return false;
88
- }
89
- const minWaitMs = Math.min(waitResumeMs, EARLY_FAIL_NO_RESUME_IFRAME_MIN_WAIT_MS);
90
- return stableNoResumeIframePolls >= EARLY_FAIL_NO_RESUME_IFRAME_STABLE_POLLS
91
- && elapsedMs >= minWaitMs;
92
- }
83
+ };
84
+ return `Resume canvas not found: wait_resume_ms=${waitResumeMs}; last_reason=${reason}; probe=${oneLineJson(payload)}`;
85
+ }
86
+
87
+ function isStableNoResumeIframeProbe(probe) {
88
+ if (!probe || probe.ok === true || probe.reason !== "NO_CRESUME_IFRAME") {
89
+ return false;
90
+ }
91
+ const activeScopeCount = Number(probe?.debug?.activeScopeCount ?? -1);
92
+ const totalResumeIframes = Number(probe?.debug?.totalResumeIframes ?? -1);
93
+ const visibleResumeIframes = Number(probe?.debug?.visibleResumeIframes ?? -1);
94
+ return activeScopeCount === 0 && totalResumeIframes === 0 && visibleResumeIframes === 0;
95
+ }
96
+
97
+ function shouldAbortResumeProbeEarly({ probe, stableNoResumeIframePolls, elapsedMs, waitResumeMs }) {
98
+ if (!isStableNoResumeIframeProbe(probe)) {
99
+ return false;
100
+ }
101
+ const minWaitMs = Math.min(waitResumeMs, EARLY_FAIL_NO_RESUME_IFRAME_MIN_WAIT_MS);
102
+ return stableNoResumeIframePolls >= EARLY_FAIL_NO_RESUME_IFRAME_STABLE_POLLS
103
+ && elapsedMs >= minWaitMs;
104
+ }
105
+
106
+ async function stitchWithSharp(metadataFile, stitchedImage) {
107
+ const sharp = loadSharp();
108
+ let metadata;
109
+ try {
110
+ metadata = JSON.parse(fs.readFileSync(metadataFile, "utf8"));
111
+ } catch (error) {
112
+ throw new Error(`Invalid stitch metadata: ${error.message || error}`);
113
+ }
114
+ const rawChunks = Array.isArray(metadata?.chunks) ? metadata.chunks : [];
115
+ if (rawChunks.length === 0) {
116
+ throw new Error("No chunks found in metadata.");
117
+ }
118
+
119
+ const chunks = rawChunks.map((chunk, index) => {
120
+ const file = path.resolve(String(chunk?.file || ""));
121
+ if (!file || !fs.existsSync(file)) {
122
+ throw new Error(`Chunk image missing: ${file || "<empty>"}`);
123
+ }
124
+ return {
125
+ index: Number.isInteger(chunk?.index) ? chunk.index : index,
126
+ file,
127
+ scrollTop: Number(chunk?.scrollTop || 0),
128
+ clipHeightCss: Number(chunk?.clipHeightCss || 0)
129
+ };
130
+ }).sort((a, b) => {
131
+ if (a.scrollTop !== b.scrollTop) return a.scrollTop - b.scrollTop;
132
+ return a.index - b.index;
133
+ });
134
+
135
+ const composites = [];
136
+ const used = [];
137
+ let outWidth = 1;
138
+ let outHeight = 0;
139
+ let prevChunk = null;
140
+
141
+ for (const chunk of chunks) {
142
+ const info = await sharp(chunk.file).metadata();
143
+ const width = Number(info?.width || 0);
144
+ const height = Number(info?.height || 0);
145
+ if (width <= 0 || height <= 0) {
146
+ throw new Error(`Invalid chunk image size: ${chunk.file}`);
147
+ }
148
+
149
+ if (prevChunk) {
150
+ const deltaCss = chunk.scrollTop - prevChunk.scrollTop;
151
+ if (!(deltaCss > 0.5)) {
152
+ prevChunk = chunk;
153
+ continue;
154
+ }
155
+ const clipHeightCss = chunk.clipHeightCss > 1 ? chunk.clipHeightCss : prevChunk.clipHeightCss;
156
+ const ratio = clipHeightCss > 1 ? (height / clipHeightCss) : 1;
157
+ const newPixels = clampInteger(Math.round(deltaCss * ratio), 1, height);
158
+ const cropTop = clampInteger(height - newPixels, 0, height - 1);
159
+ const segHeight = height - cropTop;
160
+ const segment = await sharp(chunk.file)
161
+ .removeAlpha()
162
+ .extract({
163
+ left: 0,
164
+ top: cropTop,
165
+ width,
166
+ height: segHeight
167
+ })
168
+ .png()
169
+ .toBuffer();
170
+ composites.push({
171
+ input: segment,
172
+ top: outHeight,
173
+ left: 0
174
+ });
175
+ used.push({
176
+ file: chunk.file,
177
+ scrollTop: chunk.scrollTop,
178
+ cropTopPx: cropTop,
179
+ keptHeightPx: segHeight
180
+ });
181
+ outWidth = Math.max(outWidth, width);
182
+ outHeight += segHeight;
183
+ prevChunk = chunk;
184
+ continue;
185
+ }
186
+
187
+ const segment = await sharp(chunk.file)
188
+ .removeAlpha()
189
+ .png()
190
+ .toBuffer();
191
+ composites.push({
192
+ input: segment,
193
+ top: outHeight,
194
+ left: 0
195
+ });
196
+ used.push({
197
+ file: chunk.file,
198
+ scrollTop: chunk.scrollTop,
199
+ cropTopPx: 0,
200
+ keptHeightPx: height
201
+ });
202
+ outWidth = Math.max(outWidth, width);
203
+ outHeight += height;
204
+ prevChunk = chunk;
205
+ }
206
+
207
+ if (composites.length === 0 || outHeight <= 0 || outWidth <= 0) {
208
+ throw new Error("No valid segments to stitch with sharp.");
209
+ }
210
+
211
+ await sharp({
212
+ create: {
213
+ width: outWidth,
214
+ height: outHeight,
215
+ channels: 3,
216
+ background: { r: 255, g: 255, b: 255 }
217
+ }
218
+ })
219
+ .composite(composites)
220
+ .png()
221
+ .toFile(stitchedImage);
222
+
223
+ return {
224
+ ok: true,
225
+ engine: "sharp",
226
+ output: path.resolve(stitchedImage),
227
+ segments: composites.length,
228
+ size: {
229
+ width: outWidth,
230
+ height: outHeight
231
+ },
232
+ used
233
+ };
234
+ }
235
+
236
+ function stitchWithAvailablePython(stitchScript, metadataFile, stitchedImage, spawnSyncImpl = spawnSync) {
237
+ const candidates = ["python3", "python"];
238
+ const attempts = [];
239
+ if (!fs.existsSync(stitchScript)) {
240
+ return {
241
+ ok: false,
242
+ attempts: candidates.map((command) => ({
243
+ command,
244
+ status: null,
245
+ signal: null,
246
+ error: `Missing stitch script: ${stitchScript}`,
247
+ stderr: "",
248
+ stdout: ""
249
+ }))
250
+ };
251
+ }
252
+ for (const command of candidates) {
253
+ const result = spawnSyncImpl(command, [stitchScript, metadataFile, stitchedImage], {
254
+ encoding: "utf8"
255
+ });
256
+ attempts.push({
257
+ command,
258
+ status: Number.isInteger(result.status) ? result.status : null,
259
+ signal: result.signal || null,
260
+ error: result.error ? String(result.error.message || result.error) : null,
261
+ stderr: result.stderr || "",
262
+ stdout: result.stdout || ""
263
+ });
264
+ if (result.status === 0) {
265
+ return {
266
+ ok: true,
267
+ engine: "python",
268
+ command,
269
+ result,
270
+ attempts
271
+ };
272
+ }
273
+ }
274
+ return {
275
+ ok: false,
276
+ attempts
277
+ };
278
+ }
93
279
 
94
280
  function buildResumeProbeExpr({ init, targetScroll }) {
95
281
  const initLiteral = init ? "true" : "false";
@@ -303,9 +489,6 @@ async function captureFullResumeCanvas(options = {}) {
303
489
  const metadataFile = `${outPrefix}_chunks.json`;
304
490
  const stitchedImage = `${outPrefix}.png`;
305
491
 
306
- if (!fs.existsSync(stitchScript)) {
307
- throw new Error(`Missing stitch script: ${stitchScript}`);
308
- }
309
492
  fs.mkdirSync(chunkDir, { recursive: true });
310
493
 
311
494
  const targets = await getJson(`http://${host}:${port}/json/list`);
@@ -367,11 +550,11 @@ async function captureFullResumeCanvas(options = {}) {
367
550
  await send("Runtime.enable");
368
551
  await send("Page.bringToFront");
369
552
 
370
- let probe = null;
371
- let lastProbe = null;
372
- let stableNoResumeIframePolls = 0;
373
- const startTime = Date.now();
374
- while (Date.now() - startTime < waitResumeMs) {
553
+ let probe = null;
554
+ let lastProbe = null;
555
+ let stableNoResumeIframePolls = 0;
556
+ const startTime = Date.now();
557
+ while (Date.now() - startTime < waitResumeMs) {
375
558
  try {
376
559
  probe = await evaluate(buildResumeProbeExpr({ init: true, targetScroll: 0 }));
377
560
  } catch (error) {
@@ -386,42 +569,42 @@ async function captureFullResumeCanvas(options = {}) {
386
569
  if (probe && typeof probe === "object") {
387
570
  lastProbe = probe;
388
571
  }
389
- if (probe?.ok && probe.clip?.height > 80 && probe.clip?.width > 120) {
390
- break;
391
- }
392
- if (isStableNoResumeIframeProbe(probe)) {
393
- stableNoResumeIframePolls += 1;
394
- } else {
395
- stableNoResumeIframePolls = 0;
396
- }
397
- const elapsedMs = Date.now() - startTime;
398
- if (shouldAbortResumeProbeEarly({
399
- probe,
400
- stableNoResumeIframePolls,
401
- elapsedMs,
402
- waitResumeMs
403
- })) {
404
- if (probe && typeof probe === "object") {
405
- probe = {
406
- ...probe,
407
- debug: {
408
- ...(probe.debug && typeof probe.debug === "object" ? probe.debug : {}),
409
- earlyAbort: true,
410
- stableNoResumeIframePolls,
411
- elapsedMs
412
- }
413
- };
414
- lastProbe = probe;
415
- }
416
- break;
417
- }
418
- await sleep(700);
419
- }
420
-
421
- if (!probe?.ok) {
422
- const elapsedMs = Math.max(0, Date.now() - startTime);
423
- throw new Error(buildResumeProbeTimeoutMessage(Math.min(waitResumeMs, elapsedMs), lastProbe || probe));
424
- }
572
+ if (probe?.ok && probe.clip?.height > 80 && probe.clip?.width > 120) {
573
+ break;
574
+ }
575
+ if (isStableNoResumeIframeProbe(probe)) {
576
+ stableNoResumeIframePolls += 1;
577
+ } else {
578
+ stableNoResumeIframePolls = 0;
579
+ }
580
+ const elapsedMs = Date.now() - startTime;
581
+ if (shouldAbortResumeProbeEarly({
582
+ probe,
583
+ stableNoResumeIframePolls,
584
+ elapsedMs,
585
+ waitResumeMs
586
+ })) {
587
+ if (probe && typeof probe === "object") {
588
+ probe = {
589
+ ...probe,
590
+ debug: {
591
+ ...(probe.debug && typeof probe.debug === "object" ? probe.debug : {}),
592
+ earlyAbort: true,
593
+ stableNoResumeIframePolls,
594
+ elapsedMs
595
+ }
596
+ };
597
+ lastProbe = probe;
598
+ }
599
+ break;
600
+ }
601
+ await sleep(700);
602
+ }
603
+
604
+ if (!probe?.ok) {
605
+ const elapsedMs = Math.max(0, Date.now() - startTime);
606
+ throw new Error(buildResumeProbeTimeoutMessage(Math.min(waitResumeMs, elapsedMs), lastProbe || probe));
607
+ }
425
608
 
426
609
  const maxScroll = Math.max(0, Number(probe.maxScroll || 0));
427
610
  const step = Math.max(120, Math.floor(Number(probe.clientHeight || probe.clip.height || 800)));
@@ -492,11 +675,23 @@ async function captureFullResumeCanvas(options = {}) {
492
675
  };
493
676
  fs.writeFileSync(metadataFile, JSON.stringify(metadata, null, 2), "utf8");
494
677
 
495
- const stitch = spawnSync("python", [stitchScript, metadataFile, stitchedImage], {
496
- encoding: "utf8"
497
- });
498
- if (stitch.status !== 0) {
499
- throw new Error(`Stitch failed: ${stitch.stderr || stitch.stdout}`);
678
+ let stitchEngine = "sharp";
679
+ try {
680
+ await stitchWithSharp(metadataFile, stitchedImage);
681
+ } catch (sharpError) {
682
+ const fallback = stitchWithAvailablePython(stitchScript, metadataFile, stitchedImage);
683
+ if (!fallback.ok) {
684
+ const fallbackSummary = fallback.attempts
685
+ .map((item) => {
686
+ const message = item.stderr || item.stdout || item.error || "unknown error";
687
+ return `${item.command}(status=${item.status ?? "null"}): ${message}`;
688
+ })
689
+ .join(" | ");
690
+ throw new Error(
691
+ `Stitch failed (sharp + python fallback). sharp=${sharpError?.message || sharpError}; fallback=${fallbackSummary}`
692
+ );
693
+ }
694
+ stitchEngine = fallback.command || "python";
500
695
  }
501
696
 
502
697
  return {
@@ -504,6 +699,7 @@ async function captureFullResumeCanvas(options = {}) {
504
699
  metadataFile,
505
700
  chunkDir,
506
701
  chunkCount: chunks.length,
702
+ stitch_engine: stitchEngine,
507
703
  target: {
508
704
  title: target.title,
509
705
  url: target.url
@@ -516,15 +712,17 @@ async function captureFullResumeCanvas(options = {}) {
516
712
  }
517
713
  }
518
714
 
519
- module.exports = {
520
- captureFullResumeCanvas,
521
- __testables: {
522
- EARLY_FAIL_NO_RESUME_IFRAME_MIN_WAIT_MS,
523
- EARLY_FAIL_NO_RESUME_IFRAME_STABLE_POLLS,
524
- isStableNoResumeIframeProbe,
525
- shouldAbortResumeProbeEarly
526
- }
527
- };
715
+ module.exports = {
716
+ captureFullResumeCanvas,
717
+ __testables: {
718
+ EARLY_FAIL_NO_RESUME_IFRAME_MIN_WAIT_MS,
719
+ EARLY_FAIL_NO_RESUME_IFRAME_STABLE_POLLS,
720
+ isStableNoResumeIframeProbe,
721
+ shouldAbortResumeProbeEarly,
722
+ stitchWithAvailablePython,
723
+ stitchWithSharp
724
+ }
725
+ };
528
726
 
529
727
  if (require.main === module) {
530
728
  captureFullResumeCanvas()