mcp-scraper 0.2.6 → 0.2.7
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 +7 -2
- package/dist/bin/api-server.cjs +219 -10
- package/dist/bin/api-server.cjs.map +1 -1
- package/dist/bin/api-server.js +1 -1
- package/dist/bin/browser-agent-stdio-server.cjs +442 -9
- package/dist/bin/browser-agent-stdio-server.cjs.map +1 -1
- package/dist/bin/browser-agent-stdio-server.js +2 -2
- package/dist/bin/mcp-scraper-combined-stdio-server.cjs +452 -19
- package/dist/bin/mcp-scraper-combined-stdio-server.cjs.map +1 -1
- package/dist/bin/mcp-scraper-combined-stdio-server.js +3 -3
- package/dist/bin/mcp-scraper-install.cjs +1 -1
- package/dist/bin/mcp-scraper-install.cjs.map +1 -1
- package/dist/bin/mcp-scraper-install.js +1 -1
- package/dist/bin/mcp-stdio-server.cjs +1 -1
- package/dist/bin/mcp-stdio-server.cjs.map +1 -1
- package/dist/bin/mcp-stdio-server.js +2 -2
- package/dist/chunk-3TF6UT2P.js +793 -0
- package/dist/chunk-3TF6UT2P.js.map +1 -0
- package/dist/{chunk-7WB2W6FO.js → chunk-7SI6XIR3.js} +2 -2
- package/dist/chunk-IZE5UW7Y.js +7 -0
- package/dist/chunk-IZE5UW7Y.js.map +1 -0
- package/dist/{server-MKCU3M7Y.js → server-RIKRBDOI.js} +221 -12
- package/dist/{server-MKCU3M7Y.js.map → server-RIKRBDOI.js.map} +1 -1
- package/package.json +1 -1
- package/dist/chunk-T6SIPPOQ.js +0 -7
- package/dist/chunk-T6SIPPOQ.js.map +0 -1
- package/dist/chunk-XL4V7VKD.js +0 -360
- package/dist/chunk-XL4V7VKD.js.map +0 -1
- /package/dist/{chunk-7WB2W6FO.js.map → chunk-7SI6XIR3.js.map} +0 -0
package/dist/bin/api-server.js
CHANGED
|
@@ -17,7 +17,7 @@ loadDotEnv();
|
|
|
17
17
|
async function main() {
|
|
18
18
|
const [{ serve }, { app }, { startWorker }, { migrate }] = await Promise.all([
|
|
19
19
|
import("@hono/node-server"),
|
|
20
|
-
import("../server-
|
|
20
|
+
import("../server-RIKRBDOI.js"),
|
|
21
21
|
import("../worker-NAKGTIF5.js"),
|
|
22
22
|
import("../db-YWCNHBLH.js")
|
|
23
23
|
]);
|
|
@@ -3,18 +3,18 @@
|
|
|
3
3
|
|
|
4
4
|
// bin/browser-agent-stdio-server.ts
|
|
5
5
|
var import_node_fs2 = require("fs");
|
|
6
|
-
var
|
|
7
|
-
var
|
|
6
|
+
var import_node_os3 = require("os");
|
|
7
|
+
var import_node_path3 = require("path");
|
|
8
8
|
var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
9
9
|
|
|
10
10
|
// src/mcp/browser-agent-mcp-server.ts
|
|
11
11
|
var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
12
12
|
var import_node_fs = require("fs");
|
|
13
|
-
var
|
|
14
|
-
var
|
|
13
|
+
var import_node_os2 = require("os");
|
|
14
|
+
var import_node_path2 = require("path");
|
|
15
15
|
|
|
16
16
|
// src/version.ts
|
|
17
|
-
var PACKAGE_VERSION = "0.2.
|
|
17
|
+
var PACKAGE_VERSION = "0.2.7";
|
|
18
18
|
|
|
19
19
|
// src/mcp/browser-agent-tool-schemas.ts
|
|
20
20
|
var import_zod = require("zod");
|
|
@@ -27,6 +27,19 @@ var BrowserOpenInputSchema = {
|
|
|
27
27
|
var BrowserSessionInputSchema = {
|
|
28
28
|
session_id: import_zod.z.string().describe("The session id returned by browser_open.")
|
|
29
29
|
};
|
|
30
|
+
var BrowserLocateTargetSchema = import_zod.z.object({
|
|
31
|
+
name: import_zod.z.string().optional().describe("Optional label for this target, echoed in the result."),
|
|
32
|
+
selector: import_zod.z.string().optional().describe('CSS selector for the exact DOM element to locate, for example h1, input[name="q"], or [data-testid="result"].'),
|
|
33
|
+
text: import_zod.z.string().optional().describe("Visible text to locate when a selector is not known. The tool returns the text range bounds when possible."),
|
|
34
|
+
match: import_zod.z.enum(["contains", "exact"]).default("contains").describe("How to match text targets. Defaults to contains."),
|
|
35
|
+
index: import_zod.z.number().int().min(0).default(0).describe("Zero-based match index when multiple elements match.")
|
|
36
|
+
}).refine((value) => Boolean(value.selector || value.text), {
|
|
37
|
+
message: "target requires selector or text"
|
|
38
|
+
});
|
|
39
|
+
var BrowserLocateInputSchema = {
|
|
40
|
+
session_id: import_zod.z.string().describe("The session id returned by browser_open."),
|
|
41
|
+
targets: import_zod.z.array(BrowserLocateTargetSchema).min(1).max(20).describe("DOM targets to locate in the current viewport. Use selectors for exact elements, or text for visible text ranges.")
|
|
42
|
+
};
|
|
30
43
|
var BrowserGotoInputSchema = {
|
|
31
44
|
session_id: import_zod.z.string().describe("The session id returned by browser_open."),
|
|
32
45
|
url: import_zod.z.string().url().describe("URL to navigate the browser to.")
|
|
@@ -63,16 +76,315 @@ var BrowserReplayDownloadInputSchema = {
|
|
|
63
76
|
replay_id: import_zod.z.string().describe("The replay id returned by browser_replay_start or browser_list_replays."),
|
|
64
77
|
filename: import_zod.z.string().optional().describe("Optional local MP4 filename. Defaults to a timestamped replay filename.")
|
|
65
78
|
};
|
|
79
|
+
var BrowserReplayAnnotationSchema = import_zod.z.object({
|
|
80
|
+
type: import_zod.z.enum(["box", "circle", "underline", "arrow", "label"]).default("box").describe("Annotation style to draw over the replay."),
|
|
81
|
+
start_seconds: import_zod.z.number().min(0).default(0).describe("When the annotation should appear in the replay."),
|
|
82
|
+
end_seconds: import_zod.z.number().min(0).optional().describe("When the annotation should disappear. Defaults to two seconds after start_seconds."),
|
|
83
|
+
left: import_zod.z.number().optional().describe("Target left edge in browser screenshot pixels. Use element.left from browser_screenshot/browser_read."),
|
|
84
|
+
top: import_zod.z.number().optional().describe("Target top edge in browser screenshot pixels. Use element.top from browser_screenshot/browser_read."),
|
|
85
|
+
width: import_zod.z.number().positive().optional().describe("Target width in browser screenshot pixels. Use element.width from browser_screenshot/browser_read."),
|
|
86
|
+
height: import_zod.z.number().positive().optional().describe("Target height in browser screenshot pixels. Use element.height from browser_screenshot/browser_read."),
|
|
87
|
+
x: import_zod.z.number().optional().describe("Point target x coordinate in browser screenshot pixels when no box is available."),
|
|
88
|
+
y: import_zod.z.number().optional().describe("Point target y coordinate in browser screenshot pixels when no box is available."),
|
|
89
|
+
from_x: import_zod.z.number().optional().describe("Arrow start x coordinate in browser screenshot pixels. Defaults near the target."),
|
|
90
|
+
from_y: import_zod.z.number().optional().describe("Arrow start y coordinate in browser screenshot pixels. Defaults near the target."),
|
|
91
|
+
to_x: import_zod.z.number().optional().describe("Arrow target x coordinate in browser screenshot pixels. Defaults to the target box center."),
|
|
92
|
+
to_y: import_zod.z.number().optional().describe("Arrow target y coordinate in browser screenshot pixels. Defaults to the target box center."),
|
|
93
|
+
label: import_zod.z.string().max(120).optional().describe("Optional text callout to render near the annotation."),
|
|
94
|
+
color: import_zod.z.string().regex(/^#?[0-9a-fA-F]{6}$/).optional().describe("Annotation color as hex, for example #ff3b30."),
|
|
95
|
+
thickness: import_zod.z.number().min(1).max(24).optional().describe("Stroke thickness in pixels. Defaults to 5.")
|
|
96
|
+
});
|
|
97
|
+
var BrowserReplayMarkInputSchema = {
|
|
98
|
+
session_id: import_zod.z.string().describe("The session id returned by browser_open. A replay must already be recording."),
|
|
99
|
+
target: BrowserLocateTargetSchema.describe("The exact DOM element or text range to mark in the current viewport."),
|
|
100
|
+
type: import_zod.z.enum(["box", "circle", "underline", "arrow"]).default("box").describe("Annotation style to generate. Labels are included on the returned annotation when label is provided."),
|
|
101
|
+
label: import_zod.z.string().max(120).optional().describe("Optional callout text to render near the target."),
|
|
102
|
+
color: import_zod.z.string().regex(/^#?[0-9a-fA-F]{6}$/).optional().describe("Annotation color as hex, for example #ff3b30."),
|
|
103
|
+
thickness: import_zod.z.number().min(1).max(24).optional().describe("Stroke thickness in pixels. Defaults to 5."),
|
|
104
|
+
padding: import_zod.z.number().min(0).max(80).default(8).describe("Pixels to expand the DOM bounds so the highlight does not touch the text edge."),
|
|
105
|
+
start_offset_seconds: import_zod.z.number().min(-5).max(10).default(-0.25).describe("Offset from the current replay time. Negative values make the callout appear just before the mark action is captured."),
|
|
106
|
+
duration_seconds: import_zod.z.number().min(0.5).max(30).default(4).describe("How long the generated annotation should remain visible.")
|
|
107
|
+
};
|
|
108
|
+
var BrowserReplayAnnotateInputSchema = {
|
|
109
|
+
session_id: import_zod.z.string().describe("The session id returned by browser_open."),
|
|
110
|
+
replay_id: import_zod.z.string().describe("The replay id returned by browser_replay_start or browser_list_replays."),
|
|
111
|
+
annotations: import_zod.z.array(BrowserReplayAnnotationSchema).min(1).max(50).describe("Timed overlay annotations to render on the replay. Prefer annotations returned by browser_replay_mark; otherwise use exact DOM bounds from browser_locate/browser_screenshot/browser_read."),
|
|
112
|
+
filename: import_zod.z.string().optional().describe("Optional output MP4 filename. Defaults to a timestamped annotated replay filename."),
|
|
113
|
+
source_width: import_zod.z.number().positive().optional().describe("Width of the screenshot coordinate space used for annotations. Defaults to the replay video width."),
|
|
114
|
+
source_height: import_zod.z.number().positive().optional().describe("Height of the coordinate space used for annotations. When this is a page viewport height smaller than the replay video height, the annotator infers the browser chrome top offset."),
|
|
115
|
+
source_left_offset: import_zod.z.number().min(0).optional().describe("Optional explicit X offset from annotation coordinates to replay video coordinates. Usually omitted."),
|
|
116
|
+
source_top_offset: import_zod.z.number().min(0).optional().describe("Optional explicit Y offset from annotation coordinates to replay video coordinates. Usually omitted because browser chrome offset is inferred when source_height is a page viewport height.")
|
|
117
|
+
};
|
|
66
118
|
var BrowserListInputSchema = {
|
|
67
119
|
include_closed: import_zod.z.boolean().default(false).describe("Include closed sessions in the list.")
|
|
68
120
|
};
|
|
69
121
|
|
|
122
|
+
// src/mcp/replay-annotator.ts
|
|
123
|
+
var import_node_child_process = require("child_process");
|
|
124
|
+
var import_promises = require("fs/promises");
|
|
125
|
+
var import_node_os = require("os");
|
|
126
|
+
var import_node_path = require("path");
|
|
127
|
+
var import_node_util = require("util");
|
|
128
|
+
var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
|
|
129
|
+
function finiteNumber(value) {
|
|
130
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
131
|
+
}
|
|
132
|
+
function clamp(value, min, max) {
|
|
133
|
+
return Math.min(max, Math.max(min, value));
|
|
134
|
+
}
|
|
135
|
+
function formatAssTime(seconds) {
|
|
136
|
+
const safe = Math.max(0, seconds);
|
|
137
|
+
const hours = Math.floor(safe / 3600);
|
|
138
|
+
const minutes = Math.floor(safe % 3600 / 60);
|
|
139
|
+
const wholeSeconds = Math.floor(safe % 60);
|
|
140
|
+
const centiseconds = Math.floor((safe - Math.floor(safe)) * 100);
|
|
141
|
+
return `${hours}:${String(minutes).padStart(2, "0")}:${String(wholeSeconds).padStart(2, "0")}.${String(centiseconds).padStart(2, "0")}`;
|
|
142
|
+
}
|
|
143
|
+
function cssHexToAssColor(color) {
|
|
144
|
+
const normalized = color?.trim().match(/^#?([0-9a-fA-F]{6})$/)?.[1] ?? "ff3b30";
|
|
145
|
+
const red = normalized.slice(0, 2);
|
|
146
|
+
const green = normalized.slice(2, 4);
|
|
147
|
+
const blue = normalized.slice(4, 6);
|
|
148
|
+
return `&H${blue}${green}${red}&`;
|
|
149
|
+
}
|
|
150
|
+
function escapeAssText(value) {
|
|
151
|
+
return value.replace(/\\/g, "\\\\").replace(/\{/g, "\\{").replace(/\}/g, "\\}").replace(/\r?\n/g, "\\N");
|
|
152
|
+
}
|
|
153
|
+
function resolveCoordinateTransform(video, source, options) {
|
|
154
|
+
const explicitOffsetX = finiteNumber(options.sourceLeftOffset) ? options.sourceLeftOffset : null;
|
|
155
|
+
const explicitOffsetY = finiteNumber(options.sourceTopOffset) ? options.sourceTopOffset : null;
|
|
156
|
+
const inferredTopOffset = explicitOffsetY == null && Math.round(source.width) === Math.round(video.width) && source.height < video.height && video.height - source.height <= 220 ? video.height - source.height : 0;
|
|
157
|
+
const offsetX = explicitOffsetX ?? 0;
|
|
158
|
+
const offsetY = explicitOffsetY ?? inferredTopOffset;
|
|
159
|
+
return {
|
|
160
|
+
scaleX: (video.width - offsetX) / source.width,
|
|
161
|
+
scaleY: (video.height - offsetY) / source.height,
|
|
162
|
+
offsetX,
|
|
163
|
+
offsetY
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function transformPoint(x, y, transform) {
|
|
167
|
+
return {
|
|
168
|
+
x: x * transform.scaleX + transform.offsetX,
|
|
169
|
+
y: y * transform.scaleY + transform.offsetY
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function scaleRect(annotation, transform) {
|
|
173
|
+
if (finiteNumber(annotation.left) && finiteNumber(annotation.top) && finiteNumber(annotation.width) && finiteNumber(annotation.height)) {
|
|
174
|
+
const origin = transformPoint(annotation.left, annotation.top, transform);
|
|
175
|
+
return {
|
|
176
|
+
left: origin.x,
|
|
177
|
+
top: origin.y,
|
|
178
|
+
width: annotation.width * transform.scaleX,
|
|
179
|
+
height: annotation.height * transform.scaleY
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
if (finiteNumber(annotation.x) && finiteNumber(annotation.y)) {
|
|
183
|
+
const radius = finiteNumber(annotation.width) ? annotation.width / 2 : 28;
|
|
184
|
+
const point = transformPoint(annotation.x, annotation.y, transform);
|
|
185
|
+
return {
|
|
186
|
+
left: point.x - radius * transform.scaleX,
|
|
187
|
+
top: point.y - radius * transform.scaleY,
|
|
188
|
+
width: radius * 2 * transform.scaleX,
|
|
189
|
+
height: radius * 2 * transform.scaleY
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
throw new Error("annotation needs either left/top/width/height or x/y");
|
|
193
|
+
}
|
|
194
|
+
function rectPath(rect) {
|
|
195
|
+
const x1 = Math.round(rect.left);
|
|
196
|
+
const y1 = Math.round(rect.top);
|
|
197
|
+
const x2 = Math.round(rect.left + rect.width);
|
|
198
|
+
const y2 = Math.round(rect.top + rect.height);
|
|
199
|
+
return `m ${x1} ${y1} l ${x2} ${y1} l ${x2} ${y2} l ${x1} ${y2} l ${x1} ${y1}`;
|
|
200
|
+
}
|
|
201
|
+
function ellipsePath(rect) {
|
|
202
|
+
const cx = rect.left + rect.width / 2;
|
|
203
|
+
const cy = rect.top + rect.height / 2;
|
|
204
|
+
const rx = Math.max(4, rect.width / 2);
|
|
205
|
+
const ry = Math.max(4, rect.height / 2);
|
|
206
|
+
const points = [];
|
|
207
|
+
for (let i = 0; i <= 32; i += 1) {
|
|
208
|
+
const t = Math.PI * 2 * i / 32;
|
|
209
|
+
const x = Math.round(cx + Math.cos(t) * rx);
|
|
210
|
+
const y = Math.round(cy + Math.sin(t) * ry);
|
|
211
|
+
points.push(`${i === 0 ? "m" : "l"} ${x} ${y}`);
|
|
212
|
+
}
|
|
213
|
+
return points.join(" ");
|
|
214
|
+
}
|
|
215
|
+
function filledRectPath(left, top, width, height) {
|
|
216
|
+
return rectPath({ left, top, width, height });
|
|
217
|
+
}
|
|
218
|
+
function arrowPath(fromX, fromY, toX, toY, thickness) {
|
|
219
|
+
const dx = toX - fromX;
|
|
220
|
+
const dy = toY - fromY;
|
|
221
|
+
const length = Math.max(1, Math.hypot(dx, dy));
|
|
222
|
+
const ux = dx / length;
|
|
223
|
+
const uy = dy / length;
|
|
224
|
+
const px = -uy;
|
|
225
|
+
const py = ux;
|
|
226
|
+
const half = thickness / 2;
|
|
227
|
+
const head = Math.max(14, thickness * 4);
|
|
228
|
+
const baseX = toX - ux * head;
|
|
229
|
+
const baseY = toY - uy * head;
|
|
230
|
+
const p1x = Math.round(fromX + px * half);
|
|
231
|
+
const p1y = Math.round(fromY + py * half);
|
|
232
|
+
const p2x = Math.round(baseX + px * half);
|
|
233
|
+
const p2y = Math.round(baseY + py * half);
|
|
234
|
+
const p3x = Math.round(baseX + px * head * 0.55);
|
|
235
|
+
const p3y = Math.round(baseY + py * head * 0.55);
|
|
236
|
+
const p4x = Math.round(toX);
|
|
237
|
+
const p4y = Math.round(toY);
|
|
238
|
+
const p5x = Math.round(baseX - px * head * 0.55);
|
|
239
|
+
const p5y = Math.round(baseY - py * head * 0.55);
|
|
240
|
+
const p6x = Math.round(baseX - px * half);
|
|
241
|
+
const p6y = Math.round(baseY - py * half);
|
|
242
|
+
const p7x = Math.round(fromX - px * half);
|
|
243
|
+
const p7y = Math.round(fromY - py * half);
|
|
244
|
+
return `m ${p1x} ${p1y} l ${p2x} ${p2y} l ${p3x} ${p3y} l ${p4x} ${p4y} l ${p5x} ${p5y} l ${p6x} ${p6y} l ${p7x} ${p7y} l ${p1x} ${p1y}`;
|
|
245
|
+
}
|
|
246
|
+
function eventLine(start, end, body) {
|
|
247
|
+
return `Dialogue: 0,${formatAssTime(start)},${formatAssTime(end)},Default,,0,0,0,,${body}`;
|
|
248
|
+
}
|
|
249
|
+
function labelLine(start, end, x, y, label) {
|
|
250
|
+
const safeX = Math.round(x);
|
|
251
|
+
const safeY = Math.round(y);
|
|
252
|
+
return eventLine(
|
|
253
|
+
start,
|
|
254
|
+
end,
|
|
255
|
+
`{\\an7\\pos(${safeX},${safeY})\\fs30\\bord4\\shad0\\c&HFFFFFF&\\3c&H000000&}${escapeAssText(label)}`
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
function shapeLine(start, end, path, color, thickness, filled = false) {
|
|
259
|
+
const fillAlpha = filled ? "&H00&" : "&HFF&";
|
|
260
|
+
const border = filled ? 0 : thickness;
|
|
261
|
+
return eventLine(
|
|
262
|
+
start,
|
|
263
|
+
end,
|
|
264
|
+
`{\\an7\\pos(0,0)\\p1\\bord${border}\\shad0\\1a${fillAlpha}\\1c${color}\\3c${color}}${path}`
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
function buildAssSubtitle(options, video) {
|
|
268
|
+
const source = {
|
|
269
|
+
width: options.sourceWidth && options.sourceWidth > 0 ? options.sourceWidth : video.width,
|
|
270
|
+
height: options.sourceHeight && options.sourceHeight > 0 ? options.sourceHeight : video.height
|
|
271
|
+
};
|
|
272
|
+
const transform = resolveCoordinateTransform(video, source, options);
|
|
273
|
+
const lines = [];
|
|
274
|
+
for (const annotation of options.annotations) {
|
|
275
|
+
const start = Math.max(0, annotation.start_seconds ?? 0);
|
|
276
|
+
const end = annotation.end_seconds && annotation.end_seconds > start ? annotation.end_seconds : start + 2;
|
|
277
|
+
const type = annotation.type ?? "box";
|
|
278
|
+
const color = cssHexToAssColor(annotation.color);
|
|
279
|
+
const thickness = Math.round(clamp(annotation.thickness ?? 5, 2, 24));
|
|
280
|
+
if (type === "label") {
|
|
281
|
+
if (!annotation.label) throw new Error("label annotation needs label");
|
|
282
|
+
const rect2 = scaleRect(annotation, transform);
|
|
283
|
+
lines.push(labelLine(start, end, rect2.left, Math.max(8, rect2.top), annotation.label));
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (type === "arrow") {
|
|
287
|
+
const rect2 = finiteNumber(annotation.to_x) && finiteNumber(annotation.to_y) ? null : scaleRect(annotation, transform);
|
|
288
|
+
const toPoint = finiteNumber(annotation.to_x) && finiteNumber(annotation.to_y) ? transformPoint(annotation.to_x, annotation.to_y, transform) : null;
|
|
289
|
+
const toX = toPoint ? toPoint.x : rect2.left + rect2.width / 2;
|
|
290
|
+
const toY = toPoint ? toPoint.y : rect2.top + rect2.height / 2;
|
|
291
|
+
const fromPoint = finiteNumber(annotation.from_x) && finiteNumber(annotation.from_y) ? transformPoint(annotation.from_x, annotation.from_y, transform) : null;
|
|
292
|
+
const fromX = fromPoint ? fromPoint.x : clamp(toX - 120, 12, video.width - 12);
|
|
293
|
+
const fromY = fromPoint ? fromPoint.y : clamp(toY - 90, 12, video.height - 12);
|
|
294
|
+
lines.push(shapeLine(start, end, arrowPath(fromX, fromY, toX, toY, thickness), color, thickness, true));
|
|
295
|
+
if (annotation.label) lines.push(labelLine(start, end, fromX + 8, Math.max(96, fromY - 36), annotation.label));
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const rect = scaleRect(annotation, transform);
|
|
299
|
+
if (type === "underline") {
|
|
300
|
+
const underlineTop = rect.top + rect.height + thickness;
|
|
301
|
+
lines.push(shapeLine(start, end, filledRectPath(rect.left, underlineTop, rect.width, thickness), color, 0, true));
|
|
302
|
+
} else if (type === "circle") {
|
|
303
|
+
lines.push(shapeLine(start, end, ellipsePath(rect), color, thickness));
|
|
304
|
+
} else {
|
|
305
|
+
lines.push(shapeLine(start, end, rectPath(rect), color, thickness));
|
|
306
|
+
}
|
|
307
|
+
if (annotation.label) lines.push(labelLine(start, end, rect.left, Math.max(8, rect.top - 38), annotation.label));
|
|
308
|
+
}
|
|
309
|
+
return [
|
|
310
|
+
"[Script Info]",
|
|
311
|
+
"ScriptType: v4.00+",
|
|
312
|
+
`PlayResX: ${Math.round(video.width)}`,
|
|
313
|
+
`PlayResY: ${Math.round(video.height)}`,
|
|
314
|
+
"ScaledBorderAndShadow: yes",
|
|
315
|
+
"",
|
|
316
|
+
"[V4+ Styles]",
|
|
317
|
+
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
|
|
318
|
+
"Style: Default,Arial,30,&H00FFFFFF,&H000000FF,&H00000000,&H90000000,0,0,0,0,100,100,0,0,1,2,0,7,0,0,0,1",
|
|
319
|
+
"",
|
|
320
|
+
"[Events]",
|
|
321
|
+
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
|
|
322
|
+
...lines,
|
|
323
|
+
""
|
|
324
|
+
].join("\n");
|
|
325
|
+
}
|
|
326
|
+
async function videoSize(inputFilePath) {
|
|
327
|
+
const { stdout } = await execFileAsync("ffprobe", [
|
|
328
|
+
"-v",
|
|
329
|
+
"error",
|
|
330
|
+
"-select_streams",
|
|
331
|
+
"v:0",
|
|
332
|
+
"-show_entries",
|
|
333
|
+
"stream=width,height",
|
|
334
|
+
"-of",
|
|
335
|
+
"json",
|
|
336
|
+
inputFilePath
|
|
337
|
+
], { maxBuffer: 1024 * 1024 });
|
|
338
|
+
const parsed = JSON.parse(stdout);
|
|
339
|
+
const stream = parsed.streams?.[0];
|
|
340
|
+
if (!stream?.width || !stream.height) throw new Error("could not read replay video dimensions");
|
|
341
|
+
return { width: stream.width, height: stream.height };
|
|
342
|
+
}
|
|
343
|
+
function ffmpegFilterPath(path) {
|
|
344
|
+
return path.replace(/\\/g, "\\\\").replace(/:/g, "\\:");
|
|
345
|
+
}
|
|
346
|
+
async function annotateReplayVideo(inputFilePath, outputFilePath, options) {
|
|
347
|
+
if (!options.annotations.length) throw new Error("annotations must include at least one item");
|
|
348
|
+
const size = await videoSize(inputFilePath);
|
|
349
|
+
const tmp = await (0, import_promises.mkdtemp)((0, import_node_path.join)((0, import_node_os.tmpdir)(), "mcp-scraper-ass-"));
|
|
350
|
+
const assPath = (0, import_node_path.join)(tmp, "annotations.ass");
|
|
351
|
+
try {
|
|
352
|
+
await (0, import_promises.writeFile)(assPath, buildAssSubtitle(options, size), "utf8");
|
|
353
|
+
await execFileAsync("ffmpeg", [
|
|
354
|
+
"-y",
|
|
355
|
+
"-i",
|
|
356
|
+
inputFilePath,
|
|
357
|
+
"-vf",
|
|
358
|
+
`ass=${ffmpegFilterPath(assPath)}`,
|
|
359
|
+
"-c:v",
|
|
360
|
+
"libx264",
|
|
361
|
+
"-pix_fmt",
|
|
362
|
+
"yuv420p",
|
|
363
|
+
"-movflags",
|
|
364
|
+
"+faststart",
|
|
365
|
+
"-c:a",
|
|
366
|
+
"copy",
|
|
367
|
+
outputFilePath
|
|
368
|
+
], { maxBuffer: 1024 * 1024 * 20 });
|
|
369
|
+
const out = await (0, import_promises.stat)(outputFilePath);
|
|
370
|
+
return {
|
|
371
|
+
filePath: outputFilePath,
|
|
372
|
+
bytes: out.size,
|
|
373
|
+
width: size.width,
|
|
374
|
+
height: size.height,
|
|
375
|
+
annotationCount: options.annotations.length
|
|
376
|
+
};
|
|
377
|
+
} finally {
|
|
378
|
+
await (0, import_promises.rm)(tmp, { recursive: true, force: true });
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
70
382
|
// src/mcp/browser-agent-mcp-server.ts
|
|
71
383
|
function textResult(value, isError = false) {
|
|
72
384
|
return { content: [{ type: "text", text: JSON.stringify(value) }], isError };
|
|
73
385
|
}
|
|
74
386
|
function outputBaseDir() {
|
|
75
|
-
return process.env.MCP_SCRAPER_OUTPUT_DIR?.trim() || (0,
|
|
387
|
+
return process.env.MCP_SCRAPER_OUTPUT_DIR?.trim() || (0, import_node_path2.join)((0, import_node_os2.homedir)(), "Downloads", "mcp-scraper");
|
|
76
388
|
}
|
|
77
389
|
function safeFilePart(value) {
|
|
78
390
|
return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 120) || "replay";
|
|
@@ -81,7 +393,32 @@ function replayFilePath(sessionId, replayId, filename) {
|
|
|
81
393
|
const requested = filename?.trim();
|
|
82
394
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
83
395
|
const name = requested ? safeFilePart(requested).replace(/\.mp4$/i, "") : `${stamp}-${safeFilePart(sessionId)}-${safeFilePart(replayId)}`;
|
|
84
|
-
return (0,
|
|
396
|
+
return (0, import_node_path2.join)(outputBaseDir(), "browser-replays", `${name}.mp4`);
|
|
397
|
+
}
|
|
398
|
+
function annotatedReplayFilePath(sessionId, replayId, filename) {
|
|
399
|
+
const requested = filename?.trim();
|
|
400
|
+
if (requested) return replayFilePath(sessionId, replayId, requested);
|
|
401
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
402
|
+
return replayFilePath(sessionId, replayId, `${stamp}-${safeFilePart(sessionId)}-${safeFilePart(replayId)}-annotated`);
|
|
403
|
+
}
|
|
404
|
+
function finiteNumber2(value) {
|
|
405
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
406
|
+
}
|
|
407
|
+
function expandElementBounds(element, viewport, padding) {
|
|
408
|
+
const sourceWidth = finiteNumber2(viewport?.width) && viewport.width > 0 ? viewport.width : 1920;
|
|
409
|
+
const sourceHeight = finiteNumber2(viewport?.height) && viewport.height > 0 ? viewport.height : 1080;
|
|
410
|
+
const left = Math.max(0, element.left - padding);
|
|
411
|
+
const top = Math.max(0, element.top - padding);
|
|
412
|
+
const right = Math.min(sourceWidth, element.left + element.width + padding);
|
|
413
|
+
const bottom = Math.min(sourceHeight, element.top + element.height + padding);
|
|
414
|
+
return {
|
|
415
|
+
left,
|
|
416
|
+
top,
|
|
417
|
+
width: Math.max(1, right - left),
|
|
418
|
+
height: Math.max(1, bottom - top),
|
|
419
|
+
sourceWidth,
|
|
420
|
+
sourceHeight
|
|
421
|
+
};
|
|
85
422
|
}
|
|
86
423
|
function buildBrowserAgentMcpServer(opts) {
|
|
87
424
|
const server2 = new import_mcp.McpServer({ name: "browser-agent", version: PACKAGE_VERSION });
|
|
@@ -120,7 +457,7 @@ function registerBrowserAgentMcpTools(server2, opts) {
|
|
|
120
457
|
}
|
|
121
458
|
const bytes = Buffer.from(await res.arrayBuffer());
|
|
122
459
|
const filePath = replayFilePath(sessionId, replayId, filename);
|
|
123
|
-
(0, import_node_fs.mkdirSync)((0,
|
|
460
|
+
(0, import_node_fs.mkdirSync)((0, import_node_path2.join)(outputBaseDir(), "browser-replays"), { recursive: true });
|
|
124
461
|
(0, import_node_fs.writeFileSync)(filePath, bytes);
|
|
125
462
|
return {
|
|
126
463
|
ok: true,
|
|
@@ -204,6 +541,19 @@ function registerBrowserAgentMcpTools(server2, opts) {
|
|
|
204
541
|
return textResult(res.data, !res.ok);
|
|
205
542
|
}
|
|
206
543
|
);
|
|
544
|
+
server2.registerTool(
|
|
545
|
+
"browser_locate",
|
|
546
|
+
{
|
|
547
|
+
title: "Locate DOM Targets",
|
|
548
|
+
description: "Locate exact visible DOM elements or text ranges in the current browser viewport and return left/top/width/height bounds in screenshot pixels. Use this before drawing annotations or when a callout must literally circle, box, underline, or point to a real element. Prefer CSS selectors for exact UI elements; use text when selector is unknown. When a replay is actively recording, the result includes replay_elapsed_seconds for timing.",
|
|
549
|
+
inputSchema: BrowserLocateInputSchema,
|
|
550
|
+
annotations: annotations("Locate DOM Targets", true)
|
|
551
|
+
},
|
|
552
|
+
async (input) => {
|
|
553
|
+
const res = await req("POST", `/agent/sessions/${input.session_id}/locate`, { targets: input.targets });
|
|
554
|
+
return textResult(res.data, !res.ok);
|
|
555
|
+
}
|
|
556
|
+
);
|
|
207
557
|
server2.registerTool(
|
|
208
558
|
"browser_goto",
|
|
209
559
|
{
|
|
@@ -331,6 +681,89 @@ function registerBrowserAgentMcpTools(server2, opts) {
|
|
|
331
681
|
return textResult(res.data, !res.ok);
|
|
332
682
|
}
|
|
333
683
|
);
|
|
684
|
+
server2.registerTool(
|
|
685
|
+
"browser_replay_mark",
|
|
686
|
+
{
|
|
687
|
+
title: "Mark Replay Annotation",
|
|
688
|
+
description: "While a replay is actively recording, locate one exact DOM target and return a ready-to-use annotation object with DOM bounds and replay-relative timing. Use this instead of guessing start_seconds or drawing rough rectangles. Workflow: start browser_replay_start, navigate until the target is visible and stable, call browser_replay_mark for each callout, then stop the replay and pass the returned annotations to browser_replay_annotate.",
|
|
689
|
+
inputSchema: BrowserReplayMarkInputSchema,
|
|
690
|
+
annotations: annotations("Mark Replay Annotation", true)
|
|
691
|
+
},
|
|
692
|
+
async (input) => {
|
|
693
|
+
const res = await req("POST", `/agent/sessions/${input.session_id}/locate`, { targets: [input.target] });
|
|
694
|
+
if (!res.ok) return textResult(res.data, true);
|
|
695
|
+
const target = res.data?.targets?.[0];
|
|
696
|
+
const element = target?.element;
|
|
697
|
+
const elapsed = res.data?.replay?.replay_elapsed_seconds;
|
|
698
|
+
if (!target?.found || !element) {
|
|
699
|
+
return textResult({ error: target?.error ?? "target not found in current viewport", target }, true);
|
|
700
|
+
}
|
|
701
|
+
if (!finiteNumber2(elapsed)) {
|
|
702
|
+
return textResult({ error: "no active replay clock found; call browser_replay_start before browser_replay_mark" }, true);
|
|
703
|
+
}
|
|
704
|
+
const padded = expandElementBounds(element, res.data?.viewport, input.padding ?? 8);
|
|
705
|
+
const start = Math.max(0, elapsed + (input.start_offset_seconds ?? -0.25));
|
|
706
|
+
const duration = input.duration_seconds ?? 4;
|
|
707
|
+
const annotation = {
|
|
708
|
+
type: input.type ?? "box",
|
|
709
|
+
start_seconds: Number(start.toFixed(3)),
|
|
710
|
+
end_seconds: Number((start + duration).toFixed(3)),
|
|
711
|
+
left: Math.round(padded.left),
|
|
712
|
+
top: Math.round(padded.top),
|
|
713
|
+
width: Math.round(padded.width),
|
|
714
|
+
height: Math.round(padded.height),
|
|
715
|
+
...input.label ? { label: input.label } : {},
|
|
716
|
+
...input.color ? { color: input.color } : {},
|
|
717
|
+
...input.thickness ? { thickness: input.thickness } : {}
|
|
718
|
+
};
|
|
719
|
+
return textResult({
|
|
720
|
+
annotation,
|
|
721
|
+
source_width: padded.sourceWidth,
|
|
722
|
+
source_height: padded.sourceHeight,
|
|
723
|
+
replay: res.data.replay,
|
|
724
|
+
target,
|
|
725
|
+
hint: "Append annotation to your annotations array. Use source_width/source_height with browser_replay_annotate."
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
);
|
|
729
|
+
server2.registerTool(
|
|
730
|
+
"browser_replay_annotate",
|
|
731
|
+
{
|
|
732
|
+
title: "Annotate Replay MP4",
|
|
733
|
+
description: "Download a browser replay MP4, render visual annotations over it, and save a new annotated MP4 locally. Use this after browser_replay_stop when the user wants proof videos with circles, boxes, arrows, underlines, or labels. For accurate timing and placement, prefer annotations returned by browser_replay_mark while the replay is recording; otherwise use exact left/top/width/height bounds from browser_locate. If the replay video size differs from the screenshot coordinate space, pass source_width and source_height.",
|
|
734
|
+
inputSchema: BrowserReplayAnnotateInputSchema,
|
|
735
|
+
annotations: annotations("Annotate Replay MP4", true)
|
|
736
|
+
},
|
|
737
|
+
async (input) => {
|
|
738
|
+
const sourceName = input.filename ? `${input.filename}-source` : void 0;
|
|
739
|
+
const downloaded = await downloadReplay(input.session_id, input.replay_id, sourceName);
|
|
740
|
+
if (!downloaded.ok) return textResult(downloaded.data, true);
|
|
741
|
+
try {
|
|
742
|
+
const sourcePath = String(downloaded.data.file_path);
|
|
743
|
+
const outputPath = annotatedReplayFilePath(input.session_id, input.replay_id, input.filename);
|
|
744
|
+
(0, import_node_fs.mkdirSync)((0, import_node_path2.join)(outputBaseDir(), "browser-replays"), { recursive: true });
|
|
745
|
+
const result = await annotateReplayVideo(sourcePath, outputPath, {
|
|
746
|
+
annotations: input.annotations,
|
|
747
|
+
sourceWidth: input.source_width,
|
|
748
|
+
sourceHeight: input.source_height,
|
|
749
|
+
sourceLeftOffset: input.source_left_offset,
|
|
750
|
+
sourceTopOffset: input.source_top_offset
|
|
751
|
+
});
|
|
752
|
+
return textResult({
|
|
753
|
+
replay_id: input.replay_id,
|
|
754
|
+
source_file_path: sourcePath,
|
|
755
|
+
annotated_file_path: result.filePath,
|
|
756
|
+
bytes: result.bytes,
|
|
757
|
+
width: result.width,
|
|
758
|
+
height: result.height,
|
|
759
|
+
annotation_count: result.annotationCount,
|
|
760
|
+
mime_type: "video/mp4"
|
|
761
|
+
});
|
|
762
|
+
} catch (err) {
|
|
763
|
+
return textResult({ error: err instanceof Error ? err.message : String(err) }, true);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
);
|
|
334
767
|
server2.registerTool(
|
|
335
768
|
"browser_close",
|
|
336
769
|
{
|
|
@@ -364,7 +797,7 @@ function registerBrowserAgentMcpTools(server2, opts) {
|
|
|
364
797
|
// bin/browser-agent-stdio-server.ts
|
|
365
798
|
function readApiKeyFile() {
|
|
366
799
|
const explicitPath = process.env.MCP_SCRAPER_KEY_PATH?.trim();
|
|
367
|
-
const paths = [explicitPath, (0,
|
|
800
|
+
const paths = [explicitPath, (0, import_node_path3.join)((0, import_node_os3.homedir)(), ".mcp-scraper-key")].filter(Boolean);
|
|
368
801
|
for (const path of paths) {
|
|
369
802
|
try {
|
|
370
803
|
const value = (0, import_node_fs2.readFileSync)(path, "utf8").trim();
|