@reconcrap/boss-recommend-mcp 1.3.39 → 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.
- package/README.md +53 -33
- package/package.json +61 -9
- package/skills/boss-recommend-pipeline/SKILL.md +4 -0
- package/src/chat-mcp.js +1333 -0
- package/src/chat-runtime-config.js +559 -0
- package/src/cli.js +1095 -196
- package/src/core/browser/index.js +378 -0
- package/src/core/capture/index.js +298 -0
- package/src/core/cv-acquisition/index.js +219 -0
- package/src/core/greet-quota/index.js +54 -0
- package/src/core/infinite-list/index.js +459 -0
- package/src/core/reporting/legacy-csv.js +332 -0
- package/src/core/run/index.js +286 -0
- package/src/core/screening/index.js +1166 -0
- package/src/core/self-heal/index.js +848 -0
- package/src/domains/chat/cards.js +129 -0
- package/src/domains/chat/constants.js +183 -0
- package/src/domains/chat/detail.js +1369 -0
- package/src/domains/chat/index.js +7 -0
- package/src/domains/chat/jobs.js +334 -0
- package/src/domains/chat/page-guard.js +88 -0
- package/src/domains/chat/roots.js +56 -0
- package/src/domains/chat/run-service.js +1101 -0
- package/src/domains/recommend/actions.js +457 -0
- package/src/domains/recommend/cards.js +228 -0
- package/src/domains/recommend/constants.js +141 -0
- package/src/domains/recommend/detail.js +341 -0
- package/src/domains/recommend/filters.js +581 -0
- package/src/domains/recommend/index.js +10 -0
- package/src/domains/recommend/jobs.js +232 -0
- package/src/domains/recommend/refresh.js +204 -0
- package/src/domains/recommend/roots.js +78 -0
- package/src/domains/recommend/run-service.js +903 -0
- package/src/domains/recommend/scopes.js +245 -0
- package/src/domains/recruit/actions.js +277 -0
- package/src/domains/recruit/cards.js +67 -0
- package/src/domains/recruit/constants.js +130 -0
- package/src/domains/recruit/detail.js +414 -0
- package/src/domains/recruit/index.js +9 -0
- package/src/domains/recruit/instruction-parser.js +451 -0
- package/src/domains/recruit/refresh.js +40 -0
- package/src/domains/recruit/roots.js +68 -0
- package/src/domains/recruit/run-service.js +580 -0
- package/src/domains/recruit/search.js +1149 -0
- package/src/index.js +578 -419
- package/src/recommend-mcp.js +1257 -0
- package/src/recruit-mcp.js +1035 -0
- package/src/adapters.js +0 -3079
- package/src/boss-chat.js +0 -1037
- package/src/pipeline.js +0 -2249
- package/src/recommend-healing-config.js +0 -131
- package/src/recommend-healing-rules.json +0 -261
- package/src/self-heal.js +0 -2237
- package/src/test-adapters-runtime.js +0 -628
- package/src/test-boss-chat.js +0 -3196
- package/src/test-index-async.js +0 -498
- package/src/test-parser.js +0 -742
- package/src/test-pipeline.js +0 -2703
- package/src/test-run-state.js +0 -152
- package/src/test-self-heal.js +0 -224
- package/vendor/boss-chat-cli/README.md +0 -134
- package/vendor/boss-chat-cli/package.json +0 -53
- package/vendor/boss-chat-cli/src/app.js +0 -1501
- package/vendor/boss-chat-cli/src/browser/chat-page.js +0 -3562
- package/vendor/boss-chat-cli/src/cli.js +0 -1713
- package/vendor/boss-chat-cli/src/mcp/server.js +0 -149
- package/vendor/boss-chat-cli/src/mcp/tool-runtime.js +0 -193
- package/vendor/boss-chat-cli/src/runtime/async-run-state.js +0 -260
- package/vendor/boss-chat-cli/src/runtime/interaction.js +0 -102
- package/vendor/boss-chat-cli/src/runtime/run-control.js +0 -102
- package/vendor/boss-chat-cli/src/services/chrome-client.js +0 -107
- package/vendor/boss-chat-cli/src/services/llm.js +0 -1292
- package/vendor/boss-chat-cli/src/services/llm.test.js +0 -326
- package/vendor/boss-chat-cli/src/services/profile-store.js +0 -173
- package/vendor/boss-chat-cli/src/services/report-store.js +0 -317
- package/vendor/boss-chat-cli/src/services/resume-capture.js +0 -469
- package/vendor/boss-chat-cli/src/services/resume-network.js +0 -727
- package/vendor/boss-chat-cli/src/services/state-store.js +0 -90
- package/vendor/boss-chat-cli/src/utils/customer-key.js +0 -82
- package/vendor/boss-recommend-screen-cli/boss-recommend-screen-cli.cjs +0 -7072
- package/vendor/boss-recommend-screen-cli/scripts/capture-full-resume-canvas.cjs +0 -817
- package/vendor/boss-recommend-screen-cli/scripts/stitch_resume_chunks.py +0 -141
- package/vendor/boss-recommend-screen-cli/test-recoverable-resume-failures.cjs +0 -2423
- package/vendor/boss-recommend-search-cli/src/cli.js +0 -1698
- 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
|
+
}
|