@reconcrap/boss-recommend-mcp 1.3.39 → 2.0.1

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 (88) hide show
  1. package/README.md +86 -33
  2. package/package.json +62 -9
  3. package/skills/boss-chat/SKILL.md +5 -4
  4. package/skills/boss-recommend-pipeline/SKILL.md +21 -31
  5. package/skills/boss-recruit-pipeline/README.md +17 -0
  6. package/skills/boss-recruit-pipeline/SKILL.md +55 -0
  7. package/src/chat-mcp.js +1333 -0
  8. package/src/chat-runtime-config.js +559 -0
  9. package/src/cli.js +1254 -225
  10. package/src/core/browser/index.js +378 -0
  11. package/src/core/capture/index.js +298 -0
  12. package/src/core/cv-acquisition/index.js +219 -0
  13. package/src/core/greet-quota/index.js +54 -0
  14. package/src/core/infinite-list/index.js +459 -0
  15. package/src/core/reporting/legacy-csv.js +332 -0
  16. package/src/core/run/index.js +286 -0
  17. package/src/core/screening/index.js +1166 -0
  18. package/src/core/self-heal/index.js +848 -0
  19. package/src/domains/chat/cards.js +129 -0
  20. package/src/domains/chat/constants.js +183 -0
  21. package/src/domains/chat/detail.js +1369 -0
  22. package/src/domains/chat/index.js +7 -0
  23. package/src/domains/chat/jobs.js +334 -0
  24. package/src/domains/chat/page-guard.js +88 -0
  25. package/src/domains/chat/roots.js +56 -0
  26. package/src/domains/chat/run-service.js +1101 -0
  27. package/src/domains/recommend/actions.js +457 -0
  28. package/src/domains/recommend/cards.js +228 -0
  29. package/src/domains/recommend/constants.js +141 -0
  30. package/src/domains/recommend/detail.js +341 -0
  31. package/src/domains/recommend/filters.js +581 -0
  32. package/src/domains/recommend/index.js +10 -0
  33. package/src/domains/recommend/jobs.js +232 -0
  34. package/src/domains/recommend/refresh.js +204 -0
  35. package/src/domains/recommend/roots.js +78 -0
  36. package/src/domains/recommend/run-service.js +903 -0
  37. package/src/domains/recommend/scopes.js +245 -0
  38. package/src/domains/recruit/actions.js +277 -0
  39. package/src/domains/recruit/cards.js +66 -0
  40. package/src/domains/recruit/constants.js +130 -0
  41. package/src/domains/recruit/detail.js +414 -0
  42. package/src/domains/recruit/index.js +9 -0
  43. package/src/domains/recruit/instruction-parser.js +451 -0
  44. package/src/domains/recruit/refresh.js +40 -0
  45. package/src/domains/recruit/roots.js +67 -0
  46. package/src/domains/recruit/run-service.js +580 -0
  47. package/src/domains/recruit/search.js +1149 -0
  48. package/src/index.js +578 -419
  49. package/src/recommend-mcp.js +1257 -0
  50. package/src/recruit-mcp.js +1035 -0
  51. package/src/adapters.js +0 -3079
  52. package/src/boss-chat.js +0 -1037
  53. package/src/pipeline.js +0 -2249
  54. package/src/recommend-healing-config.js +0 -131
  55. package/src/recommend-healing-rules.json +0 -261
  56. package/src/self-heal.js +0 -2237
  57. package/src/test-adapters-runtime.js +0 -628
  58. package/src/test-boss-chat.js +0 -3196
  59. package/src/test-index-async.js +0 -498
  60. package/src/test-parser.js +0 -742
  61. package/src/test-pipeline.js +0 -2703
  62. package/src/test-run-state.js +0 -152
  63. package/src/test-self-heal.js +0 -224
  64. package/vendor/boss-chat-cli/README.md +0 -134
  65. package/vendor/boss-chat-cli/package.json +0 -53
  66. package/vendor/boss-chat-cli/src/app.js +0 -1501
  67. package/vendor/boss-chat-cli/src/browser/chat-page.js +0 -3562
  68. package/vendor/boss-chat-cli/src/cli.js +0 -1713
  69. package/vendor/boss-chat-cli/src/mcp/server.js +0 -149
  70. package/vendor/boss-chat-cli/src/mcp/tool-runtime.js +0 -193
  71. package/vendor/boss-chat-cli/src/runtime/async-run-state.js +0 -260
  72. package/vendor/boss-chat-cli/src/runtime/interaction.js +0 -102
  73. package/vendor/boss-chat-cli/src/runtime/run-control.js +0 -102
  74. package/vendor/boss-chat-cli/src/services/chrome-client.js +0 -107
  75. package/vendor/boss-chat-cli/src/services/llm.js +0 -1292
  76. package/vendor/boss-chat-cli/src/services/llm.test.js +0 -326
  77. package/vendor/boss-chat-cli/src/services/profile-store.js +0 -173
  78. package/vendor/boss-chat-cli/src/services/report-store.js +0 -317
  79. package/vendor/boss-chat-cli/src/services/resume-capture.js +0 -469
  80. package/vendor/boss-chat-cli/src/services/resume-network.js +0 -727
  81. package/vendor/boss-chat-cli/src/services/state-store.js +0 -90
  82. package/vendor/boss-chat-cli/src/utils/customer-key.js +0 -82
  83. package/vendor/boss-recommend-screen-cli/boss-recommend-screen-cli.cjs +0 -7072
  84. package/vendor/boss-recommend-screen-cli/scripts/capture-full-resume-canvas.cjs +0 -817
  85. package/vendor/boss-recommend-screen-cli/scripts/stitch_resume_chunks.py +0 -141
  86. package/vendor/boss-recommend-screen-cli/test-recoverable-resume-failures.cjs +0 -2423
  87. package/vendor/boss-recommend-search-cli/src/cli.js +0 -1698
  88. 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
+ }