@reconcrap/boss-recommend-mcp 1.3.38 → 2.0.0

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.
Files changed (85) hide show
  1. package/README.md +53 -33
  2. package/package.json +61 -9
  3. package/skills/boss-recommend-pipeline/SKILL.md +4 -0
  4. package/src/chat-mcp.js +1333 -0
  5. package/src/chat-runtime-config.js +559 -0
  6. package/src/cli.js +1095 -196
  7. package/src/core/browser/index.js +378 -0
  8. package/src/core/capture/index.js +298 -0
  9. package/src/core/cv-acquisition/index.js +219 -0
  10. package/src/core/greet-quota/index.js +54 -0
  11. package/src/core/infinite-list/index.js +459 -0
  12. package/src/core/reporting/legacy-csv.js +332 -0
  13. package/src/core/run/index.js +286 -0
  14. package/src/core/screening/index.js +1166 -0
  15. package/src/core/self-heal/index.js +848 -0
  16. package/src/domains/chat/cards.js +129 -0
  17. package/src/domains/chat/constants.js +183 -0
  18. package/src/domains/chat/detail.js +1369 -0
  19. package/src/domains/chat/index.js +7 -0
  20. package/src/domains/chat/jobs.js +334 -0
  21. package/src/domains/chat/page-guard.js +88 -0
  22. package/src/domains/chat/roots.js +56 -0
  23. package/src/domains/chat/run-service.js +1101 -0
  24. package/src/domains/recommend/actions.js +457 -0
  25. package/src/domains/recommend/cards.js +228 -0
  26. package/src/domains/recommend/constants.js +141 -0
  27. package/src/domains/recommend/detail.js +341 -0
  28. package/src/domains/recommend/filters.js +581 -0
  29. package/src/domains/recommend/index.js +10 -0
  30. package/src/domains/recommend/jobs.js +232 -0
  31. package/src/domains/recommend/refresh.js +204 -0
  32. package/src/domains/recommend/roots.js +78 -0
  33. package/src/domains/recommend/run-service.js +903 -0
  34. package/src/domains/recommend/scopes.js +245 -0
  35. package/src/domains/recruit/actions.js +277 -0
  36. package/src/domains/recruit/cards.js +67 -0
  37. package/src/domains/recruit/constants.js +130 -0
  38. package/src/domains/recruit/detail.js +414 -0
  39. package/src/domains/recruit/index.js +9 -0
  40. package/src/domains/recruit/instruction-parser.js +451 -0
  41. package/src/domains/recruit/refresh.js +40 -0
  42. package/src/domains/recruit/roots.js +68 -0
  43. package/src/domains/recruit/run-service.js +580 -0
  44. package/src/domains/recruit/search.js +1149 -0
  45. package/src/index.js +578 -419
  46. package/src/recommend-mcp.js +1257 -0
  47. package/src/recruit-mcp.js +1035 -0
  48. package/src/adapters.js +0 -3079
  49. package/src/boss-chat.js +0 -1037
  50. package/src/pipeline.js +0 -2249
  51. package/src/recommend-healing-config.js +0 -131
  52. package/src/recommend-healing-rules.json +0 -261
  53. package/src/self-heal.js +0 -2237
  54. package/src/test-adapters-runtime.js +0 -628
  55. package/src/test-boss-chat.js +0 -3196
  56. package/src/test-index-async.js +0 -498
  57. package/src/test-parser.js +0 -742
  58. package/src/test-pipeline.js +0 -2703
  59. package/src/test-run-state.js +0 -152
  60. package/src/test-self-heal.js +0 -224
  61. package/vendor/boss-chat-cli/README.md +0 -134
  62. package/vendor/boss-chat-cli/package.json +0 -53
  63. package/vendor/boss-chat-cli/src/app.js +0 -1501
  64. package/vendor/boss-chat-cli/src/browser/chat-page.js +0 -3562
  65. package/vendor/boss-chat-cli/src/cli.js +0 -1713
  66. package/vendor/boss-chat-cli/src/mcp/server.js +0 -149
  67. package/vendor/boss-chat-cli/src/mcp/tool-runtime.js +0 -193
  68. package/vendor/boss-chat-cli/src/runtime/async-run-state.js +0 -260
  69. package/vendor/boss-chat-cli/src/runtime/interaction.js +0 -102
  70. package/vendor/boss-chat-cli/src/runtime/run-control.js +0 -102
  71. package/vendor/boss-chat-cli/src/services/chrome-client.js +0 -107
  72. package/vendor/boss-chat-cli/src/services/llm.js +0 -1292
  73. package/vendor/boss-chat-cli/src/services/llm.test.js +0 -326
  74. package/vendor/boss-chat-cli/src/services/profile-store.js +0 -173
  75. package/vendor/boss-chat-cli/src/services/report-store.js +0 -317
  76. package/vendor/boss-chat-cli/src/services/resume-capture.js +0 -469
  77. package/vendor/boss-chat-cli/src/services/resume-network.js +0 -727
  78. package/vendor/boss-chat-cli/src/services/state-store.js +0 -90
  79. package/vendor/boss-chat-cli/src/utils/customer-key.js +0 -82
  80. package/vendor/boss-recommend-screen-cli/boss-recommend-screen-cli.cjs +0 -6927
  81. package/vendor/boss-recommend-screen-cli/scripts/capture-full-resume-canvas.cjs +0 -817
  82. package/vendor/boss-recommend-screen-cli/scripts/stitch_resume_chunks.py +0 -141
  83. package/vendor/boss-recommend-screen-cli/test-recoverable-resume-failures.cjs +0 -2294
  84. package/vendor/boss-recommend-search-cli/src/cli.js +0 -1698
  85. package/vendor/boss-recommend-search-cli/src/test-job-selection.js +0 -211
