@reconcrap/boss-recommend-mcp 1.3.32 → 1.3.34
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/config/screening-config.example.json +11 -11
- package/package.json +64 -64
- package/src/boss-chat.js +769 -769
- package/src/test-adapters-runtime.js +628 -628
- package/src/test-boss-chat.js +2732 -2217
- package/vendor/boss-chat-cli/src/app.js +1435 -1268
- package/vendor/boss-chat-cli/src/browser/chat-page.js +441 -242
- package/vendor/boss-chat-cli/src/cli.js +1580 -1580
- package/vendor/boss-chat-cli/src/services/chrome-client.js +104 -100
- package/vendor/boss-chat-cli/src/services/llm.js +1146 -810
- package/vendor/boss-chat-cli/src/services/llm.test.js +326 -0
- package/vendor/boss-chat-cli/src/services/profile-store.js +168 -168
- package/vendor/boss-chat-cli/src/services/report-store.js +317 -317
- package/vendor/boss-chat-cli/src/services/resume-capture.js +469 -469
- package/vendor/boss-chat-cli/src/services/resume-network.js +727 -727
- package/vendor/boss-recommend-screen-cli/boss-recommend-screen-cli.cjs +6660 -6272
- package/vendor/boss-recommend-screen-cli/test-recoverable-resume-failures.cjs +429 -31
|
@@ -1,469 +1,469 @@
|
|
|
1
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import sharp from 'sharp';
|
|
4
|
-
|
|
5
|
-
function sleep(ms) {
|
|
6
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
function clamp(value, low, high) {
|
|
10
|
-
return Math.max(low, Math.min(high, value));
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function browserProbeResumeContext(options = {}) {
|
|
14
|
-
const INIT = Boolean(options.init);
|
|
15
|
-
const TARGET_SCROLL =
|
|
16
|
-
typeof options.targetScroll === 'number' && Number.isFinite(options.targetScroll)
|
|
17
|
-
? options.targetScroll
|
|
18
|
-
: null;
|
|
19
|
-
|
|
20
|
-
const absRect = (el) => {
|
|
21
|
-
const rect = el.getBoundingClientRect();
|
|
22
|
-
let x = rect.left;
|
|
23
|
-
let y = rect.top;
|
|
24
|
-
let win = el.ownerDocument.defaultView;
|
|
25
|
-
while (win && win !== win.parent) {
|
|
26
|
-
const frameEl = win.frameElement;
|
|
27
|
-
if (!frameEl) break;
|
|
28
|
-
const frameRect = frameEl.getBoundingClientRect();
|
|
29
|
-
x += frameRect.left;
|
|
30
|
-
y += frameRect.top;
|
|
31
|
-
win = win.parent;
|
|
32
|
-
}
|
|
33
|
-
return { x, y, width: rect.width, height: rect.height };
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
const isVisible = (el) => {
|
|
37
|
-
if (!(el instanceof HTMLElement)) return false;
|
|
38
|
-
const style = getComputedStyle(el);
|
|
39
|
-
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') < 0.01) {
|
|
40
|
-
return false;
|
|
41
|
-
}
|
|
42
|
-
const rect = el.getBoundingClientRect();
|
|
43
|
-
return rect.width > 30 && rect.height > 30;
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
const canScroll = (el) => Boolean(el && (el.scrollHeight || 0) > (el.clientHeight || 0) + 8);
|
|
47
|
-
|
|
48
|
-
const chooseScrollableAncestor = (startEl) => {
|
|
49
|
-
const candidates = [];
|
|
50
|
-
let current = startEl;
|
|
51
|
-
let depth = 0;
|
|
52
|
-
while (current && depth < 24) {
|
|
53
|
-
if ((current.clientHeight || 0) > 40 && canScroll(current)) {
|
|
54
|
-
const style = getComputedStyle(current);
|
|
55
|
-
const overflowY = String(style.overflowY || '').toLowerCase();
|
|
56
|
-
const key = `${current.id || ''} ${current.className || ''}`.toLowerCase();
|
|
57
|
-
let score = 0;
|
|
58
|
-
if (/auto|scroll|overlay/.test(overflowY)) score += 1000;
|
|
59
|
-
if (key.includes('resume')) score += 500;
|
|
60
|
-
if (key.includes('detail')) score += 220;
|
|
61
|
-
score += Math.min(250, Math.floor(((current.scrollHeight || 0) - (current.clientHeight || 0)) / 2));
|
|
62
|
-
score -= depth * 10;
|
|
63
|
-
candidates.push({ el: current, score });
|
|
64
|
-
}
|
|
65
|
-
current = current.parentElement;
|
|
66
|
-
depth += 1;
|
|
67
|
-
}
|
|
68
|
-
if (candidates.length === 0) return null;
|
|
69
|
-
candidates.sort((left, right) => right.score - left.score);
|
|
70
|
-
return candidates[0].el;
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
const locateInlineResumeContainer = (scopes) => {
|
|
74
|
-
const selectors = [
|
|
75
|
-
'.resume-detail.resume-detail-chat.resume-content-wrap.iframe-resume-detail',
|
|
76
|
-
'.resume-content-wrap.iframe-resume-detail',
|
|
77
|
-
'.resume-content-wrap',
|
|
78
|
-
'.resume-common-wrap',
|
|
79
|
-
'.resume-recommend',
|
|
80
|
-
'.resume-detail',
|
|
81
|
-
'.resume-container .resume-content-wrap',
|
|
82
|
-
];
|
|
83
|
-
|
|
84
|
-
for (const scope of scopes) {
|
|
85
|
-
for (const selector of selectors) {
|
|
86
|
-
const found = scope.querySelector(selector);
|
|
87
|
-
if (found && isVisible(found)) {
|
|
88
|
-
return found;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return null;
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
const locateContext = () => {
|
|
97
|
-
const scopes = Array.from(
|
|
98
|
-
document.querySelectorAll(
|
|
99
|
-
'.dialog-wrap.active, .boss-popup__wrapper, .boss-dialog, .geek-detail-modal, .modal, .boss-popup_wrapper',
|
|
100
|
-
),
|
|
101
|
-
).filter(isVisible);
|
|
102
|
-
const allResumeFrames = Array.from(
|
|
103
|
-
document.querySelectorAll('iframe[src*="/web/frame/c-resume/"], iframe[name*="resume"]'),
|
|
104
|
-
);
|
|
105
|
-
const visibleResumeFrames = allResumeFrames.filter(isVisible);
|
|
106
|
-
|
|
107
|
-
let resumeFrame = null;
|
|
108
|
-
for (const scope of scopes) {
|
|
109
|
-
const found = scope.querySelector('iframe[src*="/web/frame/c-resume/"], iframe[name*="resume"]');
|
|
110
|
-
if (found && isVisible(found)) {
|
|
111
|
-
resumeFrame = found;
|
|
112
|
-
break;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
if (!resumeFrame) {
|
|
116
|
-
resumeFrame = visibleResumeFrames[0] || null;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (!resumeFrame) {
|
|
120
|
-
const inlineResumeContainer = locateInlineResumeContainer(scopes);
|
|
121
|
-
if (!inlineResumeContainer) {
|
|
122
|
-
return {
|
|
123
|
-
ok: false,
|
|
124
|
-
reason: 'NO_RESUME_IFRAME',
|
|
125
|
-
debug: {
|
|
126
|
-
scopeCount: scopes.length,
|
|
127
|
-
totalResumeIframes: allResumeFrames.length,
|
|
128
|
-
visibleResumeIframes: visibleResumeFrames.length,
|
|
129
|
-
inlineResumeFound: false,
|
|
130
|
-
},
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const inlineScroller =
|
|
135
|
-
chooseScrollableAncestor(inlineResumeContainer) ||
|
|
136
|
-
inlineResumeContainer;
|
|
137
|
-
if (!inlineScroller || !isVisible(inlineScroller)) {
|
|
138
|
-
return {
|
|
139
|
-
ok: false,
|
|
140
|
-
reason: 'NO_SCROLL_CONTAINER',
|
|
141
|
-
debug: {
|
|
142
|
-
scopeCount: scopes.length,
|
|
143
|
-
totalResumeIframes: allResumeFrames.length,
|
|
144
|
-
visibleResumeIframes: visibleResumeFrames.length,
|
|
145
|
-
inlineResumeFound: true,
|
|
146
|
-
scrollerFound: Boolean(inlineScroller),
|
|
147
|
-
scrollerVisible: Boolean(inlineScroller && isVisible(inlineScroller)),
|
|
148
|
-
scrollerClass: inlineScroller ? String(inlineScroller.className || '') : '',
|
|
149
|
-
},
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
return {
|
|
154
|
-
ok: true,
|
|
155
|
-
mode: 'inline',
|
|
156
|
-
frame: null,
|
|
157
|
-
canvas: null,
|
|
158
|
-
scroller: inlineScroller,
|
|
159
|
-
clipEl: inlineScroller,
|
|
160
|
-
debug: {
|
|
161
|
-
scopeCount: scopes.length,
|
|
162
|
-
totalResumeIframes: allResumeFrames.length,
|
|
163
|
-
visibleResumeIframes: visibleResumeFrames.length,
|
|
164
|
-
inlineResumeFound: true,
|
|
165
|
-
inlineResumeClass: String(inlineResumeContainer.className || ''),
|
|
166
|
-
scrollerClass: String(inlineScroller.className || ''),
|
|
167
|
-
},
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const resumeDoc = resumeFrame.contentDocument;
|
|
172
|
-
const canvas = resumeDoc ? resumeDoc.querySelector('canvas#resume') || resumeDoc.querySelector('canvas') : null;
|
|
173
|
-
const scroller =
|
|
174
|
-
chooseScrollableAncestor(resumeFrame.parentElement || resumeFrame) ||
|
|
175
|
-
document.querySelector('.resume-detail-wrap') ||
|
|
176
|
-
chooseScrollableAncestor(resumeFrame) ||
|
|
177
|
-
resumeFrame.parentElement ||
|
|
178
|
-
resumeFrame;
|
|
179
|
-
|
|
180
|
-
if (!scroller || !isVisible(scroller)) {
|
|
181
|
-
return {
|
|
182
|
-
ok: false,
|
|
183
|
-
reason: 'NO_SCROLL_CONTAINER',
|
|
184
|
-
debug: {
|
|
185
|
-
scopeCount: scopes.length,
|
|
186
|
-
totalResumeIframes: allResumeFrames.length,
|
|
187
|
-
visibleResumeIframes: visibleResumeFrames.length,
|
|
188
|
-
resumeFrameSrc: String(resumeFrame.src || ''),
|
|
189
|
-
scrollerFound: Boolean(scroller),
|
|
190
|
-
scrollerVisible: Boolean(scroller && isVisible(scroller)),
|
|
191
|
-
scrollerClass: scroller ? String(scroller.className || '') : '',
|
|
192
|
-
},
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
return {
|
|
197
|
-
ok: true,
|
|
198
|
-
mode: 'iframe',
|
|
199
|
-
frame: resumeFrame,
|
|
200
|
-
canvas,
|
|
201
|
-
scroller,
|
|
202
|
-
clipEl: scroller,
|
|
203
|
-
debug: {
|
|
204
|
-
resumeFrameSrc: String(resumeFrame.src || ''),
|
|
205
|
-
scrollerClass: String(scroller.className || ''),
|
|
206
|
-
},
|
|
207
|
-
};
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
if (
|
|
211
|
-
INIT ||
|
|
212
|
-
!window.__bossChatResumeCtx ||
|
|
213
|
-
!window.__bossChatResumeCtx.scroller ||
|
|
214
|
-
!window.__bossChatResumeCtx.scroller.isConnected
|
|
215
|
-
) {
|
|
216
|
-
const located = locateContext();
|
|
217
|
-
if (!located.ok) {
|
|
218
|
-
return located;
|
|
219
|
-
}
|
|
220
|
-
window.__bossChatResumeCtx = located;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const ctx = window.__bossChatResumeCtx;
|
|
224
|
-
if (typeof TARGET_SCROLL === 'number' && Number.isFinite(TARGET_SCROLL)) {
|
|
225
|
-
try {
|
|
226
|
-
ctx.scroller.scrollTop = TARGET_SCROLL;
|
|
227
|
-
if (typeof ctx.scroller.scrollTo === 'function') {
|
|
228
|
-
ctx.scroller.scrollTo({ top: TARGET_SCROLL, left: 0, behavior: 'instant' });
|
|
229
|
-
}
|
|
230
|
-
ctx.scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
|
|
231
|
-
} catch {}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
const scrollTop = Number(ctx.scroller.scrollTop || 0);
|
|
235
|
-
const scrollHeight = Number(ctx.scroller.scrollHeight || 0);
|
|
236
|
-
const clientHeight = Number(ctx.scroller.clientHeight || 0);
|
|
237
|
-
const maxScroll = Math.max(0, scrollHeight - clientHeight);
|
|
238
|
-
const clipRaw = absRect(ctx.clipEl);
|
|
239
|
-
const baseClipHeight = Math.max(
|
|
240
|
-
1,
|
|
241
|
-
Math.min(clipRaw.height, Number(ctx.scroller.clientHeight || clipRaw.height)),
|
|
242
|
-
);
|
|
243
|
-
const baseClipTop = Number(clipRaw.y || 0);
|
|
244
|
-
|
|
245
|
-
let noiseCutoffHeight = null;
|
|
246
|
-
try {
|
|
247
|
-
const noiseSelectors = [
|
|
248
|
-
'.resume-anonymous-geek-card.v2',
|
|
249
|
-
'.resume-anonymous-geek-card',
|
|
250
|
-
'.resume-anonymous-geek-card .card-container',
|
|
251
|
-
'.resume-warning',
|
|
252
|
-
];
|
|
253
|
-
const noiseNodes = Array.from(ctx.scroller.querySelectorAll(noiseSelectors.join(','))).filter(
|
|
254
|
-
(node) => node instanceof HTMLElement && isVisible(node),
|
|
255
|
-
);
|
|
256
|
-
for (const node of noiseNodes) {
|
|
257
|
-
const rect = absRect(node);
|
|
258
|
-
if (!(rect.width > 8 && rect.height > 8)) continue;
|
|
259
|
-
const relTop = rect.y - baseClipTop;
|
|
260
|
-
if (relTop <= 80) continue;
|
|
261
|
-
const candidateCutoff = Math.max(1, Math.floor(relTop - 6));
|
|
262
|
-
if (candidateCutoff < baseClipHeight) {
|
|
263
|
-
noiseCutoffHeight = candidateCutoff;
|
|
264
|
-
break;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
} catch {}
|
|
268
|
-
const finalClipHeight =
|
|
269
|
-
typeof noiseCutoffHeight === 'number' && Number.isFinite(noiseCutoffHeight)
|
|
270
|
-
? Math.max(1, Math.min(baseClipHeight, noiseCutoffHeight))
|
|
271
|
-
: baseClipHeight;
|
|
272
|
-
|
|
273
|
-
return {
|
|
274
|
-
ok: true,
|
|
275
|
-
mode: ctx.mode || 'unknown',
|
|
276
|
-
scrollTop,
|
|
277
|
-
scrollHeight,
|
|
278
|
-
clientHeight,
|
|
279
|
-
maxScroll,
|
|
280
|
-
clip: {
|
|
281
|
-
x: clipRaw.x,
|
|
282
|
-
y: clipRaw.y,
|
|
283
|
-
width: Math.max(1, Math.min(clipRaw.width, Number(ctx.scroller.clientWidth || clipRaw.width))),
|
|
284
|
-
height: finalClipHeight,
|
|
285
|
-
},
|
|
286
|
-
canvas: ctx.canvas
|
|
287
|
-
? {
|
|
288
|
-
width: Number(ctx.canvas.width || 0),
|
|
289
|
-
height: Number(ctx.canvas.height || 0),
|
|
290
|
-
}
|
|
291
|
-
: null,
|
|
292
|
-
debug: ctx.debug || {},
|
|
293
|
-
};
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
async function detectLikelyBlankChunks(chunkFiles = []) {
|
|
297
|
-
const normalizedFiles = Array.isArray(chunkFiles) ? chunkFiles.filter(Boolean) : [];
|
|
298
|
-
if (normalizedFiles.length <= 0) {
|
|
299
|
-
return { likelyBlank: false, luma: 0, avgStd: 0, blankChunks: 0, totalChunks: 0 };
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
let lumaTotal = 0;
|
|
303
|
-
let stdTotal = 0;
|
|
304
|
-
let blankChunks = 0;
|
|
305
|
-
|
|
306
|
-
for (const file of normalizedFiles) {
|
|
307
|
-
const stats = await sharp(file).stats();
|
|
308
|
-
const channels = stats?.channels || [];
|
|
309
|
-
if (channels.length < 3) {
|
|
310
|
-
continue;
|
|
311
|
-
}
|
|
312
|
-
const meanR = Number(channels[0]?.mean || 0);
|
|
313
|
-
const meanG = Number(channels[1]?.mean || 0);
|
|
314
|
-
const meanB = Number(channels[2]?.mean || 0);
|
|
315
|
-
const stdR = Number(channels[0]?.stdev || 0);
|
|
316
|
-
const stdG = Number(channels[1]?.stdev || 0);
|
|
317
|
-
const stdB = Number(channels[2]?.stdev || 0);
|
|
318
|
-
const luma = 0.299 * meanR + 0.587 * meanG + 0.114 * meanB;
|
|
319
|
-
const avgStd = (stdR + stdG + stdB) / 3;
|
|
320
|
-
lumaTotal += luma;
|
|
321
|
-
stdTotal += avgStd;
|
|
322
|
-
if (luma >= 244 && avgStd <= 9) {
|
|
323
|
-
blankChunks += 1;
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
const totalChunks = normalizedFiles.length;
|
|
328
|
-
const avgLuma = totalChunks > 0 ? lumaTotal / totalChunks : 0;
|
|
329
|
-
const avgStd = totalChunks > 0 ? stdTotal / totalChunks : 0;
|
|
330
|
-
const likelyBlank = blankChunks === totalChunks;
|
|
331
|
-
return {
|
|
332
|
-
likelyBlank,
|
|
333
|
-
luma: Number(avgLuma.toFixed(2)),
|
|
334
|
-
avgStd: Number(avgStd.toFixed(2)),
|
|
335
|
-
blankChunks,
|
|
336
|
-
totalChunks,
|
|
337
|
-
};
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
export class ResumeCaptureService {
|
|
341
|
-
constructor({ chromeClient, logger = console } = {}) {
|
|
342
|
-
this.chromeClient = chromeClient;
|
|
343
|
-
this.logger = logger;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
async waitForProbe({ waitResumeMs = 30000, pollMs = 700 } = {}) {
|
|
347
|
-
const start = Date.now();
|
|
348
|
-
let lastProbe = null;
|
|
349
|
-
while (Date.now() - start < waitResumeMs) {
|
|
350
|
-
const probe = await this.chromeClient.callFunction(browserProbeResumeContext, {
|
|
351
|
-
init: true,
|
|
352
|
-
targetScroll: 0,
|
|
353
|
-
});
|
|
354
|
-
if (probe && typeof probe === 'object') {
|
|
355
|
-
lastProbe = probe;
|
|
356
|
-
}
|
|
357
|
-
if (probe?.ok && probe?.clip?.height > 80 && probe?.clip?.width > 120) {
|
|
358
|
-
return probe;
|
|
359
|
-
}
|
|
360
|
-
await sleep(pollMs);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
const reason = lastProbe?.reason || 'UNKNOWN';
|
|
364
|
-
throw new Error(`Resume context probe timeout: reason=${reason}`);
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
async captureResume({ artifactDir, waitResumeMs = 30000, scrollSettleMs = 500 } = {}) {
|
|
368
|
-
if (!artifactDir) {
|
|
369
|
-
throw new Error('artifactDir is required for resume capture');
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
await mkdir(artifactDir, { recursive: true });
|
|
373
|
-
const chunkDir = path.join(artifactDir, 'chunks');
|
|
374
|
-
await mkdir(chunkDir, { recursive: true });
|
|
375
|
-
const metadataFile = path.join(artifactDir, 'chunks.json');
|
|
376
|
-
|
|
377
|
-
const probe = await this.waitForProbe({ waitResumeMs });
|
|
378
|
-
const maxScroll = Math.max(0, Number(probe.maxScroll || 0));
|
|
379
|
-
const step = Math.max(120, Math.floor(Number(probe.clientHeight || probe.clip?.height || 800)));
|
|
380
|
-
const positions = [];
|
|
381
|
-
for (let pos = 0; pos <= maxScroll; pos += step) {
|
|
382
|
-
positions.push(Math.min(pos, maxScroll));
|
|
383
|
-
}
|
|
384
|
-
if (positions.length === 0 || positions[positions.length - 1] !== maxScroll) {
|
|
385
|
-
positions.push(maxScroll);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
const uniquePositions = [...new Set(positions.map((value) => Math.round(value)))].sort((a, b) => a - b);
|
|
389
|
-
const chunks = [];
|
|
390
|
-
const seenScroll = [];
|
|
391
|
-
|
|
392
|
-
for (let index = 0; index < uniquePositions.length; index += 1) {
|
|
393
|
-
const targetScroll = uniquePositions[index];
|
|
394
|
-
await this.chromeClient.callFunction(browserProbeResumeContext, {
|
|
395
|
-
init: false,
|
|
396
|
-
targetScroll,
|
|
397
|
-
});
|
|
398
|
-
await sleep(scrollSettleMs);
|
|
399
|
-
|
|
400
|
-
const current = await this.chromeClient.callFunction(browserProbeResumeContext, {
|
|
401
|
-
init: false,
|
|
402
|
-
targetScroll: null,
|
|
403
|
-
});
|
|
404
|
-
if (!current?.ok) continue;
|
|
405
|
-
|
|
406
|
-
const actualScroll = Number(current.scrollTop || 0);
|
|
407
|
-
if (seenScroll.some((value) => Math.abs(value - actualScroll) < 1)) {
|
|
408
|
-
continue;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
const clip = current.clip || {};
|
|
412
|
-
const width = Number(clip.width || 0);
|
|
413
|
-
const height = Number(clip.height || 0);
|
|
414
|
-
if (width < 50 || height < 50) {
|
|
415
|
-
continue;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
const shot = await this.chromeClient.Page.captureScreenshot({
|
|
419
|
-
format: 'png',
|
|
420
|
-
captureBeyondViewport: true,
|
|
421
|
-
clip: {
|
|
422
|
-
x: Number(clip.x.toFixed(2)),
|
|
423
|
-
y: Number(clip.y.toFixed(2)),
|
|
424
|
-
width: Number(width.toFixed(2)),
|
|
425
|
-
height: Number(height.toFixed(2)),
|
|
426
|
-
scale: 1,
|
|
427
|
-
},
|
|
428
|
-
});
|
|
429
|
-
const file = path.resolve(chunkDir, `chunk_${String(chunks.length).padStart(3, '0')}.png`);
|
|
430
|
-
await writeFile(file, Buffer.from(shot.data, 'base64'));
|
|
431
|
-
seenScroll.push(actualScroll);
|
|
432
|
-
chunks.push({
|
|
433
|
-
index: chunks.length,
|
|
434
|
-
file,
|
|
435
|
-
scrollTop: actualScroll,
|
|
436
|
-
clipHeightCss: height,
|
|
437
|
-
clipWidthCss: width,
|
|
438
|
-
});
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
if (chunks.length === 0) {
|
|
442
|
-
throw new Error('No screenshot chunks captured from resume modal');
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
const metadata = {
|
|
446
|
-
createdAt: new Date().toISOString(),
|
|
447
|
-
probe,
|
|
448
|
-
chunks,
|
|
449
|
-
};
|
|
450
|
-
await writeFile(metadataFile, `${JSON.stringify(metadata, null, 2)}\n`, 'utf8');
|
|
451
|
-
const chunkFiles = chunks.map((chunk) => path.resolve(chunk.file));
|
|
452
|
-
const blank = await detectLikelyBlankChunks(chunkFiles);
|
|
453
|
-
this.logger.log(
|
|
454
|
-
`简历截图完成: chunks=${chunks.length}, modelImages=${chunkFiles.length}, likelyBlank=${blank.likelyBlank}, blankChunks=${blank.blankChunks}/${blank.totalChunks}, luma=${blank.luma}, std=${blank.avgStd}`,
|
|
455
|
-
);
|
|
456
|
-
|
|
457
|
-
return {
|
|
458
|
-
metadataFile,
|
|
459
|
-
chunkDir,
|
|
460
|
-
chunkCount: chunks.length,
|
|
461
|
-
chunkFiles,
|
|
462
|
-
modelImagePaths: chunkFiles,
|
|
463
|
-
stitchedImage: '',
|
|
464
|
-
stitchEngine: 'skipped',
|
|
465
|
-
stitched: null,
|
|
466
|
-
quality: blank,
|
|
467
|
-
};
|
|
468
|
-
}
|
|
469
|
-
}
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import sharp from 'sharp';
|
|
4
|
+
|
|
5
|
+
function sleep(ms) {
|
|
6
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function clamp(value, low, high) {
|
|
10
|
+
return Math.max(low, Math.min(high, value));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function browserProbeResumeContext(options = {}) {
|
|
14
|
+
const INIT = Boolean(options.init);
|
|
15
|
+
const TARGET_SCROLL =
|
|
16
|
+
typeof options.targetScroll === 'number' && Number.isFinite(options.targetScroll)
|
|
17
|
+
? options.targetScroll
|
|
18
|
+
: null;
|
|
19
|
+
|
|
20
|
+
const absRect = (el) => {
|
|
21
|
+
const rect = el.getBoundingClientRect();
|
|
22
|
+
let x = rect.left;
|
|
23
|
+
let y = rect.top;
|
|
24
|
+
let win = el.ownerDocument.defaultView;
|
|
25
|
+
while (win && win !== win.parent) {
|
|
26
|
+
const frameEl = win.frameElement;
|
|
27
|
+
if (!frameEl) break;
|
|
28
|
+
const frameRect = frameEl.getBoundingClientRect();
|
|
29
|
+
x += frameRect.left;
|
|
30
|
+
y += frameRect.top;
|
|
31
|
+
win = win.parent;
|
|
32
|
+
}
|
|
33
|
+
return { x, y, width: rect.width, height: rect.height };
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const isVisible = (el) => {
|
|
37
|
+
if (!(el instanceof HTMLElement)) return false;
|
|
38
|
+
const style = getComputedStyle(el);
|
|
39
|
+
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') < 0.01) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
const rect = el.getBoundingClientRect();
|
|
43
|
+
return rect.width > 30 && rect.height > 30;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const canScroll = (el) => Boolean(el && (el.scrollHeight || 0) > (el.clientHeight || 0) + 8);
|
|
47
|
+
|
|
48
|
+
const chooseScrollableAncestor = (startEl) => {
|
|
49
|
+
const candidates = [];
|
|
50
|
+
let current = startEl;
|
|
51
|
+
let depth = 0;
|
|
52
|
+
while (current && depth < 24) {
|
|
53
|
+
if ((current.clientHeight || 0) > 40 && canScroll(current)) {
|
|
54
|
+
const style = getComputedStyle(current);
|
|
55
|
+
const overflowY = String(style.overflowY || '').toLowerCase();
|
|
56
|
+
const key = `${current.id || ''} ${current.className || ''}`.toLowerCase();
|
|
57
|
+
let score = 0;
|
|
58
|
+
if (/auto|scroll|overlay/.test(overflowY)) score += 1000;
|
|
59
|
+
if (key.includes('resume')) score += 500;
|
|
60
|
+
if (key.includes('detail')) score += 220;
|
|
61
|
+
score += Math.min(250, Math.floor(((current.scrollHeight || 0) - (current.clientHeight || 0)) / 2));
|
|
62
|
+
score -= depth * 10;
|
|
63
|
+
candidates.push({ el: current, score });
|
|
64
|
+
}
|
|
65
|
+
current = current.parentElement;
|
|
66
|
+
depth += 1;
|
|
67
|
+
}
|
|
68
|
+
if (candidates.length === 0) return null;
|
|
69
|
+
candidates.sort((left, right) => right.score - left.score);
|
|
70
|
+
return candidates[0].el;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const locateInlineResumeContainer = (scopes) => {
|
|
74
|
+
const selectors = [
|
|
75
|
+
'.resume-detail.resume-detail-chat.resume-content-wrap.iframe-resume-detail',
|
|
76
|
+
'.resume-content-wrap.iframe-resume-detail',
|
|
77
|
+
'.resume-content-wrap',
|
|
78
|
+
'.resume-common-wrap',
|
|
79
|
+
'.resume-recommend',
|
|
80
|
+
'.resume-detail',
|
|
81
|
+
'.resume-container .resume-content-wrap',
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
for (const scope of scopes) {
|
|
85
|
+
for (const selector of selectors) {
|
|
86
|
+
const found = scope.querySelector(selector);
|
|
87
|
+
if (found && isVisible(found)) {
|
|
88
|
+
return found;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return null;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const locateContext = () => {
|
|
97
|
+
const scopes = Array.from(
|
|
98
|
+
document.querySelectorAll(
|
|
99
|
+
'.dialog-wrap.active, .boss-popup__wrapper, .boss-dialog, .geek-detail-modal, .modal, .boss-popup_wrapper',
|
|
100
|
+
),
|
|
101
|
+
).filter(isVisible);
|
|
102
|
+
const allResumeFrames = Array.from(
|
|
103
|
+
document.querySelectorAll('iframe[src*="/web/frame/c-resume/"], iframe[name*="resume"]'),
|
|
104
|
+
);
|
|
105
|
+
const visibleResumeFrames = allResumeFrames.filter(isVisible);
|
|
106
|
+
|
|
107
|
+
let resumeFrame = null;
|
|
108
|
+
for (const scope of scopes) {
|
|
109
|
+
const found = scope.querySelector('iframe[src*="/web/frame/c-resume/"], iframe[name*="resume"]');
|
|
110
|
+
if (found && isVisible(found)) {
|
|
111
|
+
resumeFrame = found;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!resumeFrame) {
|
|
116
|
+
resumeFrame = visibleResumeFrames[0] || null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!resumeFrame) {
|
|
120
|
+
const inlineResumeContainer = locateInlineResumeContainer(scopes);
|
|
121
|
+
if (!inlineResumeContainer) {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
reason: 'NO_RESUME_IFRAME',
|
|
125
|
+
debug: {
|
|
126
|
+
scopeCount: scopes.length,
|
|
127
|
+
totalResumeIframes: allResumeFrames.length,
|
|
128
|
+
visibleResumeIframes: visibleResumeFrames.length,
|
|
129
|
+
inlineResumeFound: false,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const inlineScroller =
|
|
135
|
+
chooseScrollableAncestor(inlineResumeContainer) ||
|
|
136
|
+
inlineResumeContainer;
|
|
137
|
+
if (!inlineScroller || !isVisible(inlineScroller)) {
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
reason: 'NO_SCROLL_CONTAINER',
|
|
141
|
+
debug: {
|
|
142
|
+
scopeCount: scopes.length,
|
|
143
|
+
totalResumeIframes: allResumeFrames.length,
|
|
144
|
+
visibleResumeIframes: visibleResumeFrames.length,
|
|
145
|
+
inlineResumeFound: true,
|
|
146
|
+
scrollerFound: Boolean(inlineScroller),
|
|
147
|
+
scrollerVisible: Boolean(inlineScroller && isVisible(inlineScroller)),
|
|
148
|
+
scrollerClass: inlineScroller ? String(inlineScroller.className || '') : '',
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
ok: true,
|
|
155
|
+
mode: 'inline',
|
|
156
|
+
frame: null,
|
|
157
|
+
canvas: null,
|
|
158
|
+
scroller: inlineScroller,
|
|
159
|
+
clipEl: inlineScroller,
|
|
160
|
+
debug: {
|
|
161
|
+
scopeCount: scopes.length,
|
|
162
|
+
totalResumeIframes: allResumeFrames.length,
|
|
163
|
+
visibleResumeIframes: visibleResumeFrames.length,
|
|
164
|
+
inlineResumeFound: true,
|
|
165
|
+
inlineResumeClass: String(inlineResumeContainer.className || ''),
|
|
166
|
+
scrollerClass: String(inlineScroller.className || ''),
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const resumeDoc = resumeFrame.contentDocument;
|
|
172
|
+
const canvas = resumeDoc ? resumeDoc.querySelector('canvas#resume') || resumeDoc.querySelector('canvas') : null;
|
|
173
|
+
const scroller =
|
|
174
|
+
chooseScrollableAncestor(resumeFrame.parentElement || resumeFrame) ||
|
|
175
|
+
document.querySelector('.resume-detail-wrap') ||
|
|
176
|
+
chooseScrollableAncestor(resumeFrame) ||
|
|
177
|
+
resumeFrame.parentElement ||
|
|
178
|
+
resumeFrame;
|
|
179
|
+
|
|
180
|
+
if (!scroller || !isVisible(scroller)) {
|
|
181
|
+
return {
|
|
182
|
+
ok: false,
|
|
183
|
+
reason: 'NO_SCROLL_CONTAINER',
|
|
184
|
+
debug: {
|
|
185
|
+
scopeCount: scopes.length,
|
|
186
|
+
totalResumeIframes: allResumeFrames.length,
|
|
187
|
+
visibleResumeIframes: visibleResumeFrames.length,
|
|
188
|
+
resumeFrameSrc: String(resumeFrame.src || ''),
|
|
189
|
+
scrollerFound: Boolean(scroller),
|
|
190
|
+
scrollerVisible: Boolean(scroller && isVisible(scroller)),
|
|
191
|
+
scrollerClass: scroller ? String(scroller.className || '') : '',
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
ok: true,
|
|
198
|
+
mode: 'iframe',
|
|
199
|
+
frame: resumeFrame,
|
|
200
|
+
canvas,
|
|
201
|
+
scroller,
|
|
202
|
+
clipEl: scroller,
|
|
203
|
+
debug: {
|
|
204
|
+
resumeFrameSrc: String(resumeFrame.src || ''),
|
|
205
|
+
scrollerClass: String(scroller.className || ''),
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
if (
|
|
211
|
+
INIT ||
|
|
212
|
+
!window.__bossChatResumeCtx ||
|
|
213
|
+
!window.__bossChatResumeCtx.scroller ||
|
|
214
|
+
!window.__bossChatResumeCtx.scroller.isConnected
|
|
215
|
+
) {
|
|
216
|
+
const located = locateContext();
|
|
217
|
+
if (!located.ok) {
|
|
218
|
+
return located;
|
|
219
|
+
}
|
|
220
|
+
window.__bossChatResumeCtx = located;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const ctx = window.__bossChatResumeCtx;
|
|
224
|
+
if (typeof TARGET_SCROLL === 'number' && Number.isFinite(TARGET_SCROLL)) {
|
|
225
|
+
try {
|
|
226
|
+
ctx.scroller.scrollTop = TARGET_SCROLL;
|
|
227
|
+
if (typeof ctx.scroller.scrollTo === 'function') {
|
|
228
|
+
ctx.scroller.scrollTo({ top: TARGET_SCROLL, left: 0, behavior: 'instant' });
|
|
229
|
+
}
|
|
230
|
+
ctx.scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
|
|
231
|
+
} catch {}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const scrollTop = Number(ctx.scroller.scrollTop || 0);
|
|
235
|
+
const scrollHeight = Number(ctx.scroller.scrollHeight || 0);
|
|
236
|
+
const clientHeight = Number(ctx.scroller.clientHeight || 0);
|
|
237
|
+
const maxScroll = Math.max(0, scrollHeight - clientHeight);
|
|
238
|
+
const clipRaw = absRect(ctx.clipEl);
|
|
239
|
+
const baseClipHeight = Math.max(
|
|
240
|
+
1,
|
|
241
|
+
Math.min(clipRaw.height, Number(ctx.scroller.clientHeight || clipRaw.height)),
|
|
242
|
+
);
|
|
243
|
+
const baseClipTop = Number(clipRaw.y || 0);
|
|
244
|
+
|
|
245
|
+
let noiseCutoffHeight = null;
|
|
246
|
+
try {
|
|
247
|
+
const noiseSelectors = [
|
|
248
|
+
'.resume-anonymous-geek-card.v2',
|
|
249
|
+
'.resume-anonymous-geek-card',
|
|
250
|
+
'.resume-anonymous-geek-card .card-container',
|
|
251
|
+
'.resume-warning',
|
|
252
|
+
];
|
|
253
|
+
const noiseNodes = Array.from(ctx.scroller.querySelectorAll(noiseSelectors.join(','))).filter(
|
|
254
|
+
(node) => node instanceof HTMLElement && isVisible(node),
|
|
255
|
+
);
|
|
256
|
+
for (const node of noiseNodes) {
|
|
257
|
+
const rect = absRect(node);
|
|
258
|
+
if (!(rect.width > 8 && rect.height > 8)) continue;
|
|
259
|
+
const relTop = rect.y - baseClipTop;
|
|
260
|
+
if (relTop <= 80) continue;
|
|
261
|
+
const candidateCutoff = Math.max(1, Math.floor(relTop - 6));
|
|
262
|
+
if (candidateCutoff < baseClipHeight) {
|
|
263
|
+
noiseCutoffHeight = candidateCutoff;
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
} catch {}
|
|
268
|
+
const finalClipHeight =
|
|
269
|
+
typeof noiseCutoffHeight === 'number' && Number.isFinite(noiseCutoffHeight)
|
|
270
|
+
? Math.max(1, Math.min(baseClipHeight, noiseCutoffHeight))
|
|
271
|
+
: baseClipHeight;
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
ok: true,
|
|
275
|
+
mode: ctx.mode || 'unknown',
|
|
276
|
+
scrollTop,
|
|
277
|
+
scrollHeight,
|
|
278
|
+
clientHeight,
|
|
279
|
+
maxScroll,
|
|
280
|
+
clip: {
|
|
281
|
+
x: clipRaw.x,
|
|
282
|
+
y: clipRaw.y,
|
|
283
|
+
width: Math.max(1, Math.min(clipRaw.width, Number(ctx.scroller.clientWidth || clipRaw.width))),
|
|
284
|
+
height: finalClipHeight,
|
|
285
|
+
},
|
|
286
|
+
canvas: ctx.canvas
|
|
287
|
+
? {
|
|
288
|
+
width: Number(ctx.canvas.width || 0),
|
|
289
|
+
height: Number(ctx.canvas.height || 0),
|
|
290
|
+
}
|
|
291
|
+
: null,
|
|
292
|
+
debug: ctx.debug || {},
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function detectLikelyBlankChunks(chunkFiles = []) {
|
|
297
|
+
const normalizedFiles = Array.isArray(chunkFiles) ? chunkFiles.filter(Boolean) : [];
|
|
298
|
+
if (normalizedFiles.length <= 0) {
|
|
299
|
+
return { likelyBlank: false, luma: 0, avgStd: 0, blankChunks: 0, totalChunks: 0 };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
let lumaTotal = 0;
|
|
303
|
+
let stdTotal = 0;
|
|
304
|
+
let blankChunks = 0;
|
|
305
|
+
|
|
306
|
+
for (const file of normalizedFiles) {
|
|
307
|
+
const stats = await sharp(file).stats();
|
|
308
|
+
const channels = stats?.channels || [];
|
|
309
|
+
if (channels.length < 3) {
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
const meanR = Number(channels[0]?.mean || 0);
|
|
313
|
+
const meanG = Number(channels[1]?.mean || 0);
|
|
314
|
+
const meanB = Number(channels[2]?.mean || 0);
|
|
315
|
+
const stdR = Number(channels[0]?.stdev || 0);
|
|
316
|
+
const stdG = Number(channels[1]?.stdev || 0);
|
|
317
|
+
const stdB = Number(channels[2]?.stdev || 0);
|
|
318
|
+
const luma = 0.299 * meanR + 0.587 * meanG + 0.114 * meanB;
|
|
319
|
+
const avgStd = (stdR + stdG + stdB) / 3;
|
|
320
|
+
lumaTotal += luma;
|
|
321
|
+
stdTotal += avgStd;
|
|
322
|
+
if (luma >= 244 && avgStd <= 9) {
|
|
323
|
+
blankChunks += 1;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const totalChunks = normalizedFiles.length;
|
|
328
|
+
const avgLuma = totalChunks > 0 ? lumaTotal / totalChunks : 0;
|
|
329
|
+
const avgStd = totalChunks > 0 ? stdTotal / totalChunks : 0;
|
|
330
|
+
const likelyBlank = blankChunks === totalChunks;
|
|
331
|
+
return {
|
|
332
|
+
likelyBlank,
|
|
333
|
+
luma: Number(avgLuma.toFixed(2)),
|
|
334
|
+
avgStd: Number(avgStd.toFixed(2)),
|
|
335
|
+
blankChunks,
|
|
336
|
+
totalChunks,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export class ResumeCaptureService {
|
|
341
|
+
constructor({ chromeClient, logger = console } = {}) {
|
|
342
|
+
this.chromeClient = chromeClient;
|
|
343
|
+
this.logger = logger;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async waitForProbe({ waitResumeMs = 30000, pollMs = 700 } = {}) {
|
|
347
|
+
const start = Date.now();
|
|
348
|
+
let lastProbe = null;
|
|
349
|
+
while (Date.now() - start < waitResumeMs) {
|
|
350
|
+
const probe = await this.chromeClient.callFunction(browserProbeResumeContext, {
|
|
351
|
+
init: true,
|
|
352
|
+
targetScroll: 0,
|
|
353
|
+
});
|
|
354
|
+
if (probe && typeof probe === 'object') {
|
|
355
|
+
lastProbe = probe;
|
|
356
|
+
}
|
|
357
|
+
if (probe?.ok && probe?.clip?.height > 80 && probe?.clip?.width > 120) {
|
|
358
|
+
return probe;
|
|
359
|
+
}
|
|
360
|
+
await sleep(pollMs);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const reason = lastProbe?.reason || 'UNKNOWN';
|
|
364
|
+
throw new Error(`Resume context probe timeout: reason=${reason}`);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async captureResume({ artifactDir, waitResumeMs = 30000, scrollSettleMs = 500 } = {}) {
|
|
368
|
+
if (!artifactDir) {
|
|
369
|
+
throw new Error('artifactDir is required for resume capture');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
await mkdir(artifactDir, { recursive: true });
|
|
373
|
+
const chunkDir = path.join(artifactDir, 'chunks');
|
|
374
|
+
await mkdir(chunkDir, { recursive: true });
|
|
375
|
+
const metadataFile = path.join(artifactDir, 'chunks.json');
|
|
376
|
+
|
|
377
|
+
const probe = await this.waitForProbe({ waitResumeMs });
|
|
378
|
+
const maxScroll = Math.max(0, Number(probe.maxScroll || 0));
|
|
379
|
+
const step = Math.max(120, Math.floor(Number(probe.clientHeight || probe.clip?.height || 800)));
|
|
380
|
+
const positions = [];
|
|
381
|
+
for (let pos = 0; pos <= maxScroll; pos += step) {
|
|
382
|
+
positions.push(Math.min(pos, maxScroll));
|
|
383
|
+
}
|
|
384
|
+
if (positions.length === 0 || positions[positions.length - 1] !== maxScroll) {
|
|
385
|
+
positions.push(maxScroll);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const uniquePositions = [...new Set(positions.map((value) => Math.round(value)))].sort((a, b) => a - b);
|
|
389
|
+
const chunks = [];
|
|
390
|
+
const seenScroll = [];
|
|
391
|
+
|
|
392
|
+
for (let index = 0; index < uniquePositions.length; index += 1) {
|
|
393
|
+
const targetScroll = uniquePositions[index];
|
|
394
|
+
await this.chromeClient.callFunction(browserProbeResumeContext, {
|
|
395
|
+
init: false,
|
|
396
|
+
targetScroll,
|
|
397
|
+
});
|
|
398
|
+
await sleep(scrollSettleMs);
|
|
399
|
+
|
|
400
|
+
const current = await this.chromeClient.callFunction(browserProbeResumeContext, {
|
|
401
|
+
init: false,
|
|
402
|
+
targetScroll: null,
|
|
403
|
+
});
|
|
404
|
+
if (!current?.ok) continue;
|
|
405
|
+
|
|
406
|
+
const actualScroll = Number(current.scrollTop || 0);
|
|
407
|
+
if (seenScroll.some((value) => Math.abs(value - actualScroll) < 1)) {
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const clip = current.clip || {};
|
|
412
|
+
const width = Number(clip.width || 0);
|
|
413
|
+
const height = Number(clip.height || 0);
|
|
414
|
+
if (width < 50 || height < 50) {
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const shot = await this.chromeClient.Page.captureScreenshot({
|
|
419
|
+
format: 'png',
|
|
420
|
+
captureBeyondViewport: true,
|
|
421
|
+
clip: {
|
|
422
|
+
x: Number(clip.x.toFixed(2)),
|
|
423
|
+
y: Number(clip.y.toFixed(2)),
|
|
424
|
+
width: Number(width.toFixed(2)),
|
|
425
|
+
height: Number(height.toFixed(2)),
|
|
426
|
+
scale: 1,
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
const file = path.resolve(chunkDir, `chunk_${String(chunks.length).padStart(3, '0')}.png`);
|
|
430
|
+
await writeFile(file, Buffer.from(shot.data, 'base64'));
|
|
431
|
+
seenScroll.push(actualScroll);
|
|
432
|
+
chunks.push({
|
|
433
|
+
index: chunks.length,
|
|
434
|
+
file,
|
|
435
|
+
scrollTop: actualScroll,
|
|
436
|
+
clipHeightCss: height,
|
|
437
|
+
clipWidthCss: width,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (chunks.length === 0) {
|
|
442
|
+
throw new Error('No screenshot chunks captured from resume modal');
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const metadata = {
|
|
446
|
+
createdAt: new Date().toISOString(),
|
|
447
|
+
probe,
|
|
448
|
+
chunks,
|
|
449
|
+
};
|
|
450
|
+
await writeFile(metadataFile, `${JSON.stringify(metadata, null, 2)}\n`, 'utf8');
|
|
451
|
+
const chunkFiles = chunks.map((chunk) => path.resolve(chunk.file));
|
|
452
|
+
const blank = await detectLikelyBlankChunks(chunkFiles);
|
|
453
|
+
this.logger.log(
|
|
454
|
+
`简历截图完成: chunks=${chunks.length}, modelImages=${chunkFiles.length}, likelyBlank=${blank.likelyBlank}, blankChunks=${blank.blankChunks}/${blank.totalChunks}, luma=${blank.luma}, std=${blank.avgStd}`,
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
return {
|
|
458
|
+
metadataFile,
|
|
459
|
+
chunkDir,
|
|
460
|
+
chunkCount: chunks.length,
|
|
461
|
+
chunkFiles,
|
|
462
|
+
modelImagePaths: chunkFiles,
|
|
463
|
+
stitchedImage: '',
|
|
464
|
+
stitchEngine: 'skipped',
|
|
465
|
+
stitched: null,
|
|
466
|
+
quality: blank,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
}
|