@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.
@@ -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
- function getJson(url) {
13
- return new Promise((resolve, reject) => {
14
- http
15
- .get(url, (res) => {
16
- let data = "";
17
- res.on("data", (chunk) => {
18
- data += chunk;
19
- });
20
- res.on("end", () => {
21
- try {
22
- resolve(JSON.parse(data));
23
- } catch (error) {
24
- reject(new Error(`Parse JSON failed for ${url}: ${error.message}`));
25
- }
26
- });
27
- })
28
- .on("error", reject);
29
- });
30
- }
31
-
32
- function pickTarget(targets, targetPattern) {
33
- const pages = targets.filter((item) => item.type === "page");
34
- if (!pages.length) return null;
35
- return (
36
- pages.find((item) => typeof item.url === "string" && item.url.includes(targetPattern))
37
- || pages.find((item) => /zhipin\.com/i.test(item.url || ""))
38
- || pages[0]
39
- );
40
- }
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
-
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 buildResumeProbeExpr({ init, targetScroll }) {
73
- const initLiteral = init ? "true" : "false";
74
- const scrollLiteral = typeof targetScroll === "number" && Number.isFinite(targetScroll)
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
- function evaluate(expression) {
313
- return send("Runtime.evaluate", {
314
- expression,
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
- ws.on("message", (data) => {
321
- let message;
322
- try {
323
- message = JSON.parse(String(data));
324
- } catch {
325
- return;
326
- }
327
- if (!message.id) return;
328
- const promise = pending.get(message.id);
329
- if (!promise) return;
330
- pending.delete(message.id);
331
- if (message.error) {
332
- promise.reject(new Error(JSON.stringify(message.error)));
333
- } else {
334
- promise.resolve(message.result);
335
- }
336
- });
337
-
338
- await new Promise((resolve, reject) => {
339
- ws.once("open", resolve);
340
- ws.once("error", reject);
341
- });
342
-
343
- try {
344
- await send("Page.enable");
345
- await send("Runtime.enable");
346
- await send("Page.bringToFront");
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 && typeof probe === "object") {
364
- lastProbe = probe;
392
+ if (isStableNoResumeIframeProbe(probe)) {
393
+ stableNoResumeIframePolls += 1;
394
+ } else {
395
+ stableNoResumeIframePolls = 0;
365
396
  }
366
- if (probe?.ok && probe.clip?.height > 80 && probe.clip?.width > 120) {
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
- throw new Error(buildResumeProbeTimeoutMessage(waitResumeMs, lastProbe || probe));
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
- if (!positions.length || positions[positions.length - 1] !== maxScroll) {
383
- positions.push(maxScroll);
384
- }
385
-
386
- const uniquePositions = [...new Set(positions.map((value) => Math.round(value)))].sort((a, b) => a - b);
387
- const chunks = [];
388
- const seenScroll = [];
389
-
390
- for (let index = 0; index < uniquePositions.length; index += 1) {
391
- const targetScroll = uniquePositions[index];
392
- await evaluate(buildResumeProbeExpr({ init: false, targetScroll }));
393
- await sleep(scrollSettleMs);
394
- const current = await evaluate(buildResumeProbeExpr({ init: false, targetScroll: null }));
395
- if (!current?.ok) continue;
396
-
397
- const actualScroll = Number(current.scrollTop || 0);
398
- if (seenScroll.some((value) => Math.abs(value - actualScroll) < 1)) {
399
- continue;
400
- }
401
-
402
- const clip = current.clip || {};
403
- const width = Number(clip.width || 0);
404
- const height = Number(clip.height || 0);
405
- if (width < 50 || height < 50) {
406
- continue;
407
- }
408
-
409
- const shot = await send("Page.captureScreenshot", {
410
- format: "png",
411
- captureBeyondViewport: true,
412
- clip: {
413
- x: Number(clip.x.toFixed(2)),
414
- y: Number(clip.y.toFixed(2)),
415
- width: Number(width.toFixed(2)),
416
- height: Number(height.toFixed(2)),
417
- scale: 1
418
- }
419
- });
420
-
421
- const file = path.resolve(chunkDir, `chunk_${String(chunks.length).padStart(3, "0")}.png`);
422
- fs.writeFileSync(file, Buffer.from(shot.data, "base64"));
423
- seenScroll.push(actualScroll);
424
- chunks.push({
425
- index: chunks.length,
426
- file,
427
- scrollTop: actualScroll,
428
- clipHeightCss: height,
429
- clipWidthCss: width
430
- });
431
- }
432
-
433
- if (!chunks.length) {
434
- throw new Error("No screenshot chunks captured.");
435
- }
436
-
437
- const metadata = {
438
- createdAt: new Date().toISOString(),
439
- target: { title: target.title, url: target.url },
440
- probe,
441
- chunks
442
- };
443
- fs.writeFileSync(metadataFile, JSON.stringify(metadata, null, 2), "utf8");
444
-
445
- const stitch = spawnSync("python", [stitchScript, metadataFile, stitchedImage], {
446
- encoding: "utf8"
447
- });
448
- if (stitch.status !== 0) {
449
- throw new Error(`Stitch failed: ${stitch.stderr || stitch.stdout}`);
450
- }
451
-
452
- return {
453
- stitchedImage,
454
- metadataFile,
455
- chunkDir,
456
- chunkCount: chunks.length,
457
- target: {
458
- title: target.title,
459
- url: target.url
460
- }
461
- };
462
- } finally {
463
- try {
464
- ws.close();
465
- } catch {}
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
  }