@@ -0,0 +1,378 @@
1
+ import CDP from "chrome-remote-interface";
2
+
3
+ export const DEFAULT_CHROME_HOST = "127.0.0.1";
4
+ export const DEFAULT_CHROME_PORT = 9222;
5
+
6
+ export const ALLOWED_CDP_DOMAINS = new Set([
7
+ "Accessibility",
8
+ "DOM",
9
+ "Input",
10
+ "Network",
11
+ "Page",
12
+ "Target"
13
+ ]);
14
+
15
+ export const FORBIDDEN_CDP_DOMAINS = new Set(["Runtime"]);
16
+
17
+ function nowIso() {
18
+ return new Date().toISOString();
19
+ }
20
+
21
+ function normalizeTargetMatcher({ targetUrlIncludes, targetPredicate } = {}) {
22
+ if (typeof targetPredicate === "function") return targetPredicate;
23
+ if (targetUrlIncludes) {
24
+ return (target) => String(target?.url || "").includes(targetUrlIncludes);
25
+ }
26
+ return (target) => target?.type === "page";
27
+ }
28
+
29
+ function isForbiddenMethod(methodName) {
30
+ const [domain] = String(methodName || "").split(".");
31
+ return FORBIDDEN_CDP_DOMAINS.has(domain);
32
+ }
33
+
34
+ function methodName(domain, method) {
35
+ return `${String(domain)}.${String(method)}`;
36
+ }
37
+
38
+ function recordMethod(methodLog, method) {
39
+ if (Array.isArray(methodLog)) {
40
+ methodLog.push({ method, at: nowIso() });
41
+ }
42
+ }
43
+
44
+ export function assertNoForbiddenCdpCalls(methodLog = []) {
45
+ const forbidden = methodLog.filter((entry) => isForbiddenMethod(entry?.method));
46
+ if (forbidden.length > 0) {
47
+ const methods = forbidden.map((entry) => entry.method).join(", ");
48
+ throw new Error(`Forbidden CDP methods were used: ${methods}`);
49
+ }
50
+ }
51
+
52
+ export function createGuardedCdpClient(client, { methodLog = [] } = {}) {
53
+ return new Proxy(client, {
54
+ get(target, property, receiver) {
55
+ if (property === "send") {
56
+ return async (method, params = {}) => {
57
+ if (isForbiddenMethod(method)) {
58
+ throw new Error(`Forbidden CDP method blocked: ${method}`);
59
+ }
60
+ recordMethod(methodLog, method);
61
+ return target.send(method, params);
62
+ };
63
+ }
64
+
65
+ const value = Reflect.get(target, property, receiver);
66
+ if (!value || typeof value !== "object") return value;
67
+
68
+ return new Proxy(value, {
69
+ get(domainTarget, method, domainReceiver) {
70
+ const domainValue = Reflect.get(domainTarget, method, domainReceiver);
71
+ if (typeof domainValue !== "function") return domainValue;
72
+
73
+ return async (params = {}) => {
74
+ const fullMethod = methodName(property, method);
75
+ if (isForbiddenMethod(fullMethod)) {
76
+ throw new Error(`Forbidden CDP method blocked: ${fullMethod}`);
77
+ }
78
+ recordMethod(methodLog, fullMethod);
79
+ return domainValue.call(domainTarget, params);
80
+ };
81
+ }
82
+ });
83
+ }
84
+ });
85
+ }
86
+
87
+ export async function listChromeTargets({
88
+ host = DEFAULT_CHROME_HOST,
89
+ port = DEFAULT_CHROME_PORT
90
+ } = {}) {
91
+ return CDP.List({ host, port });
92
+ }
93
+
94
+ export async function connectToChromeTarget({
95
+ host = DEFAULT_CHROME_HOST,
96
+ port = DEFAULT_CHROME_PORT,
97
+ targetUrlIncludes,
98
+ targetPredicate
99
+ } = {}) {
100
+ const targets = await listChromeTargets({ host, port });
101
+ const matcher = normalizeTargetMatcher({ targetUrlIncludes, targetPredicate });
102
+ const target = targets.find(matcher);
103
+ if (!target) {
104
+ const urls = targets.map((item) => item.url).filter(Boolean).join("\n");
105
+ throw new Error(`No matching Chrome target found on ${host}:${port}.\nAvailable targets:\n${urls}`);
106
+ }
107
+
108
+ const rawClient = await CDP({ host, port, target });
109
+ const methodLog = [];
110
+ const client = createGuardedCdpClient(rawClient, { methodLog });
111
+
112
+ return {
113
+ client,
114
+ rawClient,
115
+ target,
116
+ methodLog,
117
+ async close() {
118
+ await rawClient.close();
119
+ }
120
+ };
121
+ }
122
+
123
+ export async function assertRuntimeEvaluateBlocked(client) {
124
+ try {
125
+ await client.Runtime.evaluate({ expression: "1" });
126
+ } catch (error) {
127
+ if (/Forbidden CDP method blocked: Runtime\.evaluate/.test(String(error?.message || ""))) {
128
+ return { blocked: true, message: error.message };
129
+ }
130
+ throw error;
131
+ }
132
+ throw new Error("Runtime.evaluate was not blocked by the CDP guard");
133
+ }
134
+
135
+ export async function enableDomains(client, domains = ["Page", "DOM", "Input"]) {
136
+ for (const domain of domains) {
137
+ if (!ALLOWED_CDP_DOMAINS.has(domain)) {
138
+ throw new Error(`CDP domain is not allowed by the CDP-only contract: ${domain}`);
139
+ }
140
+ if (typeof client?.[domain]?.enable === "function") {
141
+ await client[domain].enable();
142
+ }
143
+ }
144
+ }
145
+
146
+ export async function bringPageToFront(client) {
147
+ if (typeof client?.Page?.bringToFront === "function") {
148
+ await client.Page.bringToFront();
149
+ }
150
+ }
151
+
152
+ export async function getPageFrameTree(client) {
153
+ const result = await client.Page.getFrameTree();
154
+ return result.frameTree || null;
155
+ }
156
+
157
+ export async function getMainFrame(client) {
158
+ const frameTree = await getPageFrameTree(client);
159
+ return frameTree?.frame || null;
160
+ }
161
+
162
+ export async function getMainFrameUrl(client) {
163
+ const frame = await getMainFrame(client);
164
+ return frame?.url || "";
165
+ }
166
+
167
+ export async function waitForMainFrameUrl(client, predicate, {
168
+ timeoutMs = 10000,
169
+ intervalMs = 250
170
+ } = {}) {
171
+ const started = Date.now();
172
+ let lastUrl = "";
173
+ while (Date.now() - started <= timeoutMs) {
174
+ lastUrl = await getMainFrameUrl(client);
175
+ if (predicate(lastUrl)) {
176
+ return {
177
+ ok: true,
178
+ elapsed_ms: Date.now() - started,
179
+ url: lastUrl
180
+ };
181
+ }
182
+ await sleep(intervalMs);
183
+ }
184
+ return {
185
+ ok: false,
186
+ elapsed_ms: Date.now() - started,
187
+ url: lastUrl
188
+ };
189
+ }
190
+
191
+ export async function getDocumentRoot(client, { depth = 1, pierce = true } = {}) {
192
+ const result = await client.DOM.getDocument({ depth, pierce });
193
+ return result.root;
194
+ }
195
+
196
+ export async function querySelector(client, nodeId, selector) {
197
+ const result = await client.DOM.querySelector({ nodeId, selector });
198
+ return result.nodeId || 0;
199
+ }
200
+
201
+ export async function querySelectorAll(client, nodeId, selector) {
202
+ const result = await client.DOM.querySelectorAll({ nodeId, selector });
203
+ return result.nodeIds || [];
204
+ }
205
+
206
+ export async function findFirstNode(client, rootNodeId, selectors = []) {
207
+ for (const selector of selectors) {
208
+ const nodeId = await querySelector(client, rootNodeId, selector);
209
+ if (nodeId) return { selector, nodeId };
210
+ }
211
+ return null;
212
+ }
213
+
214
+ export async function describeNode(client, nodeId, { depth = 1, pierce = true } = {}) {
215
+ const result = await client.DOM.describeNode({ nodeId, depth, pierce });
216
+ return result.node;
217
+ }
218
+
219
+ export async function getFrameDocumentNodeId(client, iframeNodeId) {
220
+ const node = await describeNode(client, iframeNodeId, { depth: 1, pierce: true });
221
+ const documentNodeId = node?.contentDocument?.nodeId;
222
+ if (!documentNodeId) {
223
+ throw new Error(`Node ${iframeNodeId} does not expose a contentDocument node`);
224
+ }
225
+ return documentNodeId;
226
+ }
227
+
228
+ export async function findIframeDocument(client, rootNodeId, selectors = []) {
229
+ const iframe = await findFirstNode(client, rootNodeId, selectors);
230
+ if (!iframe) return null;
231
+ const documentNodeId = await getFrameDocumentNodeId(client, iframe.nodeId);
232
+ return { ...iframe, documentNodeId };
233
+ }
234
+
235
+ export async function getAttributesMap(client, nodeId) {
236
+ const result = await client.DOM.getAttributes({ nodeId });
237
+ const attributes = {};
238
+ const raw = result.attributes || [];
239
+ for (let index = 0; index < raw.length; index += 2) {
240
+ attributes[raw[index]] = raw[index + 1] || "";
241
+ }
242
+ return attributes;
243
+ }
244
+
245
+ export async function getOuterHTML(client, nodeId) {
246
+ const result = await client.DOM.getOuterHTML({ nodeId });
247
+ return result.outerHTML || "";
248
+ }
249
+
250
+ export async function getNodeBox(client, nodeId) {
251
+ const result = await client.DOM.getBoxModel({ nodeId });
252
+ const model = result.model;
253
+ const quad = model.border?.length ? model.border : model.content;
254
+ const xs = [quad[0], quad[2], quad[4], quad[6]];
255
+ const ys = [quad[1], quad[3], quad[5], quad[7]];
256
+ const minX = Math.min(...xs);
257
+ const maxX = Math.max(...xs);
258
+ const minY = Math.min(...ys);
259
+ const maxY = Math.max(...ys);
260
+ return {
261
+ model,
262
+ center: {
263
+ x: (minX + maxX) / 2,
264
+ y: (minY + maxY) / 2
265
+ },
266
+ rect: {
267
+ x: minX,
268
+ y: minY,
269
+ width: maxX - minX,
270
+ height: maxY - minY
271
+ }
272
+ };
273
+ }
274
+
275
+ export async function clickPoint(client, x, y, {
276
+ button = "left",
277
+ clickCount = 1,
278
+ delayMs = 80
279
+ } = {}) {
280
+ await client.Input.dispatchMouseEvent({ type: "mouseMoved", x, y, button: "none" });
281
+ await client.Input.dispatchMouseEvent({ type: "mousePressed", x, y, button, clickCount });
282
+ if (delayMs > 0) await sleep(delayMs);
283
+ await client.Input.dispatchMouseEvent({ type: "mouseReleased", x, y, button, clickCount });
284
+ }
285
+
286
+ export async function scrollNodeIntoView(client, nodeId) {
287
+ await client.DOM.scrollIntoViewIfNeeded({ nodeId });
288
+ }
289
+
290
+ export async function clickNodeCenter(client, nodeId, {
291
+ scrollIntoView = false,
292
+ ...clickOptions
293
+ } = {}) {
294
+ if (scrollIntoView) {
295
+ await scrollNodeIntoView(client, nodeId);
296
+ await sleep(150);
297
+ }
298
+ const box = await getNodeBox(client, nodeId);
299
+ await clickPoint(client, box.center.x, box.center.y, clickOptions);
300
+ return box;
301
+ }
302
+
303
+ export async function pressKey(client, key, {
304
+ code = key,
305
+ windowsVirtualKeyCode,
306
+ nativeVirtualKeyCode = windowsVirtualKeyCode,
307
+ text = "",
308
+ modifiers = 0
309
+ } = {}) {
310
+ await client.Input.dispatchKeyEvent({
311
+ type: "keyDown",
312
+ key,
313
+ code,
314
+ windowsVirtualKeyCode,
315
+ nativeVirtualKeyCode,
316
+ text,
317
+ modifiers
318
+ });
319
+ await client.Input.dispatchKeyEvent({
320
+ type: "keyUp",
321
+ key,
322
+ code,
323
+ windowsVirtualKeyCode,
324
+ nativeVirtualKeyCode,
325
+ modifiers
326
+ });
327
+ }
328
+
329
+ export async function insertText(client, text) {
330
+ await client.Input.insertText({ text: String(text || "") });
331
+ }
332
+
333
+ export async function selectAllFocusedText(client) {
334
+ await pressKey(client, "a", {
335
+ code: "KeyA",
336
+ windowsVirtualKeyCode: 65,
337
+ nativeVirtualKeyCode: 65,
338
+ modifiers: 2
339
+ });
340
+ }
341
+
342
+ export async function clearFocusedInput(client) {
343
+ await selectAllFocusedText(client);
344
+ await pressKey(client, "Backspace", {
345
+ code: "Backspace",
346
+ windowsVirtualKeyCode: 8,
347
+ nativeVirtualKeyCode: 8
348
+ });
349
+ }
350
+
351
+ export async function waitForSelector(client, nodeId, selector, {
352
+ timeoutMs = 5000,
353
+ intervalMs = 150
354
+ } = {}) {
355
+ const started = Date.now();
356
+ while (Date.now() - started <= timeoutMs) {
357
+ const foundNodeId = await querySelector(client, nodeId, selector);
358
+ if (foundNodeId) return foundNodeId;
359
+ await sleep(intervalMs);
360
+ }
361
+ return 0;
362
+ }
363
+
364
+ export async function countSelectors(client, nodeId, selectors = {}) {
365
+ const counts = {};
366
+ for (const [name, selector] of Object.entries(selectors)) {
367
+ counts[name] = (await querySelectorAll(client, nodeId, selector)).length;
368
+ }
369
+ return counts;
370
+ }
371
+
372
+ export async function getAccessibilityTree(client, options = {}) {
373
+ return client.Accessibility.getFullAXTree(options);
374
+ }
375
+
376
+ export async function sleep(ms) {
377
+ await new Promise((resolve) => setTimeout(resolve, ms));
378
+ }
@@ -0,0 +1,298 @@
1
+ import fs from "node:fs";
2
+ import crypto from "node:crypto";
3
+ import path from "node:path";
4
+ import {
5
+ getAttributesMap,
6
+ getNodeBox,
7
+ getOuterHTML,
8
+ sleep
9
+ } from "../browser/index.js";
10
+ import {
11
+ htmlToText,
12
+ normalizeText
13
+ } from "../screening/index.js";
14
+
15
+ function nowIso() {
16
+ return new Date().toISOString();
17
+ }
18
+
19
+ function resolveOutputPath(filePath) {
20
+ if (!filePath) return null;
21
+ const resolved = path.resolve(filePath);
22
+ fs.mkdirSync(path.dirname(resolved), { recursive: true });
23
+ return resolved;
24
+ }
25
+
26
+ function withPadding(rect, padding = 0) {
27
+ const safePadding = Math.max(0, Number(padding) || 0);
28
+ const x = Math.max(0, rect.x - safePadding);
29
+ const y = Math.max(0, rect.y - safePadding);
30
+ return {
31
+ x,
32
+ y,
33
+ width: Math.max(1, rect.width + safePadding * 2 - (rect.x - x)),
34
+ height: Math.max(1, rect.height + safePadding * 2 - (rect.y - y)),
35
+ scale: 1
36
+ };
37
+ }
38
+
39
+ export async function captureNodeHtml(client, nodeId, {
40
+ domain = "unknown",
41
+ source = "dom",
42
+ metadata = {}
43
+ } = {}) {
44
+ const [attributes, outerHTML] = await Promise.all([
45
+ getAttributesMap(client, nodeId),
46
+ getOuterHTML(client, nodeId)
47
+ ]);
48
+ const text = htmlToText(outerHTML);
49
+ return {
50
+ schema_version: 1,
51
+ domain: normalizeText(domain) || "unknown",
52
+ source,
53
+ captured_at: nowIso(),
54
+ node_id: nodeId,
55
+ attributes,
56
+ outer_html_length: outerHTML.length,
57
+ text_length: text.length,
58
+ text,
59
+ outer_html: outerHTML,
60
+ metadata
61
+ };
62
+ }
63
+
64
+ export async function captureNodeScreenshot(client, nodeId, {
65
+ filePath,
66
+ format = "png",
67
+ quality,
68
+ padding = 0,
69
+ captureBeyondViewport = true,
70
+ fromSurface = true,
71
+ metadata = {}
72
+ } = {}) {
73
+ const box = await getNodeBox(client, nodeId);
74
+ const clip = withPadding(box.rect, padding);
75
+ const captureOptions = {
76
+ format,
77
+ fromSurface,
78
+ captureBeyondViewport,
79
+ clip
80
+ };
81
+ if (quality != null) {
82
+ captureOptions.quality = quality;
83
+ }
84
+ const screenshot = await client.Page.captureScreenshot(captureOptions);
85
+ const buffer = Buffer.from(screenshot.data || "", "base64");
86
+ const resolvedPath = resolveOutputPath(filePath);
87
+ if (resolvedPath) {
88
+ fs.writeFileSync(resolvedPath, buffer);
89
+ }
90
+ return {
91
+ schema_version: 1,
92
+ source: "image",
93
+ captured_at: nowIso(),
94
+ node_id: nodeId,
95
+ format,
96
+ mime_type: `image/${format === "jpeg" ? "jpeg" : "png"}`,
97
+ byte_length: buffer.length,
98
+ file_path: resolvedPath,
99
+ clip,
100
+ node_rect: box.rect,
101
+ metadata
102
+ };
103
+ }
104
+
105
+ export async function captureViewportScreenshot(client, {
106
+ filePath,
107
+ format = "png",
108
+ quality,
109
+ captureBeyondViewport = false,
110
+ fromSurface = true,
111
+ metadata = {}
112
+ } = {}) {
113
+ const captureOptions = {
114
+ format,
115
+ fromSurface,
116
+ captureBeyondViewport
117
+ };
118
+ if (quality != null) {
119
+ captureOptions.quality = quality;
120
+ }
121
+ const screenshot = await client.Page.captureScreenshot(captureOptions);
122
+ const buffer = Buffer.from(screenshot.data || "", "base64");
123
+ const resolvedPath = resolveOutputPath(filePath);
124
+ if (resolvedPath) {
125
+ fs.writeFileSync(resolvedPath, buffer);
126
+ }
127
+ return {
128
+ schema_version: 1,
129
+ source: "viewport-image",
130
+ captured_at: nowIso(),
131
+ format,
132
+ mime_type: `image/${format === "jpeg" ? "jpeg" : "png"}`,
133
+ byte_length: buffer.length,
134
+ file_path: resolvedPath,
135
+ capture_beyond_viewport: Boolean(captureBeyondViewport),
136
+ metadata
137
+ };
138
+ }
139
+
140
+ function filePathForSequence(basePath, index, extension) {
141
+ const resolved = resolveOutputPath(basePath);
142
+ if (!resolved) return null;
143
+ const parsed = path.parse(resolved);
144
+ const page = String(index + 1).padStart(2, "0");
145
+ return path.join(parsed.dir, `${parsed.name}-page-${page}${parsed.ext || `.${extension}`}`);
146
+ }
147
+
148
+ function screenshotHash(buffer) {
149
+ return crypto.createHash("sha256").update(buffer).digest("hex");
150
+ }
151
+
152
+ export async function captureScrolledNodeScreenshots(client, nodeId, {
153
+ filePath,
154
+ format = "png",
155
+ quality,
156
+ padding = 0,
157
+ captureBeyondViewport = true,
158
+ fromSurface = true,
159
+ maxScreenshots = 6,
160
+ wheelDeltaY = 650,
161
+ settleMs = 900,
162
+ duplicateStopCount = 2,
163
+ metadata = {}
164
+ } = {}) {
165
+ if (!nodeId) throw new Error("captureScrolledNodeScreenshots requires nodeId");
166
+ const screenshots = [];
167
+ let consecutiveDuplicates = 0;
168
+ let previousHash = "";
169
+
170
+ for (let index = 0; index < Math.max(1, Number(maxScreenshots) || 1); index += 1) {
171
+ const box = await getNodeBox(client, nodeId);
172
+ const clip = withPadding(box.rect, padding);
173
+ const captureOptions = {
174
+ format,
175
+ fromSurface,
176
+ captureBeyondViewport,
177
+ clip
178
+ };
179
+ if (quality != null) {
180
+ captureOptions.quality = quality;
181
+ }
182
+ const screenshot = await client.Page.captureScreenshot(captureOptions);
183
+ const buffer = Buffer.from(screenshot.data || "", "base64");
184
+ const hash = screenshotHash(buffer);
185
+ const duplicateOfPrevious = previousHash && previousHash === hash;
186
+ if (duplicateOfPrevious) {
187
+ consecutiveDuplicates += 1;
188
+ } else {
189
+ consecutiveDuplicates = 0;
190
+ }
191
+
192
+ const outputPath = filePath ? filePathForSequence(filePath, index, format) : null;
193
+ if (outputPath) {
194
+ fs.writeFileSync(outputPath, buffer);
195
+ }
196
+
197
+ screenshots.push({
198
+ index,
199
+ source: "image",
200
+ captured_at: nowIso(),
201
+ node_id: nodeId,
202
+ format,
203
+ mime_type: `image/${format === "jpeg" ? "jpeg" : "png"}`,
204
+ byte_length: buffer.length,
205
+ file_path: outputPath,
206
+ sha256: hash,
207
+ duplicate_of_previous: Boolean(duplicateOfPrevious),
208
+ clip,
209
+ node_rect: box.rect,
210
+ scroll: index === 0
211
+ ? { before_capture: "initial" }
212
+ : { before_capture: `wheel_down_${index}` },
213
+ metadata
214
+ });
215
+
216
+ previousHash = hash;
217
+ if (consecutiveDuplicates >= Math.max(1, Number(duplicateStopCount) || 1)) {
218
+ break;
219
+ }
220
+
221
+ if (index < Math.max(1, Number(maxScreenshots) || 1) - 1) {
222
+ const x = box.center.x;
223
+ const y = box.center.y;
224
+ await client.Input.dispatchMouseEvent({ type: "mouseMoved", x, y, button: "none" });
225
+ await client.Input.dispatchMouseEvent({
226
+ type: "mouseWheel",
227
+ x,
228
+ y,
229
+ deltaX: 0,
230
+ deltaY: Math.max(1, Number(wheelDeltaY) || 650)
231
+ });
232
+ if (settleMs > 0) await sleep(settleMs);
233
+ }
234
+ }
235
+
236
+ return {
237
+ schema_version: 1,
238
+ source: "image-scroll-sequence",
239
+ captured_at: nowIso(),
240
+ node_id: nodeId,
241
+ screenshot_count: screenshots.length,
242
+ unique_screenshot_count: new Set(screenshots.map((item) => item.sha256)).size,
243
+ file_paths: screenshots.map((item) => item.file_path).filter(Boolean),
244
+ screenshots,
245
+ metadata
246
+ };
247
+ }
248
+
249
+ export async function captureCandidateEvidence(client, {
250
+ nodeId,
251
+ domain = "unknown",
252
+ source = "dom",
253
+ screenshotPath,
254
+ includeHtml = true,
255
+ includeScreenshot = false,
256
+ screenshotMode = "scroll",
257
+ screenshotOptions = {},
258
+ metadata = {}
259
+ } = {}) {
260
+ if (!nodeId) throw new Error("captureCandidateEvidence requires nodeId");
261
+ const evidence = {
262
+ schema_version: 1,
263
+ domain: normalizeText(domain) || "unknown",
264
+ source,
265
+ captured_at: nowIso(),
266
+ node_id: nodeId,
267
+ html: null,
268
+ image: null,
269
+ metadata
270
+ };
271
+ if (includeHtml) {
272
+ evidence.html = await captureNodeHtml(client, nodeId, {
273
+ domain,
274
+ source: "dom",
275
+ metadata
276
+ });
277
+ }
278
+ if (includeScreenshot) {
279
+ evidence.image = screenshotMode === "single"
280
+ ? await captureNodeScreenshot(client, nodeId, {
281
+ ...screenshotOptions,
282
+ filePath: screenshotPath,
283
+ metadata: {
284
+ ...metadata,
285
+ capture_mode: "single_visible_clip"
286
+ }
287
+ })
288
+ : await captureScrolledNodeScreenshots(client, nodeId, {
289
+ ...screenshotOptions,
290
+ filePath: screenshotPath,
291
+ metadata: {
292
+ ...metadata,
293
+ capture_mode: "scroll_sequence"
294
+ }
295
+ });
296
+ }
297
+ return evidence;
298
+ }