@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.
- package/package.json +13 -2
- package/skills/boss-recommend-pipeline/SKILL.md +1 -4
- package/src/adapters.js +307 -203
- package/src/pipeline.js +450 -375
- package/src/test-adapters-runtime.js +10 -1
- package/src/test-pipeline.js +506 -4
- package/vendor/boss-recommend-screen-cli/boss-recommend-screen-cli.cjs +113 -34
- package/vendor/boss-recommend-screen-cli/scripts/capture-full-resume-canvas.cjs +287 -89
- package/vendor/boss-recommend-screen-cli/test-recoverable-resume-failures.cjs +413 -245
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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()
|