@reconcrap/boss-recommend-mcp 1.1.2 → 1.1.4
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 +3 -2
- package/scripts/postinstall.cjs +44 -44
- package/skills/boss-recommend-pipeline/README.md +12 -12
- package/skills/boss-recommend-pipeline/SKILL.md +195 -195
- package/src/adapters.js +1876 -1806
- package/src/index.js +1254 -1254
- package/src/parser.js +19 -28
- package/src/pipeline.js +919 -792
- package/src/run-state.js +351 -351
- package/src/test-adapters-runtime.js +163 -163
- package/src/test-index-async.js +236 -236
- package/src/test-parser.js +55 -0
- package/src/test-pipeline.js +103 -0
- package/src/test-run-state.js +152 -152
- package/vendor/boss-recommend-screen-cli/boss-recommend-screen-cli.cjs +111 -18
- package/vendor/boss-recommend-screen-cli/scripts/capture-full-resume-canvas.cjs +508 -452
- package/vendor/boss-recommend-screen-cli/test-recoverable-resume-failures.cjs +245 -0
- package/vendor/boss-recommend-search-cli/src/cli.js +811 -811
- package/vendor/boss-recommend-search-cli/src/test-job-selection.js +201 -201
|
@@ -1,482 +1,538 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
const fs = require("node:fs");
|
|
3
|
-
const path = require("node:path");
|
|
4
|
-
const http = require("node:http");
|
|
5
|
-
const { spawnSync } = require("node:child_process");
|
|
6
|
-
const WebSocket = require("ws");
|
|
7
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require("node:fs");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const http = require("node:http");
|
|
5
|
+
const { spawnSync } = require("node:child_process");
|
|
6
|
+
const WebSocket = require("ws");
|
|
7
|
+
|
|
8
8
|
function sleep(ms) {
|
|
9
9
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
res.on("
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
12
|
+
const EARLY_FAIL_NO_RESUME_IFRAME_MIN_WAIT_MS = 5000;
|
|
13
|
+
const EARLY_FAIL_NO_RESUME_IFRAME_STABLE_POLLS = 4;
|
|
14
|
+
|
|
15
|
+
function getJson(url) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
http
|
|
18
|
+
.get(url, (res) => {
|
|
19
|
+
let data = "";
|
|
20
|
+
res.on("data", (chunk) => {
|
|
21
|
+
data += chunk;
|
|
22
|
+
});
|
|
23
|
+
res.on("end", () => {
|
|
24
|
+
try {
|
|
25
|
+
resolve(JSON.parse(data));
|
|
26
|
+
} catch (error) {
|
|
27
|
+
reject(new Error(`Parse JSON failed for ${url}: ${error.message}`));
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
})
|
|
31
|
+
.on("error", reject);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function pickTarget(targets, targetPattern) {
|
|
36
|
+
const pages = targets.filter((item) => item.type === "page");
|
|
37
|
+
if (!pages.length) return null;
|
|
38
|
+
return (
|
|
39
|
+
pages.find((item) => typeof item.url === "string" && item.url.includes(targetPattern))
|
|
40
|
+
|| pages.find((item) => /zhipin\.com/i.test(item.url || ""))
|
|
41
|
+
|| pages[0]
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function oneLineJson(value, maxLength = 1200) {
|
|
46
|
+
try {
|
|
47
|
+
const text = JSON.stringify(value);
|
|
48
|
+
if (text.length <= maxLength) return text;
|
|
49
|
+
return `${text.slice(0, maxLength)}...`;
|
|
50
|
+
} catch {
|
|
51
|
+
return "\"<unserializable>\"";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function summarizeProbeReason(probe) {
|
|
56
|
+
if (!probe || typeof probe !== "object") return "NO_PROBE";
|
|
57
|
+
if (probe.ok === true) return "INVALID_CLIP";
|
|
58
|
+
return String(probe.reason || "UNKNOWN");
|
|
59
|
+
}
|
|
60
|
+
|
|
58
61
|
function buildResumeProbeTimeoutMessage(waitResumeMs, probe) {
|
|
59
62
|
const reason = summarizeProbeReason(probe);
|
|
60
63
|
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
|
|
64
|
+
reason,
|
|
65
|
+
clip: probe?.clip || null,
|
|
66
|
+
scroll_top: Number.isFinite(Number(probe?.scrollTop)) ? Number(probe.scrollTop) : null,
|
|
67
|
+
client_height: Number.isFinite(Number(probe?.clientHeight)) ? Number(probe.clientHeight) : null,
|
|
68
|
+
scroll_height: Number.isFinite(Number(probe?.scrollHeight)) ? Number(probe.scrollHeight) : null,
|
|
69
|
+
max_scroll: Number.isFinite(Number(probe?.maxScroll)) ? Number(probe.maxScroll) : null,
|
|
70
|
+
debug: probe?.debug || null
|
|
68
71
|
};
|
|
69
72
|
return `Resume canvas not found: wait_resume_ms=${waitResumeMs}; last_reason=${reason}; probe=${oneLineJson(payload)}`;
|
|
70
73
|
}
|
|
71
74
|
|
|
72
|
-
function
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
? String(targetScroll)
|
|
76
|
-
: "null";
|
|
77
|
-
|
|
78
|
-
return `(() => {
|
|
79
|
-
const INIT = ${initLiteral};
|
|
80
|
-
const TARGET_SCROLL = ${scrollLiteral};
|
|
81
|
-
|
|
82
|
-
function absRect(el) {
|
|
83
|
-
const rect = el.getBoundingClientRect();
|
|
84
|
-
let x = rect.left;
|
|
85
|
-
let y = rect.top;
|
|
86
|
-
let win = el.ownerDocument.defaultView;
|
|
87
|
-
while (win && win !== win.parent) {
|
|
88
|
-
const frameEl = win.frameElement;
|
|
89
|
-
if (!frameEl) break;
|
|
90
|
-
const frameRect = frameEl.getBoundingClientRect();
|
|
91
|
-
x += frameRect.left;
|
|
92
|
-
y += frameRect.top;
|
|
93
|
-
win = win.parent;
|
|
94
|
-
}
|
|
95
|
-
return { x, y, width: rect.width, height: rect.height };
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function canScroll(el) {
|
|
99
|
-
return Boolean(el && (el.scrollHeight || 0) > (el.clientHeight || 0) + 8);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function chooseScrollableAncestor(startEl) {
|
|
103
|
-
const candidates = [];
|
|
104
|
-
let cur = startEl;
|
|
105
|
-
let depth = 0;
|
|
106
|
-
while (cur && depth < 24) {
|
|
107
|
-
if ((cur.clientHeight || 0) > 40 && canScroll(cur)) {
|
|
108
|
-
const style = getComputedStyle(cur);
|
|
109
|
-
const overflowY = String(style.overflowY || '').toLowerCase();
|
|
110
|
-
const key = (((cur.id || '') + ' ' + (cur.className || '')).toLowerCase());
|
|
111
|
-
let score = 0;
|
|
112
|
-
if (/auto|scroll|overlay/.test(overflowY)) score += 1000;
|
|
113
|
-
if (key.includes('resume')) score += 600;
|
|
114
|
-
if (key.includes('detail')) score += 300;
|
|
115
|
-
score += Math.min(250, Math.floor((cur.scrollHeight - cur.clientHeight) / 2));
|
|
116
|
-
score -= depth * 10;
|
|
117
|
-
candidates.push({ el: cur, score });
|
|
118
|
-
}
|
|
119
|
-
cur = cur.parentElement;
|
|
120
|
-
depth += 1;
|
|
121
|
-
}
|
|
122
|
-
if (!candidates.length) return null;
|
|
123
|
-
candidates.sort((a, b) => b.score - a.score);
|
|
124
|
-
return candidates[0].el;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function isVisible(el) {
|
|
128
|
-
if (!el) return false;
|
|
129
|
-
const style = getComputedStyle(el);
|
|
130
|
-
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') < 0.01) {
|
|
131
|
-
return false;
|
|
132
|
-
}
|
|
133
|
-
const rect = el.getBoundingClientRect();
|
|
134
|
-
return rect.width > 80 && rect.height > 80;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function locateContext() {
|
|
138
|
-
const recommendFrame = document.querySelector('iframe[name="recommendFrame"]')
|
|
139
|
-
|| document.querySelector('iframe[src*="/web/frame/recommend/"]')
|
|
140
|
-
|| document.querySelector('iframe');
|
|
141
|
-
const recommendDoc = recommendFrame && recommendFrame.contentDocument;
|
|
142
|
-
if (!recommendFrame || !recommendDoc) {
|
|
143
|
-
return {
|
|
144
|
-
ok: false,
|
|
145
|
-
reason: 'NO_RECOMMEND_IFRAME',
|
|
146
|
-
debug: {
|
|
147
|
-
topIframeCount: document.querySelectorAll('iframe').length
|
|
148
|
-
}
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const scopes = Array.from(
|
|
153
|
-
recommendDoc.querySelectorAll('.dialog-wrap.active, .boss-popup__wrapper.boss-dialog, .boss-dialog__wrapper')
|
|
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);
|
|
159
|
-
|
|
160
|
-
let resumeFrame = null;
|
|
161
|
-
for (const scope of scopes) {
|
|
162
|
-
const found = scope.querySelector('iframe[src*="/web/frame/c-resume/"], iframe[name*="resume"]');
|
|
163
|
-
if (found && isVisible(found)) {
|
|
164
|
-
resumeFrame = found;
|
|
165
|
-
break;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
if (!resumeFrame) {
|
|
170
|
-
resumeFrame = visibleResumeFrames[0] || null;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
if (!resumeFrame) {
|
|
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
|
-
};
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const resumeDoc = resumeFrame.contentDocument;
|
|
189
|
-
const canvas = resumeDoc ? (resumeDoc.querySelector('canvas#resume') || resumeDoc.querySelector('canvas')) : null;
|
|
190
|
-
const scroller = chooseScrollableAncestor(resumeFrame.parentElement || resumeFrame)
|
|
191
|
-
|| recommendDoc.querySelector('.resume-detail-wrap')
|
|
192
|
-
|| chooseScrollableAncestor(resumeFrame)
|
|
193
|
-
|| null;
|
|
194
|
-
if (!scroller || !isVisible(scroller)) {
|
|
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
|
-
};
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
return {
|
|
211
|
-
ok: true,
|
|
212
|
-
frame: resumeFrame,
|
|
213
|
-
canvas,
|
|
214
|
-
scroller,
|
|
215
|
-
clipEl: scroller,
|
|
216
|
-
debug: {
|
|
217
|
-
recommendFrameUrl: (() => {
|
|
218
|
-
try { return String(recommendFrame.contentWindow.location.href || ''); } catch { return ''; }
|
|
219
|
-
})(),
|
|
220
|
-
resumeFrameSrc: String(resumeFrame.src || ''),
|
|
221
|
-
scrollerClass: String(scroller.className || '')
|
|
222
|
-
}
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
if (INIT || !window.__bossRecommendResumeCtx || !window.__bossRecommendResumeCtx.scroller || !window.__bossRecommendResumeCtx.scroller.isConnected) {
|
|
227
|
-
const located = locateContext();
|
|
228
|
-
if (!located.ok) {
|
|
229
|
-
return located;
|
|
230
|
-
}
|
|
231
|
-
window.__bossRecommendResumeCtx = located;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
const ctx = window.__bossRecommendResumeCtx;
|
|
235
|
-
if (typeof TARGET_SCROLL === 'number' && Number.isFinite(TARGET_SCROLL)) {
|
|
236
|
-
try {
|
|
237
|
-
ctx.scroller.scrollTop = TARGET_SCROLL;
|
|
238
|
-
if (typeof ctx.scroller.scrollTo === 'function') {
|
|
239
|
-
ctx.scroller.scrollTo({ top: TARGET_SCROLL, left: 0, behavior: 'instant' });
|
|
240
|
-
}
|
|
241
|
-
ctx.scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
|
|
242
|
-
} catch {}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const scrollTop = Number(ctx.scroller.scrollTop || 0);
|
|
246
|
-
const scrollHeight = Number(ctx.scroller.scrollHeight || 0);
|
|
247
|
-
const clientHeight = Number(ctx.scroller.clientHeight || 0);
|
|
248
|
-
const maxScroll = Math.max(0, scrollHeight - clientHeight);
|
|
249
|
-
const clipRaw = absRect(ctx.clipEl);
|
|
250
|
-
|
|
251
|
-
return {
|
|
252
|
-
ok: true,
|
|
253
|
-
scrollTop,
|
|
254
|
-
scrollHeight,
|
|
255
|
-
clientHeight,
|
|
256
|
-
maxScroll,
|
|
257
|
-
clip: {
|
|
258
|
-
x: clipRaw.x,
|
|
259
|
-
y: clipRaw.y,
|
|
260
|
-
width: Math.max(1, Math.min(clipRaw.width, Number(ctx.scroller.clientWidth || clipRaw.width))),
|
|
261
|
-
height: Math.max(1, Math.min(clipRaw.height, Number(ctx.scroller.clientHeight || clipRaw.height)))
|
|
262
|
-
},
|
|
263
|
-
canvas: ctx.canvas ? {
|
|
264
|
-
width: Number(ctx.canvas.width || 0),
|
|
265
|
-
height: Number(ctx.canvas.height || 0)
|
|
266
|
-
} : null,
|
|
267
|
-
debug: ctx.debug || {}
|
|
268
|
-
};
|
|
269
|
-
})()`;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
async function captureFullResumeCanvas(options = {}) {
|
|
273
|
-
const host = options.host || process.env.CDP_HOST || "127.0.0.1";
|
|
274
|
-
const port = Number(options.port || process.env.CDP_PORT || 9222);
|
|
275
|
-
const waitResumeMs = Number(options.waitResumeMs || process.env.WAIT_RESUME_MS || 30000);
|
|
276
|
-
const scrollSettleMs = Number(options.scrollSettleMs || process.env.SCROLL_SETTLE_MS || 500);
|
|
277
|
-
const outPrefix = options.outPrefix || process.env.OUT_PREFIX || path.resolve(process.cwd(), "recommend_resume_full");
|
|
278
|
-
const targetPattern = options.targetPattern || process.env.TARGET_PATTERN || "/web/chat/recommend";
|
|
279
|
-
const stitchScript = path.resolve(__dirname, "stitch_resume_chunks.py");
|
|
280
|
-
const chunkDir = `${outPrefix}_chunks`;
|
|
281
|
-
const metadataFile = `${outPrefix}_chunks.json`;
|
|
282
|
-
const stitchedImage = `${outPrefix}.png`;
|
|
283
|
-
|
|
284
|
-
if (!fs.existsSync(stitchScript)) {
|
|
285
|
-
throw new Error(`Missing stitch script: ${stitchScript}`);
|
|
286
|
-
}
|
|
287
|
-
fs.mkdirSync(chunkDir, { recursive: true });
|
|
288
|
-
|
|
289
|
-
const targets = await getJson(`http://${host}:${port}/json/list`);
|
|
290
|
-
const target = pickTarget(targets, targetPattern);
|
|
291
|
-
if (!target?.webSocketDebuggerUrl) {
|
|
292
|
-
throw new Error("No debuggable zhipin page target found.");
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
|
296
|
-
let seq = 0;
|
|
297
|
-
const pending = new Map();
|
|
298
|
-
|
|
299
|
-
function send(method, params = {}) {
|
|
300
|
-
const id = ++seq;
|
|
301
|
-
ws.send(JSON.stringify({ id, method, params }));
|
|
302
|
-
return new Promise((resolve, reject) => {
|
|
303
|
-
pending.set(id, { resolve, reject, method });
|
|
304
|
-
setTimeout(() => {
|
|
305
|
-
if (!pending.has(id)) return;
|
|
306
|
-
pending.delete(id);
|
|
307
|
-
reject(new Error(`Timeout: ${method}`));
|
|
308
|
-
}, 30000);
|
|
309
|
-
});
|
|
75
|
+
function isStableNoResumeIframeProbe(probe) {
|
|
76
|
+
if (!probe || probe.ok === true || probe.reason !== "NO_CRESUME_IFRAME") {
|
|
77
|
+
return false;
|
|
310
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
|
+
}
|
|
311
84
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
returnByValue: true,
|
|
316
|
-
awaitPromise: true
|
|
317
|
-
}).then((response) => response.result?.value);
|
|
85
|
+
function shouldAbortResumeProbeEarly({ probe, stableNoResumeIframePolls, elapsedMs, waitResumeMs }) {
|
|
86
|
+
if (!isStableNoResumeIframeProbe(probe)) {
|
|
87
|
+
return false;
|
|
318
88
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildResumeProbeExpr({ init, targetScroll }) {
|
|
95
|
+
const initLiteral = init ? "true" : "false";
|
|
96
|
+
const scrollLiteral = typeof targetScroll === "number" && Number.isFinite(targetScroll)
|
|
97
|
+
? String(targetScroll)
|
|
98
|
+
: "null";
|
|
99
|
+
|
|
100
|
+
return `(() => {
|
|
101
|
+
const INIT = ${initLiteral};
|
|
102
|
+
const TARGET_SCROLL = ${scrollLiteral};
|
|
103
|
+
|
|
104
|
+
function absRect(el) {
|
|
105
|
+
const rect = el.getBoundingClientRect();
|
|
106
|
+
let x = rect.left;
|
|
107
|
+
let y = rect.top;
|
|
108
|
+
let win = el.ownerDocument.defaultView;
|
|
109
|
+
while (win && win !== win.parent) {
|
|
110
|
+
const frameEl = win.frameElement;
|
|
111
|
+
if (!frameEl) break;
|
|
112
|
+
const frameRect = frameEl.getBoundingClientRect();
|
|
113
|
+
x += frameRect.left;
|
|
114
|
+
y += frameRect.top;
|
|
115
|
+
win = win.parent;
|
|
116
|
+
}
|
|
117
|
+
return { x, y, width: rect.width, height: rect.height };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function canScroll(el) {
|
|
121
|
+
return Boolean(el && (el.scrollHeight || 0) > (el.clientHeight || 0) + 8);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function chooseScrollableAncestor(startEl) {
|
|
125
|
+
const candidates = [];
|
|
126
|
+
let cur = startEl;
|
|
127
|
+
let depth = 0;
|
|
128
|
+
while (cur && depth < 24) {
|
|
129
|
+
if ((cur.clientHeight || 0) > 40 && canScroll(cur)) {
|
|
130
|
+
const style = getComputedStyle(cur);
|
|
131
|
+
const overflowY = String(style.overflowY || '').toLowerCase();
|
|
132
|
+
const key = (((cur.id || '') + ' ' + (cur.className || '')).toLowerCase());
|
|
133
|
+
let score = 0;
|
|
134
|
+
if (/auto|scroll|overlay/.test(overflowY)) score += 1000;
|
|
135
|
+
if (key.includes('resume')) score += 600;
|
|
136
|
+
if (key.includes('detail')) score += 300;
|
|
137
|
+
score += Math.min(250, Math.floor((cur.scrollHeight - cur.clientHeight) / 2));
|
|
138
|
+
score -= depth * 10;
|
|
139
|
+
candidates.push({ el: cur, score });
|
|
140
|
+
}
|
|
141
|
+
cur = cur.parentElement;
|
|
142
|
+
depth += 1;
|
|
143
|
+
}
|
|
144
|
+
if (!candidates.length) return null;
|
|
145
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
146
|
+
return candidates[0].el;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isVisible(el) {
|
|
150
|
+
if (!el) return false;
|
|
151
|
+
const style = getComputedStyle(el);
|
|
152
|
+
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') < 0.01) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
const rect = el.getBoundingClientRect();
|
|
156
|
+
return rect.width > 80 && rect.height > 80;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function locateContext() {
|
|
160
|
+
const recommendFrame = document.querySelector('iframe[name="recommendFrame"]')
|
|
161
|
+
|| document.querySelector('iframe[src*="/web/frame/recommend/"]')
|
|
162
|
+
|| document.querySelector('iframe');
|
|
163
|
+
const recommendDoc = recommendFrame && recommendFrame.contentDocument;
|
|
164
|
+
if (!recommendFrame || !recommendDoc) {
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
reason: 'NO_RECOMMEND_IFRAME',
|
|
168
|
+
debug: {
|
|
169
|
+
topIframeCount: document.querySelectorAll('iframe').length
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const scopes = Array.from(
|
|
175
|
+
recommendDoc.querySelectorAll('.dialog-wrap.active, .boss-popup__wrapper.boss-dialog, .boss-dialog__wrapper')
|
|
176
|
+
).filter(isVisible);
|
|
177
|
+
const allResumeFrames = Array.from(
|
|
178
|
+
recommendDoc.querySelectorAll('iframe[src*="/web/frame/c-resume/"], iframe[name*="resume"]')
|
|
179
|
+
);
|
|
180
|
+
const visibleResumeFrames = allResumeFrames.filter(isVisible);
|
|
181
|
+
|
|
182
|
+
let resumeFrame = null;
|
|
183
|
+
for (const scope of scopes) {
|
|
184
|
+
const found = scope.querySelector('iframe[src*="/web/frame/c-resume/"], iframe[name*="resume"]');
|
|
185
|
+
if (found && isVisible(found)) {
|
|
186
|
+
resumeFrame = found;
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!resumeFrame) {
|
|
192
|
+
resumeFrame = visibleResumeFrames[0] || null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!resumeFrame) {
|
|
196
|
+
return {
|
|
197
|
+
ok: false,
|
|
198
|
+
reason: 'NO_CRESUME_IFRAME',
|
|
199
|
+
debug: {
|
|
200
|
+
activeScopeCount: scopes.length,
|
|
201
|
+
totalResumeIframes: allResumeFrames.length,
|
|
202
|
+
visibleResumeIframes: visibleResumeFrames.length,
|
|
203
|
+
recommendFrameUrl: (() => {
|
|
204
|
+
try { return String(recommendFrame.contentWindow.location.href || ''); } catch { return ''; }
|
|
205
|
+
})()
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const resumeDoc = resumeFrame.contentDocument;
|
|
211
|
+
const canvas = resumeDoc ? (resumeDoc.querySelector('canvas#resume') || resumeDoc.querySelector('canvas')) : null;
|
|
212
|
+
const scroller = chooseScrollableAncestor(resumeFrame.parentElement || resumeFrame)
|
|
213
|
+
|| recommendDoc.querySelector('.resume-detail-wrap')
|
|
214
|
+
|| chooseScrollableAncestor(resumeFrame)
|
|
215
|
+
|| null;
|
|
216
|
+
if (!scroller || !isVisible(scroller)) {
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
reason: 'NO_SCROLL_CONTAINER',
|
|
220
|
+
debug: {
|
|
221
|
+
activeScopeCount: scopes.length,
|
|
222
|
+
totalResumeIframes: allResumeFrames.length,
|
|
223
|
+
visibleResumeIframes: visibleResumeFrames.length,
|
|
224
|
+
resumeFrameSrc: String(resumeFrame.src || ''),
|
|
225
|
+
scrollerFound: Boolean(scroller),
|
|
226
|
+
scrollerVisible: Boolean(scroller && isVisible(scroller)),
|
|
227
|
+
scrollerClass: scroller ? String(scroller.className || '') : ''
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
ok: true,
|
|
234
|
+
frame: resumeFrame,
|
|
235
|
+
canvas,
|
|
236
|
+
scroller,
|
|
237
|
+
clipEl: scroller,
|
|
238
|
+
debug: {
|
|
239
|
+
recommendFrameUrl: (() => {
|
|
240
|
+
try { return String(recommendFrame.contentWindow.location.href || ''); } catch { return ''; }
|
|
241
|
+
})(),
|
|
242
|
+
resumeFrameSrc: String(resumeFrame.src || ''),
|
|
243
|
+
scrollerClass: String(scroller.className || '')
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (INIT || !window.__bossRecommendResumeCtx || !window.__bossRecommendResumeCtx.scroller || !window.__bossRecommendResumeCtx.scroller.isConnected) {
|
|
249
|
+
const located = locateContext();
|
|
250
|
+
if (!located.ok) {
|
|
251
|
+
return located;
|
|
252
|
+
}
|
|
253
|
+
window.__bossRecommendResumeCtx = located;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const ctx = window.__bossRecommendResumeCtx;
|
|
257
|
+
if (typeof TARGET_SCROLL === 'number' && Number.isFinite(TARGET_SCROLL)) {
|
|
258
|
+
try {
|
|
259
|
+
ctx.scroller.scrollTop = TARGET_SCROLL;
|
|
260
|
+
if (typeof ctx.scroller.scrollTo === 'function') {
|
|
261
|
+
ctx.scroller.scrollTo({ top: TARGET_SCROLL, left: 0, behavior: 'instant' });
|
|
262
|
+
}
|
|
263
|
+
ctx.scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
|
|
264
|
+
} catch {}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const scrollTop = Number(ctx.scroller.scrollTop || 0);
|
|
268
|
+
const scrollHeight = Number(ctx.scroller.scrollHeight || 0);
|
|
269
|
+
const clientHeight = Number(ctx.scroller.clientHeight || 0);
|
|
270
|
+
const maxScroll = Math.max(0, scrollHeight - clientHeight);
|
|
271
|
+
const clipRaw = absRect(ctx.clipEl);
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
ok: true,
|
|
275
|
+
scrollTop,
|
|
276
|
+
scrollHeight,
|
|
277
|
+
clientHeight,
|
|
278
|
+
maxScroll,
|
|
279
|
+
clip: {
|
|
280
|
+
x: clipRaw.x,
|
|
281
|
+
y: clipRaw.y,
|
|
282
|
+
width: Math.max(1, Math.min(clipRaw.width, Number(ctx.scroller.clientWidth || clipRaw.width))),
|
|
283
|
+
height: Math.max(1, Math.min(clipRaw.height, Number(ctx.scroller.clientHeight || clipRaw.height)))
|
|
284
|
+
},
|
|
285
|
+
canvas: ctx.canvas ? {
|
|
286
|
+
width: Number(ctx.canvas.width || 0),
|
|
287
|
+
height: Number(ctx.canvas.height || 0)
|
|
288
|
+
} : null,
|
|
289
|
+
debug: ctx.debug || {}
|
|
290
|
+
};
|
|
291
|
+
})()`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function captureFullResumeCanvas(options = {}) {
|
|
295
|
+
const host = options.host || process.env.CDP_HOST || "127.0.0.1";
|
|
296
|
+
const port = Number(options.port || process.env.CDP_PORT || 9222);
|
|
297
|
+
const waitResumeMs = Number(options.waitResumeMs || process.env.WAIT_RESUME_MS || 30000);
|
|
298
|
+
const scrollSettleMs = Number(options.scrollSettleMs || process.env.SCROLL_SETTLE_MS || 500);
|
|
299
|
+
const outPrefix = options.outPrefix || process.env.OUT_PREFIX || path.resolve(process.cwd(), "recommend_resume_full");
|
|
300
|
+
const targetPattern = options.targetPattern || process.env.TARGET_PATTERN || "/web/chat/recommend";
|
|
301
|
+
const stitchScript = path.resolve(__dirname, "stitch_resume_chunks.py");
|
|
302
|
+
const chunkDir = `${outPrefix}_chunks`;
|
|
303
|
+
const metadataFile = `${outPrefix}_chunks.json`;
|
|
304
|
+
const stitchedImage = `${outPrefix}.png`;
|
|
305
|
+
|
|
306
|
+
if (!fs.existsSync(stitchScript)) {
|
|
307
|
+
throw new Error(`Missing stitch script: ${stitchScript}`);
|
|
308
|
+
}
|
|
309
|
+
fs.mkdirSync(chunkDir, { recursive: true });
|
|
310
|
+
|
|
311
|
+
const targets = await getJson(`http://${host}:${port}/json/list`);
|
|
312
|
+
const target = pickTarget(targets, targetPattern);
|
|
313
|
+
if (!target?.webSocketDebuggerUrl) {
|
|
314
|
+
throw new Error("No debuggable zhipin page target found.");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
|
318
|
+
let seq = 0;
|
|
319
|
+
const pending = new Map();
|
|
320
|
+
|
|
321
|
+
function send(method, params = {}) {
|
|
322
|
+
const id = ++seq;
|
|
323
|
+
ws.send(JSON.stringify({ id, method, params }));
|
|
324
|
+
return new Promise((resolve, reject) => {
|
|
325
|
+
pending.set(id, { resolve, reject, method });
|
|
326
|
+
setTimeout(() => {
|
|
327
|
+
if (!pending.has(id)) return;
|
|
328
|
+
pending.delete(id);
|
|
329
|
+
reject(new Error(`Timeout: ${method}`));
|
|
330
|
+
}, 30000);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function evaluate(expression) {
|
|
335
|
+
return send("Runtime.evaluate", {
|
|
336
|
+
expression,
|
|
337
|
+
returnByValue: true,
|
|
338
|
+
awaitPromise: true
|
|
339
|
+
}).then((response) => response.result?.value);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
ws.on("message", (data) => {
|
|
343
|
+
let message;
|
|
344
|
+
try {
|
|
345
|
+
message = JSON.parse(String(data));
|
|
346
|
+
} catch {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (!message.id) return;
|
|
350
|
+
const promise = pending.get(message.id);
|
|
351
|
+
if (!promise) return;
|
|
352
|
+
pending.delete(message.id);
|
|
353
|
+
if (message.error) {
|
|
354
|
+
promise.reject(new Error(JSON.stringify(message.error)));
|
|
355
|
+
} else {
|
|
356
|
+
promise.resolve(message.result);
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
await new Promise((resolve, reject) => {
|
|
361
|
+
ws.once("open", resolve);
|
|
362
|
+
ws.once("error", reject);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
try {
|
|
366
|
+
await send("Page.enable");
|
|
367
|
+
await send("Runtime.enable");
|
|
368
|
+
await send("Page.bringToFront");
|
|
369
|
+
|
|
348
370
|
let probe = null;
|
|
349
371
|
let lastProbe = null;
|
|
372
|
+
let stableNoResumeIframePolls = 0;
|
|
350
373
|
const startTime = Date.now();
|
|
351
374
|
while (Date.now() - startTime < waitResumeMs) {
|
|
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
|
-
};
|
|
375
|
+
try {
|
|
376
|
+
probe = await evaluate(buildResumeProbeExpr({ init: true, targetScroll: 0 }));
|
|
377
|
+
} catch (error) {
|
|
378
|
+
probe = {
|
|
379
|
+
ok: false,
|
|
380
|
+
reason: "PROBE_EVALUATE_FAILED",
|
|
381
|
+
debug: {
|
|
382
|
+
message: String(error?.message || error || "unknown")
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
if (probe && typeof probe === "object") {
|
|
387
|
+
lastProbe = probe;
|
|
388
|
+
}
|
|
389
|
+
if (probe?.ok && probe.clip?.height > 80 && probe.clip?.width > 120) {
|
|
390
|
+
break;
|
|
362
391
|
}
|
|
363
|
-
if (probe
|
|
364
|
-
|
|
392
|
+
if (isStableNoResumeIframeProbe(probe)) {
|
|
393
|
+
stableNoResumeIframePolls += 1;
|
|
394
|
+
} else {
|
|
395
|
+
stableNoResumeIframePolls = 0;
|
|
365
396
|
}
|
|
366
|
-
|
|
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
|
+
}
|
|
367
416
|
break;
|
|
368
417
|
}
|
|
369
418
|
await sleep(700);
|
|
370
419
|
}
|
|
371
420
|
|
|
372
421
|
if (!probe?.ok) {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const maxScroll = Math.max(0, Number(probe.maxScroll || 0));
|
|
377
|
-
const step = Math.max(120, Math.floor(Number(probe.clientHeight || probe.clip.height || 800)));
|
|
378
|
-
const positions = [];
|
|
379
|
-
for (let pos = 0; pos <= maxScroll; pos += step) {
|
|
380
|
-
positions.push(Math.min(pos, maxScroll));
|
|
422
|
+
const elapsedMs = Math.max(0, Date.now() - startTime);
|
|
423
|
+
throw new Error(buildResumeProbeTimeoutMessage(Math.min(waitResumeMs, elapsedMs), lastProbe || probe));
|
|
381
424
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
const
|
|
405
|
-
if (
|
|
406
|
-
continue;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
chunks
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
}
|
|
468
|
-
|
|
425
|
+
|
|
426
|
+
const maxScroll = Math.max(0, Number(probe.maxScroll || 0));
|
|
427
|
+
const step = Math.max(120, Math.floor(Number(probe.clientHeight || probe.clip.height || 800)));
|
|
428
|
+
const positions = [];
|
|
429
|
+
for (let pos = 0; pos <= maxScroll; pos += step) {
|
|
430
|
+
positions.push(Math.min(pos, maxScroll));
|
|
431
|
+
}
|
|
432
|
+
if (!positions.length || positions[positions.length - 1] !== maxScroll) {
|
|
433
|
+
positions.push(maxScroll);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const uniquePositions = [...new Set(positions.map((value) => Math.round(value)))].sort((a, b) => a - b);
|
|
437
|
+
const chunks = [];
|
|
438
|
+
const seenScroll = [];
|
|
439
|
+
|
|
440
|
+
for (let index = 0; index < uniquePositions.length; index += 1) {
|
|
441
|
+
const targetScroll = uniquePositions[index];
|
|
442
|
+
await evaluate(buildResumeProbeExpr({ init: false, targetScroll }));
|
|
443
|
+
await sleep(scrollSettleMs);
|
|
444
|
+
const current = await evaluate(buildResumeProbeExpr({ init: false, targetScroll: null }));
|
|
445
|
+
if (!current?.ok) continue;
|
|
446
|
+
|
|
447
|
+
const actualScroll = Number(current.scrollTop || 0);
|
|
448
|
+
if (seenScroll.some((value) => Math.abs(value - actualScroll) < 1)) {
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const clip = current.clip || {};
|
|
453
|
+
const width = Number(clip.width || 0);
|
|
454
|
+
const height = Number(clip.height || 0);
|
|
455
|
+
if (width < 50 || height < 50) {
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const shot = await send("Page.captureScreenshot", {
|
|
460
|
+
format: "png",
|
|
461
|
+
captureBeyondViewport: true,
|
|
462
|
+
clip: {
|
|
463
|
+
x: Number(clip.x.toFixed(2)),
|
|
464
|
+
y: Number(clip.y.toFixed(2)),
|
|
465
|
+
width: Number(width.toFixed(2)),
|
|
466
|
+
height: Number(height.toFixed(2)),
|
|
467
|
+
scale: 1
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
const file = path.resolve(chunkDir, `chunk_${String(chunks.length).padStart(3, "0")}.png`);
|
|
472
|
+
fs.writeFileSync(file, Buffer.from(shot.data, "base64"));
|
|
473
|
+
seenScroll.push(actualScroll);
|
|
474
|
+
chunks.push({
|
|
475
|
+
index: chunks.length,
|
|
476
|
+
file,
|
|
477
|
+
scrollTop: actualScroll,
|
|
478
|
+
clipHeightCss: height,
|
|
479
|
+
clipWidthCss: width
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (!chunks.length) {
|
|
484
|
+
throw new Error("No screenshot chunks captured.");
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const metadata = {
|
|
488
|
+
createdAt: new Date().toISOString(),
|
|
489
|
+
target: { title: target.title, url: target.url },
|
|
490
|
+
probe,
|
|
491
|
+
chunks
|
|
492
|
+
};
|
|
493
|
+
fs.writeFileSync(metadataFile, JSON.stringify(metadata, null, 2), "utf8");
|
|
494
|
+
|
|
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}`);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return {
|
|
503
|
+
stitchedImage,
|
|
504
|
+
metadataFile,
|
|
505
|
+
chunkDir,
|
|
506
|
+
chunkCount: chunks.length,
|
|
507
|
+
target: {
|
|
508
|
+
title: target.title,
|
|
509
|
+
url: target.url
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
} finally {
|
|
513
|
+
try {
|
|
514
|
+
ws.close();
|
|
515
|
+
} catch {}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
469
519
|
module.exports = {
|
|
470
|
-
captureFullResumeCanvas
|
|
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
|
+
}
|
|
471
527
|
};
|
|
472
|
-
|
|
473
|
-
if (require.main === module) {
|
|
474
|
-
captureFullResumeCanvas()
|
|
475
|
-
.then((result) => {
|
|
476
|
-
console.log(JSON.stringify(result, null, 2));
|
|
477
|
-
})
|
|
478
|
-
.catch((error) => {
|
|
479
|
-
console.error(String(error?.message || error));
|
|
480
|
-
process.exit(1);
|
|
481
|
-
});
|
|
528
|
+
|
|
529
|
+
if (require.main === module) {
|
|
530
|
+
captureFullResumeCanvas()
|
|
531
|
+
.then((result) => {
|
|
532
|
+
console.log(JSON.stringify(result, null, 2));
|
|
533
|
+
})
|
|
534
|
+
.catch((error) => {
|
|
535
|
+
console.error(String(error?.message || error));
|
|
536
|
+
process.exit(1);
|
|
537
|
+
});
|
|
482
538
|
}
|