mcp-scraper 0.2.5 → 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 +13 -7
- package/dist/bin/api-server.cjs +617 -11
- 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 +842 -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 +3 -2
- package/dist/bin/mcp-scraper-install.cjs.map +1 -1
- package/dist/bin/mcp-scraper-install.js +3 -2
- package/dist/bin/mcp-scraper-install.js.map +1 -1
- package/dist/bin/mcp-stdio-server.cjs +391 -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-ENI4DM3S.js → chunk-7SI6XIR3.js} +392 -2
- package/dist/chunk-7SI6XIR3.js.map +1 -0
- package/dist/chunk-IZE5UW7Y.js +7 -0
- package/dist/chunk-IZE5UW7Y.js.map +1 -0
- package/dist/{server-Q3WHIS63.js → server-RIKRBDOI.js} +221 -12
- package/dist/{server-Q3WHIS63.js.map → server-RIKRBDOI.js.map} +1 -1
- package/package.json +1 -1
- package/dist/chunk-ENI4DM3S.js.map +0 -1
- package/dist/chunk-JDX57SEC.js +0 -7
- package/dist/chunk-JDX57SEC.js.map +0 -1
- package/dist/chunk-O36HHUKX.js +0 -360
- package/dist/chunk-O36HHUKX.js.map +0 -1
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
// bin/mcp-scraper-combined-stdio-server.ts
|
|
5
5
|
var import_node_fs4 = require("fs");
|
|
6
|
-
var
|
|
7
|
-
var
|
|
6
|
+
var import_node_os4 = require("os");
|
|
7
|
+
var import_node_path5 = require("path");
|
|
8
8
|
var import_mcp3 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
9
9
|
var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
10
10
|
|
|
@@ -134,11 +134,11 @@ var HttpMcpToolExecutor = class {
|
|
|
134
134
|
// src/mcp/browser-agent-mcp-server.ts
|
|
135
135
|
var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
136
136
|
var import_node_fs = require("fs");
|
|
137
|
-
var
|
|
138
|
-
var
|
|
137
|
+
var import_node_os2 = require("os");
|
|
138
|
+
var import_node_path2 = require("path");
|
|
139
139
|
|
|
140
140
|
// src/version.ts
|
|
141
|
-
var PACKAGE_VERSION = "0.2.
|
|
141
|
+
var PACKAGE_VERSION = "0.2.7";
|
|
142
142
|
|
|
143
143
|
// src/mcp/browser-agent-tool-schemas.ts
|
|
144
144
|
var import_zod = require("zod");
|
|
@@ -151,6 +151,19 @@ var BrowserOpenInputSchema = {
|
|
|
151
151
|
var BrowserSessionInputSchema = {
|
|
152
152
|
session_id: import_zod.z.string().describe("The session id returned by browser_open.")
|
|
153
153
|
};
|
|
154
|
+
var BrowserLocateTargetSchema = import_zod.z.object({
|
|
155
|
+
name: import_zod.z.string().optional().describe("Optional label for this target, echoed in the result."),
|
|
156
|
+
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"].'),
|
|
157
|
+
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."),
|
|
158
|
+
match: import_zod.z.enum(["contains", "exact"]).default("contains").describe("How to match text targets. Defaults to contains."),
|
|
159
|
+
index: import_zod.z.number().int().min(0).default(0).describe("Zero-based match index when multiple elements match.")
|
|
160
|
+
}).refine((value) => Boolean(value.selector || value.text), {
|
|
161
|
+
message: "target requires selector or text"
|
|
162
|
+
});
|
|
163
|
+
var BrowserLocateInputSchema = {
|
|
164
|
+
session_id: import_zod.z.string().describe("The session id returned by browser_open."),
|
|
165
|
+
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.")
|
|
166
|
+
};
|
|
154
167
|
var BrowserGotoInputSchema = {
|
|
155
168
|
session_id: import_zod.z.string().describe("The session id returned by browser_open."),
|
|
156
169
|
url: import_zod.z.string().url().describe("URL to navigate the browser to.")
|
|
@@ -187,16 +200,315 @@ var BrowserReplayDownloadInputSchema = {
|
|
|
187
200
|
replay_id: import_zod.z.string().describe("The replay id returned by browser_replay_start or browser_list_replays."),
|
|
188
201
|
filename: import_zod.z.string().optional().describe("Optional local MP4 filename. Defaults to a timestamped replay filename.")
|
|
189
202
|
};
|
|
203
|
+
var BrowserReplayAnnotationSchema = import_zod.z.object({
|
|
204
|
+
type: import_zod.z.enum(["box", "circle", "underline", "arrow", "label"]).default("box").describe("Annotation style to draw over the replay."),
|
|
205
|
+
start_seconds: import_zod.z.number().min(0).default(0).describe("When the annotation should appear in the replay."),
|
|
206
|
+
end_seconds: import_zod.z.number().min(0).optional().describe("When the annotation should disappear. Defaults to two seconds after start_seconds."),
|
|
207
|
+
left: import_zod.z.number().optional().describe("Target left edge in browser screenshot pixels. Use element.left from browser_screenshot/browser_read."),
|
|
208
|
+
top: import_zod.z.number().optional().describe("Target top edge in browser screenshot pixels. Use element.top from browser_screenshot/browser_read."),
|
|
209
|
+
width: import_zod.z.number().positive().optional().describe("Target width in browser screenshot pixels. Use element.width from browser_screenshot/browser_read."),
|
|
210
|
+
height: import_zod.z.number().positive().optional().describe("Target height in browser screenshot pixels. Use element.height from browser_screenshot/browser_read."),
|
|
211
|
+
x: import_zod.z.number().optional().describe("Point target x coordinate in browser screenshot pixels when no box is available."),
|
|
212
|
+
y: import_zod.z.number().optional().describe("Point target y coordinate in browser screenshot pixels when no box is available."),
|
|
213
|
+
from_x: import_zod.z.number().optional().describe("Arrow start x coordinate in browser screenshot pixels. Defaults near the target."),
|
|
214
|
+
from_y: import_zod.z.number().optional().describe("Arrow start y coordinate in browser screenshot pixels. Defaults near the target."),
|
|
215
|
+
to_x: import_zod.z.number().optional().describe("Arrow target x coordinate in browser screenshot pixels. Defaults to the target box center."),
|
|
216
|
+
to_y: import_zod.z.number().optional().describe("Arrow target y coordinate in browser screenshot pixels. Defaults to the target box center."),
|
|
217
|
+
label: import_zod.z.string().max(120).optional().describe("Optional text callout to render near the annotation."),
|
|
218
|
+
color: import_zod.z.string().regex(/^#?[0-9a-fA-F]{6}$/).optional().describe("Annotation color as hex, for example #ff3b30."),
|
|
219
|
+
thickness: import_zod.z.number().min(1).max(24).optional().describe("Stroke thickness in pixels. Defaults to 5.")
|
|
220
|
+
});
|
|
221
|
+
var BrowserReplayMarkInputSchema = {
|
|
222
|
+
session_id: import_zod.z.string().describe("The session id returned by browser_open. A replay must already be recording."),
|
|
223
|
+
target: BrowserLocateTargetSchema.describe("The exact DOM element or text range to mark in the current viewport."),
|
|
224
|
+
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."),
|
|
225
|
+
label: import_zod.z.string().max(120).optional().describe("Optional callout text to render near the target."),
|
|
226
|
+
color: import_zod.z.string().regex(/^#?[0-9a-fA-F]{6}$/).optional().describe("Annotation color as hex, for example #ff3b30."),
|
|
227
|
+
thickness: import_zod.z.number().min(1).max(24).optional().describe("Stroke thickness in pixels. Defaults to 5."),
|
|
228
|
+
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."),
|
|
229
|
+
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."),
|
|
230
|
+
duration_seconds: import_zod.z.number().min(0.5).max(30).default(4).describe("How long the generated annotation should remain visible.")
|
|
231
|
+
};
|
|
232
|
+
var BrowserReplayAnnotateInputSchema = {
|
|
233
|
+
session_id: import_zod.z.string().describe("The session id returned by browser_open."),
|
|
234
|
+
replay_id: import_zod.z.string().describe("The replay id returned by browser_replay_start or browser_list_replays."),
|
|
235
|
+
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."),
|
|
236
|
+
filename: import_zod.z.string().optional().describe("Optional output MP4 filename. Defaults to a timestamped annotated replay filename."),
|
|
237
|
+
source_width: import_zod.z.number().positive().optional().describe("Width of the screenshot coordinate space used for annotations. Defaults to the replay video width."),
|
|
238
|
+
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."),
|
|
239
|
+
source_left_offset: import_zod.z.number().min(0).optional().describe("Optional explicit X offset from annotation coordinates to replay video coordinates. Usually omitted."),
|
|
240
|
+
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.")
|
|
241
|
+
};
|
|
190
242
|
var BrowserListInputSchema = {
|
|
191
243
|
include_closed: import_zod.z.boolean().default(false).describe("Include closed sessions in the list.")
|
|
192
244
|
};
|
|
193
245
|
|
|
246
|
+
// src/mcp/replay-annotator.ts
|
|
247
|
+
var import_node_child_process = require("child_process");
|
|
248
|
+
var import_promises = require("fs/promises");
|
|
249
|
+
var import_node_os = require("os");
|
|
250
|
+
var import_node_path = require("path");
|
|
251
|
+
var import_node_util = require("util");
|
|
252
|
+
var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
|
|
253
|
+
function finiteNumber(value) {
|
|
254
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
255
|
+
}
|
|
256
|
+
function clamp(value, min, max) {
|
|
257
|
+
return Math.min(max, Math.max(min, value));
|
|
258
|
+
}
|
|
259
|
+
function formatAssTime(seconds) {
|
|
260
|
+
const safe = Math.max(0, seconds);
|
|
261
|
+
const hours = Math.floor(safe / 3600);
|
|
262
|
+
const minutes = Math.floor(safe % 3600 / 60);
|
|
263
|
+
const wholeSeconds = Math.floor(safe % 60);
|
|
264
|
+
const centiseconds = Math.floor((safe - Math.floor(safe)) * 100);
|
|
265
|
+
return `${hours}:${String(minutes).padStart(2, "0")}:${String(wholeSeconds).padStart(2, "0")}.${String(centiseconds).padStart(2, "0")}`;
|
|
266
|
+
}
|
|
267
|
+
function cssHexToAssColor(color) {
|
|
268
|
+
const normalized = color?.trim().match(/^#?([0-9a-fA-F]{6})$/)?.[1] ?? "ff3b30";
|
|
269
|
+
const red = normalized.slice(0, 2);
|
|
270
|
+
const green = normalized.slice(2, 4);
|
|
271
|
+
const blue = normalized.slice(4, 6);
|
|
272
|
+
return `&H${blue}${green}${red}&`;
|
|
273
|
+
}
|
|
274
|
+
function escapeAssText(value) {
|
|
275
|
+
return value.replace(/\\/g, "\\\\").replace(/\{/g, "\\{").replace(/\}/g, "\\}").replace(/\r?\n/g, "\\N");
|
|
276
|
+
}
|
|
277
|
+
function resolveCoordinateTransform(video, source, options) {
|
|
278
|
+
const explicitOffsetX = finiteNumber(options.sourceLeftOffset) ? options.sourceLeftOffset : null;
|
|
279
|
+
const explicitOffsetY = finiteNumber(options.sourceTopOffset) ? options.sourceTopOffset : null;
|
|
280
|
+
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;
|
|
281
|
+
const offsetX = explicitOffsetX ?? 0;
|
|
282
|
+
const offsetY = explicitOffsetY ?? inferredTopOffset;
|
|
283
|
+
return {
|
|
284
|
+
scaleX: (video.width - offsetX) / source.width,
|
|
285
|
+
scaleY: (video.height - offsetY) / source.height,
|
|
286
|
+
offsetX,
|
|
287
|
+
offsetY
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
function transformPoint(x, y, transform) {
|
|
291
|
+
return {
|
|
292
|
+
x: x * transform.scaleX + transform.offsetX,
|
|
293
|
+
y: y * transform.scaleY + transform.offsetY
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
function scaleRect(annotation, transform) {
|
|
297
|
+
if (finiteNumber(annotation.left) && finiteNumber(annotation.top) && finiteNumber(annotation.width) && finiteNumber(annotation.height)) {
|
|
298
|
+
const origin = transformPoint(annotation.left, annotation.top, transform);
|
|
299
|
+
return {
|
|
300
|
+
left: origin.x,
|
|
301
|
+
top: origin.y,
|
|
302
|
+
width: annotation.width * transform.scaleX,
|
|
303
|
+
height: annotation.height * transform.scaleY
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
if (finiteNumber(annotation.x) && finiteNumber(annotation.y)) {
|
|
307
|
+
const radius = finiteNumber(annotation.width) ? annotation.width / 2 : 28;
|
|
308
|
+
const point = transformPoint(annotation.x, annotation.y, transform);
|
|
309
|
+
return {
|
|
310
|
+
left: point.x - radius * transform.scaleX,
|
|
311
|
+
top: point.y - radius * transform.scaleY,
|
|
312
|
+
width: radius * 2 * transform.scaleX,
|
|
313
|
+
height: radius * 2 * transform.scaleY
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
throw new Error("annotation needs either left/top/width/height or x/y");
|
|
317
|
+
}
|
|
318
|
+
function rectPath(rect) {
|
|
319
|
+
const x1 = Math.round(rect.left);
|
|
320
|
+
const y1 = Math.round(rect.top);
|
|
321
|
+
const x2 = Math.round(rect.left + rect.width);
|
|
322
|
+
const y2 = Math.round(rect.top + rect.height);
|
|
323
|
+
return `m ${x1} ${y1} l ${x2} ${y1} l ${x2} ${y2} l ${x1} ${y2} l ${x1} ${y1}`;
|
|
324
|
+
}
|
|
325
|
+
function ellipsePath(rect) {
|
|
326
|
+
const cx = rect.left + rect.width / 2;
|
|
327
|
+
const cy = rect.top + rect.height / 2;
|
|
328
|
+
const rx = Math.max(4, rect.width / 2);
|
|
329
|
+
const ry = Math.max(4, rect.height / 2);
|
|
330
|
+
const points = [];
|
|
331
|
+
for (let i = 0; i <= 32; i += 1) {
|
|
332
|
+
const t = Math.PI * 2 * i / 32;
|
|
333
|
+
const x = Math.round(cx + Math.cos(t) * rx);
|
|
334
|
+
const y = Math.round(cy + Math.sin(t) * ry);
|
|
335
|
+
points.push(`${i === 0 ? "m" : "l"} ${x} ${y}`);
|
|
336
|
+
}
|
|
337
|
+
return points.join(" ");
|
|
338
|
+
}
|
|
339
|
+
function filledRectPath(left, top, width, height) {
|
|
340
|
+
return rectPath({ left, top, width, height });
|
|
341
|
+
}
|
|
342
|
+
function arrowPath(fromX, fromY, toX, toY, thickness) {
|
|
343
|
+
const dx = toX - fromX;
|
|
344
|
+
const dy = toY - fromY;
|
|
345
|
+
const length = Math.max(1, Math.hypot(dx, dy));
|
|
346
|
+
const ux = dx / length;
|
|
347
|
+
const uy = dy / length;
|
|
348
|
+
const px = -uy;
|
|
349
|
+
const py = ux;
|
|
350
|
+
const half = thickness / 2;
|
|
351
|
+
const head = Math.max(14, thickness * 4);
|
|
352
|
+
const baseX = toX - ux * head;
|
|
353
|
+
const baseY = toY - uy * head;
|
|
354
|
+
const p1x = Math.round(fromX + px * half);
|
|
355
|
+
const p1y = Math.round(fromY + py * half);
|
|
356
|
+
const p2x = Math.round(baseX + px * half);
|
|
357
|
+
const p2y = Math.round(baseY + py * half);
|
|
358
|
+
const p3x = Math.round(baseX + px * head * 0.55);
|
|
359
|
+
const p3y = Math.round(baseY + py * head * 0.55);
|
|
360
|
+
const p4x = Math.round(toX);
|
|
361
|
+
const p4y = Math.round(toY);
|
|
362
|
+
const p5x = Math.round(baseX - px * head * 0.55);
|
|
363
|
+
const p5y = Math.round(baseY - py * head * 0.55);
|
|
364
|
+
const p6x = Math.round(baseX - px * half);
|
|
365
|
+
const p6y = Math.round(baseY - py * half);
|
|
366
|
+
const p7x = Math.round(fromX - px * half);
|
|
367
|
+
const p7y = Math.round(fromY - py * half);
|
|
368
|
+
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}`;
|
|
369
|
+
}
|
|
370
|
+
function eventLine(start, end, body) {
|
|
371
|
+
return `Dialogue: 0,${formatAssTime(start)},${formatAssTime(end)},Default,,0,0,0,,${body}`;
|
|
372
|
+
}
|
|
373
|
+
function labelLine(start, end, x, y, label) {
|
|
374
|
+
const safeX = Math.round(x);
|
|
375
|
+
const safeY = Math.round(y);
|
|
376
|
+
return eventLine(
|
|
377
|
+
start,
|
|
378
|
+
end,
|
|
379
|
+
`{\\an7\\pos(${safeX},${safeY})\\fs30\\bord4\\shad0\\c&HFFFFFF&\\3c&H000000&}${escapeAssText(label)}`
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
function shapeLine(start, end, path, color, thickness, filled = false) {
|
|
383
|
+
const fillAlpha = filled ? "&H00&" : "&HFF&";
|
|
384
|
+
const border = filled ? 0 : thickness;
|
|
385
|
+
return eventLine(
|
|
386
|
+
start,
|
|
387
|
+
end,
|
|
388
|
+
`{\\an7\\pos(0,0)\\p1\\bord${border}\\shad0\\1a${fillAlpha}\\1c${color}\\3c${color}}${path}`
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
function buildAssSubtitle(options, video) {
|
|
392
|
+
const source = {
|
|
393
|
+
width: options.sourceWidth && options.sourceWidth > 0 ? options.sourceWidth : video.width,
|
|
394
|
+
height: options.sourceHeight && options.sourceHeight > 0 ? options.sourceHeight : video.height
|
|
395
|
+
};
|
|
396
|
+
const transform = resolveCoordinateTransform(video, source, options);
|
|
397
|
+
const lines = [];
|
|
398
|
+
for (const annotation of options.annotations) {
|
|
399
|
+
const start = Math.max(0, annotation.start_seconds ?? 0);
|
|
400
|
+
const end = annotation.end_seconds && annotation.end_seconds > start ? annotation.end_seconds : start + 2;
|
|
401
|
+
const type = annotation.type ?? "box";
|
|
402
|
+
const color = cssHexToAssColor(annotation.color);
|
|
403
|
+
const thickness = Math.round(clamp(annotation.thickness ?? 5, 2, 24));
|
|
404
|
+
if (type === "label") {
|
|
405
|
+
if (!annotation.label) throw new Error("label annotation needs label");
|
|
406
|
+
const rect2 = scaleRect(annotation, transform);
|
|
407
|
+
lines.push(labelLine(start, end, rect2.left, Math.max(8, rect2.top), annotation.label));
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (type === "arrow") {
|
|
411
|
+
const rect2 = finiteNumber(annotation.to_x) && finiteNumber(annotation.to_y) ? null : scaleRect(annotation, transform);
|
|
412
|
+
const toPoint = finiteNumber(annotation.to_x) && finiteNumber(annotation.to_y) ? transformPoint(annotation.to_x, annotation.to_y, transform) : null;
|
|
413
|
+
const toX = toPoint ? toPoint.x : rect2.left + rect2.width / 2;
|
|
414
|
+
const toY = toPoint ? toPoint.y : rect2.top + rect2.height / 2;
|
|
415
|
+
const fromPoint = finiteNumber(annotation.from_x) && finiteNumber(annotation.from_y) ? transformPoint(annotation.from_x, annotation.from_y, transform) : null;
|
|
416
|
+
const fromX = fromPoint ? fromPoint.x : clamp(toX - 120, 12, video.width - 12);
|
|
417
|
+
const fromY = fromPoint ? fromPoint.y : clamp(toY - 90, 12, video.height - 12);
|
|
418
|
+
lines.push(shapeLine(start, end, arrowPath(fromX, fromY, toX, toY, thickness), color, thickness, true));
|
|
419
|
+
if (annotation.label) lines.push(labelLine(start, end, fromX + 8, Math.max(96, fromY - 36), annotation.label));
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
const rect = scaleRect(annotation, transform);
|
|
423
|
+
if (type === "underline") {
|
|
424
|
+
const underlineTop = rect.top + rect.height + thickness;
|
|
425
|
+
lines.push(shapeLine(start, end, filledRectPath(rect.left, underlineTop, rect.width, thickness), color, 0, true));
|
|
426
|
+
} else if (type === "circle") {
|
|
427
|
+
lines.push(shapeLine(start, end, ellipsePath(rect), color, thickness));
|
|
428
|
+
} else {
|
|
429
|
+
lines.push(shapeLine(start, end, rectPath(rect), color, thickness));
|
|
430
|
+
}
|
|
431
|
+
if (annotation.label) lines.push(labelLine(start, end, rect.left, Math.max(8, rect.top - 38), annotation.label));
|
|
432
|
+
}
|
|
433
|
+
return [
|
|
434
|
+
"[Script Info]",
|
|
435
|
+
"ScriptType: v4.00+",
|
|
436
|
+
`PlayResX: ${Math.round(video.width)}`,
|
|
437
|
+
`PlayResY: ${Math.round(video.height)}`,
|
|
438
|
+
"ScaledBorderAndShadow: yes",
|
|
439
|
+
"",
|
|
440
|
+
"[V4+ Styles]",
|
|
441
|
+
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
|
|
442
|
+
"Style: Default,Arial,30,&H00FFFFFF,&H000000FF,&H00000000,&H90000000,0,0,0,0,100,100,0,0,1,2,0,7,0,0,0,1",
|
|
443
|
+
"",
|
|
444
|
+
"[Events]",
|
|
445
|
+
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
|
|
446
|
+
...lines,
|
|
447
|
+
""
|
|
448
|
+
].join("\n");
|
|
449
|
+
}
|
|
450
|
+
async function videoSize(inputFilePath) {
|
|
451
|
+
const { stdout } = await execFileAsync("ffprobe", [
|
|
452
|
+
"-v",
|
|
453
|
+
"error",
|
|
454
|
+
"-select_streams",
|
|
455
|
+
"v:0",
|
|
456
|
+
"-show_entries",
|
|
457
|
+
"stream=width,height",
|
|
458
|
+
"-of",
|
|
459
|
+
"json",
|
|
460
|
+
inputFilePath
|
|
461
|
+
], { maxBuffer: 1024 * 1024 });
|
|
462
|
+
const parsed = JSON.parse(stdout);
|
|
463
|
+
const stream = parsed.streams?.[0];
|
|
464
|
+
if (!stream?.width || !stream.height) throw new Error("could not read replay video dimensions");
|
|
465
|
+
return { width: stream.width, height: stream.height };
|
|
466
|
+
}
|
|
467
|
+
function ffmpegFilterPath(path) {
|
|
468
|
+
return path.replace(/\\/g, "\\\\").replace(/:/g, "\\:");
|
|
469
|
+
}
|
|
470
|
+
async function annotateReplayVideo(inputFilePath, outputFilePath, options) {
|
|
471
|
+
if (!options.annotations.length) throw new Error("annotations must include at least one item");
|
|
472
|
+
const size = await videoSize(inputFilePath);
|
|
473
|
+
const tmp = await (0, import_promises.mkdtemp)((0, import_node_path.join)((0, import_node_os.tmpdir)(), "mcp-scraper-ass-"));
|
|
474
|
+
const assPath = (0, import_node_path.join)(tmp, "annotations.ass");
|
|
475
|
+
try {
|
|
476
|
+
await (0, import_promises.writeFile)(assPath, buildAssSubtitle(options, size), "utf8");
|
|
477
|
+
await execFileAsync("ffmpeg", [
|
|
478
|
+
"-y",
|
|
479
|
+
"-i",
|
|
480
|
+
inputFilePath,
|
|
481
|
+
"-vf",
|
|
482
|
+
`ass=${ffmpegFilterPath(assPath)}`,
|
|
483
|
+
"-c:v",
|
|
484
|
+
"libx264",
|
|
485
|
+
"-pix_fmt",
|
|
486
|
+
"yuv420p",
|
|
487
|
+
"-movflags",
|
|
488
|
+
"+faststart",
|
|
489
|
+
"-c:a",
|
|
490
|
+
"copy",
|
|
491
|
+
outputFilePath
|
|
492
|
+
], { maxBuffer: 1024 * 1024 * 20 });
|
|
493
|
+
const out = await (0, import_promises.stat)(outputFilePath);
|
|
494
|
+
return {
|
|
495
|
+
filePath: outputFilePath,
|
|
496
|
+
bytes: out.size,
|
|
497
|
+
width: size.width,
|
|
498
|
+
height: size.height,
|
|
499
|
+
annotationCount: options.annotations.length
|
|
500
|
+
};
|
|
501
|
+
} finally {
|
|
502
|
+
await (0, import_promises.rm)(tmp, { recursive: true, force: true });
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
194
506
|
// src/mcp/browser-agent-mcp-server.ts
|
|
195
507
|
function textResult(value, isError = false) {
|
|
196
508
|
return { content: [{ type: "text", text: JSON.stringify(value) }], isError };
|
|
197
509
|
}
|
|
198
510
|
function outputBaseDir() {
|
|
199
|
-
return process.env.MCP_SCRAPER_OUTPUT_DIR?.trim() || (0,
|
|
511
|
+
return process.env.MCP_SCRAPER_OUTPUT_DIR?.trim() || (0, import_node_path2.join)((0, import_node_os2.homedir)(), "Downloads", "mcp-scraper");
|
|
200
512
|
}
|
|
201
513
|
function safeFilePart(value) {
|
|
202
514
|
return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 120) || "replay";
|
|
@@ -205,7 +517,32 @@ function replayFilePath(sessionId, replayId, filename) {
|
|
|
205
517
|
const requested = filename?.trim();
|
|
206
518
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
207
519
|
const name = requested ? safeFilePart(requested).replace(/\.mp4$/i, "") : `${stamp}-${safeFilePart(sessionId)}-${safeFilePart(replayId)}`;
|
|
208
|
-
return (0,
|
|
520
|
+
return (0, import_node_path2.join)(outputBaseDir(), "browser-replays", `${name}.mp4`);
|
|
521
|
+
}
|
|
522
|
+
function annotatedReplayFilePath(sessionId, replayId, filename) {
|
|
523
|
+
const requested = filename?.trim();
|
|
524
|
+
if (requested) return replayFilePath(sessionId, replayId, requested);
|
|
525
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
526
|
+
return replayFilePath(sessionId, replayId, `${stamp}-${safeFilePart(sessionId)}-${safeFilePart(replayId)}-annotated`);
|
|
527
|
+
}
|
|
528
|
+
function finiteNumber2(value) {
|
|
529
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
530
|
+
}
|
|
531
|
+
function expandElementBounds(element, viewport, padding) {
|
|
532
|
+
const sourceWidth = finiteNumber2(viewport?.width) && viewport.width > 0 ? viewport.width : 1920;
|
|
533
|
+
const sourceHeight = finiteNumber2(viewport?.height) && viewport.height > 0 ? viewport.height : 1080;
|
|
534
|
+
const left = Math.max(0, element.left - padding);
|
|
535
|
+
const top = Math.max(0, element.top - padding);
|
|
536
|
+
const right = Math.min(sourceWidth, element.left + element.width + padding);
|
|
537
|
+
const bottom = Math.min(sourceHeight, element.top + element.height + padding);
|
|
538
|
+
return {
|
|
539
|
+
left,
|
|
540
|
+
top,
|
|
541
|
+
width: Math.max(1, right - left),
|
|
542
|
+
height: Math.max(1, bottom - top),
|
|
543
|
+
sourceWidth,
|
|
544
|
+
sourceHeight
|
|
545
|
+
};
|
|
209
546
|
}
|
|
210
547
|
function registerBrowserAgentMcpTools(server2, opts) {
|
|
211
548
|
const baseUrl2 = opts.baseUrl.replace(/\/$/, "");
|
|
@@ -239,7 +576,7 @@ function registerBrowserAgentMcpTools(server2, opts) {
|
|
|
239
576
|
}
|
|
240
577
|
const bytes = Buffer.from(await res.arrayBuffer());
|
|
241
578
|
const filePath = replayFilePath(sessionId, replayId, filename);
|
|
242
|
-
(0, import_node_fs.mkdirSync)((0,
|
|
579
|
+
(0, import_node_fs.mkdirSync)((0, import_node_path2.join)(outputBaseDir(), "browser-replays"), { recursive: true });
|
|
243
580
|
(0, import_node_fs.writeFileSync)(filePath, bytes);
|
|
244
581
|
return {
|
|
245
582
|
ok: true,
|
|
@@ -323,6 +660,19 @@ function registerBrowserAgentMcpTools(server2, opts) {
|
|
|
323
660
|
return textResult(res.data, !res.ok);
|
|
324
661
|
}
|
|
325
662
|
);
|
|
663
|
+
server2.registerTool(
|
|
664
|
+
"browser_locate",
|
|
665
|
+
{
|
|
666
|
+
title: "Locate DOM Targets",
|
|
667
|
+
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.",
|
|
668
|
+
inputSchema: BrowserLocateInputSchema,
|
|
669
|
+
annotations: annotations("Locate DOM Targets", true)
|
|
670
|
+
},
|
|
671
|
+
async (input) => {
|
|
672
|
+
const res = await req("POST", `/agent/sessions/${input.session_id}/locate`, { targets: input.targets });
|
|
673
|
+
return textResult(res.data, !res.ok);
|
|
674
|
+
}
|
|
675
|
+
);
|
|
326
676
|
server2.registerTool(
|
|
327
677
|
"browser_goto",
|
|
328
678
|
{
|
|
@@ -450,6 +800,89 @@ function registerBrowserAgentMcpTools(server2, opts) {
|
|
|
450
800
|
return textResult(res.data, !res.ok);
|
|
451
801
|
}
|
|
452
802
|
);
|
|
803
|
+
server2.registerTool(
|
|
804
|
+
"browser_replay_mark",
|
|
805
|
+
{
|
|
806
|
+
title: "Mark Replay Annotation",
|
|
807
|
+
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.",
|
|
808
|
+
inputSchema: BrowserReplayMarkInputSchema,
|
|
809
|
+
annotations: annotations("Mark Replay Annotation", true)
|
|
810
|
+
},
|
|
811
|
+
async (input) => {
|
|
812
|
+
const res = await req("POST", `/agent/sessions/${input.session_id}/locate`, { targets: [input.target] });
|
|
813
|
+
if (!res.ok) return textResult(res.data, true);
|
|
814
|
+
const target = res.data?.targets?.[0];
|
|
815
|
+
const element = target?.element;
|
|
816
|
+
const elapsed = res.data?.replay?.replay_elapsed_seconds;
|
|
817
|
+
if (!target?.found || !element) {
|
|
818
|
+
return textResult({ error: target?.error ?? "target not found in current viewport", target }, true);
|
|
819
|
+
}
|
|
820
|
+
if (!finiteNumber2(elapsed)) {
|
|
821
|
+
return textResult({ error: "no active replay clock found; call browser_replay_start before browser_replay_mark" }, true);
|
|
822
|
+
}
|
|
823
|
+
const padded = expandElementBounds(element, res.data?.viewport, input.padding ?? 8);
|
|
824
|
+
const start = Math.max(0, elapsed + (input.start_offset_seconds ?? -0.25));
|
|
825
|
+
const duration = input.duration_seconds ?? 4;
|
|
826
|
+
const annotation = {
|
|
827
|
+
type: input.type ?? "box",
|
|
828
|
+
start_seconds: Number(start.toFixed(3)),
|
|
829
|
+
end_seconds: Number((start + duration).toFixed(3)),
|
|
830
|
+
left: Math.round(padded.left),
|
|
831
|
+
top: Math.round(padded.top),
|
|
832
|
+
width: Math.round(padded.width),
|
|
833
|
+
height: Math.round(padded.height),
|
|
834
|
+
...input.label ? { label: input.label } : {},
|
|
835
|
+
...input.color ? { color: input.color } : {},
|
|
836
|
+
...input.thickness ? { thickness: input.thickness } : {}
|
|
837
|
+
};
|
|
838
|
+
return textResult({
|
|
839
|
+
annotation,
|
|
840
|
+
source_width: padded.sourceWidth,
|
|
841
|
+
source_height: padded.sourceHeight,
|
|
842
|
+
replay: res.data.replay,
|
|
843
|
+
target,
|
|
844
|
+
hint: "Append annotation to your annotations array. Use source_width/source_height with browser_replay_annotate."
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
);
|
|
848
|
+
server2.registerTool(
|
|
849
|
+
"browser_replay_annotate",
|
|
850
|
+
{
|
|
851
|
+
title: "Annotate Replay MP4",
|
|
852
|
+
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.",
|
|
853
|
+
inputSchema: BrowserReplayAnnotateInputSchema,
|
|
854
|
+
annotations: annotations("Annotate Replay MP4", true)
|
|
855
|
+
},
|
|
856
|
+
async (input) => {
|
|
857
|
+
const sourceName = input.filename ? `${input.filename}-source` : void 0;
|
|
858
|
+
const downloaded = await downloadReplay(input.session_id, input.replay_id, sourceName);
|
|
859
|
+
if (!downloaded.ok) return textResult(downloaded.data, true);
|
|
860
|
+
try {
|
|
861
|
+
const sourcePath = String(downloaded.data.file_path);
|
|
862
|
+
const outputPath = annotatedReplayFilePath(input.session_id, input.replay_id, input.filename);
|
|
863
|
+
(0, import_node_fs.mkdirSync)((0, import_node_path2.join)(outputBaseDir(), "browser-replays"), { recursive: true });
|
|
864
|
+
const result = await annotateReplayVideo(sourcePath, outputPath, {
|
|
865
|
+
annotations: input.annotations,
|
|
866
|
+
sourceWidth: input.source_width,
|
|
867
|
+
sourceHeight: input.source_height,
|
|
868
|
+
sourceLeftOffset: input.source_left_offset,
|
|
869
|
+
sourceTopOffset: input.source_top_offset
|
|
870
|
+
});
|
|
871
|
+
return textResult({
|
|
872
|
+
replay_id: input.replay_id,
|
|
873
|
+
source_file_path: sourcePath,
|
|
874
|
+
annotated_file_path: result.filePath,
|
|
875
|
+
bytes: result.bytes,
|
|
876
|
+
width: result.width,
|
|
877
|
+
height: result.height,
|
|
878
|
+
annotation_count: result.annotationCount,
|
|
879
|
+
mime_type: "video/mp4"
|
|
880
|
+
});
|
|
881
|
+
} catch (err) {
|
|
882
|
+
return textResult({ error: err instanceof Error ? err.message : String(err) }, true);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
);
|
|
453
886
|
server2.registerTool(
|
|
454
887
|
"browser_close",
|
|
455
888
|
{
|
|
@@ -483,12 +916,12 @@ function registerBrowserAgentMcpTools(server2, opts) {
|
|
|
483
916
|
// src/mcp/paa-mcp-server.ts
|
|
484
917
|
var import_mcp2 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
485
918
|
var import_node_fs3 = require("fs");
|
|
486
|
-
var
|
|
919
|
+
var import_node_path4 = require("path");
|
|
487
920
|
|
|
488
921
|
// src/mcp/mcp-response-formatter.ts
|
|
489
922
|
var import_node_fs2 = require("fs");
|
|
490
|
-
var
|
|
491
|
-
var
|
|
923
|
+
var import_node_os3 = require("os");
|
|
924
|
+
var import_node_path3 = require("path");
|
|
492
925
|
|
|
493
926
|
// src/errors.ts
|
|
494
927
|
function sanitizeVendorName(message) {
|
|
@@ -510,7 +943,7 @@ function reportTitle(full) {
|
|
|
510
943
|
return title?.replace(/^#\s+/, "").trim() || "MCP Scraper Report";
|
|
511
944
|
}
|
|
512
945
|
function outputBaseDir2() {
|
|
513
|
-
return process.env.MCP_SCRAPER_OUTPUT_DIR?.trim() || (0,
|
|
946
|
+
return process.env.MCP_SCRAPER_OUTPUT_DIR?.trim() || (0, import_node_path3.join)((0, import_node_os3.homedir)(), "Downloads", "mcp-scraper");
|
|
514
947
|
}
|
|
515
948
|
function saveFullReport(full) {
|
|
516
949
|
if (!reportSavingEnabled || process.env.MCP_SCRAPER_SAVE_REPORTS === "false") return null;
|
|
@@ -518,7 +951,7 @@ function saveFullReport(full) {
|
|
|
518
951
|
try {
|
|
519
952
|
(0, import_node_fs2.mkdirSync)(outDir, { recursive: true });
|
|
520
953
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
521
|
-
const file = (0,
|
|
954
|
+
const file = (0, import_node_path3.join)(outDir, `${stamp}-${slugifyReportName(reportTitle(full))}.md`);
|
|
522
955
|
(0, import_node_fs2.writeFileSync)(file, full, "utf8");
|
|
523
956
|
return file;
|
|
524
957
|
} catch {
|
|
@@ -528,11 +961,11 @@ function saveFullReport(full) {
|
|
|
528
961
|
function persistScreenshotLocally(base64, url) {
|
|
529
962
|
if (!reportSavingEnabled || process.env.MCP_SCRAPER_SAVE_REPORTS === "false") return null;
|
|
530
963
|
try {
|
|
531
|
-
const dir = (0,
|
|
964
|
+
const dir = (0, import_node_path3.join)(outputBaseDir2(), "screenshots");
|
|
532
965
|
(0, import_node_fs2.mkdirSync)(dir, { recursive: true });
|
|
533
966
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
534
967
|
const slug = url.replace(/^https?:\/\//, "").replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").slice(0, 60);
|
|
535
|
-
const filePath = (0,
|
|
968
|
+
const filePath = (0, import_node_path3.join)(dir, `${stamp}-${slug}.png`);
|
|
536
969
|
(0, import_node_fs2.writeFileSync)(filePath, Buffer.from(base64, "base64"));
|
|
537
970
|
return filePath;
|
|
538
971
|
} catch {
|
|
@@ -1513,6 +1946,24 @@ var DirectoryWorkflowInputSchema = {
|
|
|
1513
1946
|
proxyZip: import_zod2.z.string().regex(/^\d{5}$/).optional().describe("Optional ZIP override for proxy targeting. Normally omit it so each city can use its Lead Magician ZIP group or city/state location."),
|
|
1514
1947
|
debug: import_zod2.z.boolean().default(false).describe("Include sanitized browser/proxy diagnostics in each Maps browser session when supported.")
|
|
1515
1948
|
};
|
|
1949
|
+
var RankTrackerModeSchema = import_zod2.z.enum(["maps", "organic", "ai_overview", "paa"]);
|
|
1950
|
+
var RankTrackerBlueprintInputSchema = {
|
|
1951
|
+
projectName: import_zod2.z.string().min(1).optional().describe("Optional name for the rank tracker project, client, or campaign."),
|
|
1952
|
+
targetDomain: import_zod2.z.string().min(1).optional().describe("Primary domain to track in organic results, AI Overview citations, and PAA sources, e.g. example.com."),
|
|
1953
|
+
targetBusinessName: import_zod2.z.string().min(1).optional().describe("Primary Google Business Profile or brand/business name to match in Maps results. Required for reliable Maps rank tracking."),
|
|
1954
|
+
trackingModes: import_zod2.z.array(RankTrackerModeSchema).min(1).max(4).default(["maps", "organic", "ai_overview", "paa"]).describe("Rank tracker surfaces to build. maps uses directory_workflow/maps_search. organic uses search_serp. ai_overview uses search_serp/harvest_paa. paa uses harvest_paa source presence."),
|
|
1955
|
+
keywords: import_zod2.z.array(import_zod2.z.string().min(1)).max(200).default([]).describe("Seed keywords or service queries to track. Leave empty when the downstream AI should create the keyword table from user input."),
|
|
1956
|
+
locations: import_zod2.z.array(import_zod2.z.string().min(1)).max(100).default([]).describe("Markets, cities, ZIPs, or service areas to track. Use city/state strings like Denver, CO for localized SERP and Maps checks."),
|
|
1957
|
+
competitors: import_zod2.z.array(import_zod2.z.string().min(1)).max(100).default([]).describe("Optional competitor domains or business names to persist as comparison targets."),
|
|
1958
|
+
database: import_zod2.z.enum(["postgres", "neon", "supabase", "sqlite", "mysql"]).default("postgres").describe("Database family the downstream AI should target when generating migrations."),
|
|
1959
|
+
scheduleCadence: import_zod2.z.enum(["daily", "weekly", "monthly", "custom"]).default("weekly").describe("Default recurring rank check cadence for the generated cron/heartbeat plan."),
|
|
1960
|
+
customCron: import_zod2.z.string().min(1).optional().describe("Cron expression to use when scheduleCadence is custom."),
|
|
1961
|
+
timezone: import_zod2.z.string().min(1).default("UTC").describe("IANA timezone for scheduled rank checks, e.g. America/Denver."),
|
|
1962
|
+
includeCron: import_zod2.z.boolean().default(true).describe("Include a cron or heartbeat worker plan. Keep true for production rank trackers."),
|
|
1963
|
+
includeDashboard: import_zod2.z.boolean().default(true).describe("Include dashboard/reporting requirements in the generated prompt."),
|
|
1964
|
+
includeAlerts: import_zod2.z.boolean().default(true).describe("Include alert rules for rank movement and SERP feature gains/losses."),
|
|
1965
|
+
notes: import_zod2.z.string().max(4e3).optional().describe("Extra product, client, stack, or hosting requirements to include in the implementation prompt.")
|
|
1966
|
+
};
|
|
1516
1967
|
var NullableString = import_zod2.z.string().nullable();
|
|
1517
1968
|
var MapsSearchOutputSchema = {
|
|
1518
1969
|
query: import_zod2.z.string(),
|
|
@@ -1588,6 +2039,39 @@ var DirectoryWorkflowOutputSchema = {
|
|
|
1588
2039
|
})),
|
|
1589
2040
|
durationMs: import_zod2.z.number().int().min(0)
|
|
1590
2041
|
};
|
|
2042
|
+
var RankTrackerToolPlanOutput = import_zod2.z.object({
|
|
2043
|
+
tool: import_zod2.z.string(),
|
|
2044
|
+
purpose: import_zod2.z.string()
|
|
2045
|
+
});
|
|
2046
|
+
var RankTrackerTableOutput = import_zod2.z.object({
|
|
2047
|
+
name: import_zod2.z.string(),
|
|
2048
|
+
purpose: import_zod2.z.string(),
|
|
2049
|
+
keyColumns: import_zod2.z.array(import_zod2.z.string())
|
|
2050
|
+
});
|
|
2051
|
+
var RankTrackerCronJobOutput = import_zod2.z.object({
|
|
2052
|
+
name: import_zod2.z.string(),
|
|
2053
|
+
purpose: import_zod2.z.string(),
|
|
2054
|
+
modes: import_zod2.z.array(RankTrackerModeSchema),
|
|
2055
|
+
recommendedTools: import_zod2.z.array(import_zod2.z.string())
|
|
2056
|
+
});
|
|
2057
|
+
var RankTrackerBlueprintOutputSchema = {
|
|
2058
|
+
projectName: import_zod2.z.string(),
|
|
2059
|
+
targetDomain: NullableString,
|
|
2060
|
+
targetBusinessName: NullableString,
|
|
2061
|
+
trackingModes: import_zod2.z.array(RankTrackerModeSchema),
|
|
2062
|
+
database: import_zod2.z.string(),
|
|
2063
|
+
recommendedTools: import_zod2.z.array(RankTrackerToolPlanOutput),
|
|
2064
|
+
tables: import_zod2.z.array(RankTrackerTableOutput),
|
|
2065
|
+
cron: import_zod2.z.object({
|
|
2066
|
+
enabled: import_zod2.z.boolean(),
|
|
2067
|
+
cadence: import_zod2.z.string(),
|
|
2068
|
+
expression: import_zod2.z.string(),
|
|
2069
|
+
timezone: import_zod2.z.string(),
|
|
2070
|
+
jobs: import_zod2.z.array(RankTrackerCronJobOutput)
|
|
2071
|
+
}),
|
|
2072
|
+
metrics: import_zod2.z.array(import_zod2.z.string()),
|
|
2073
|
+
implementationPrompt: import_zod2.z.string()
|
|
2074
|
+
};
|
|
1591
2075
|
var OrganicResultOutput = import_zod2.z.object({
|
|
1592
2076
|
position: import_zod2.z.number().int(),
|
|
1593
2077
|
title: import_zod2.z.string(),
|
|
@@ -1802,6 +2286,329 @@ var CaptureSerpPageSnapshotsInputSchema = {
|
|
|
1802
2286
|
debug: import_zod2.z.boolean().default(false).describe("Include sanitized browser/proxy diagnostics for page snapshot debugging. Use true for capture, network, or proxy troubleshooting.")
|
|
1803
2287
|
};
|
|
1804
2288
|
|
|
2289
|
+
// src/mcp/rank-tracker-blueprint.ts
|
|
2290
|
+
var DEFAULT_MODES = ["maps", "organic", "ai_overview", "paa"];
|
|
2291
|
+
function normalizeDomain(value) {
|
|
2292
|
+
const trimmed = value?.trim();
|
|
2293
|
+
if (!trimmed) return null;
|
|
2294
|
+
try {
|
|
2295
|
+
const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
|
|
2296
|
+
return url.hostname.replace(/^www\./, "").toLowerCase();
|
|
2297
|
+
} catch {
|
|
2298
|
+
return trimmed.replace(/^https?:\/\//, "").replace(/^www\./, "").split("/")[0]?.toLowerCase() || trimmed;
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
function uniqueModes(input) {
|
|
2302
|
+
const modes = input?.length ? input : DEFAULT_MODES;
|
|
2303
|
+
return [...new Set(modes)];
|
|
2304
|
+
}
|
|
2305
|
+
function cronExpression(cadence, customCron) {
|
|
2306
|
+
if (cadence === "daily") return "15 6 * * *";
|
|
2307
|
+
if (cadence === "monthly") return "15 6 1 * *";
|
|
2308
|
+
if (cadence === "custom") return customCron?.trim() || "CUSTOM_CRON_EXPRESSION_REQUIRED";
|
|
2309
|
+
return "15 6 * * 1";
|
|
2310
|
+
}
|
|
2311
|
+
function includes(modes, mode) {
|
|
2312
|
+
return modes.includes(mode);
|
|
2313
|
+
}
|
|
2314
|
+
function buildToolPlan(modes) {
|
|
2315
|
+
const plans = [];
|
|
2316
|
+
const push = (tool, purpose) => {
|
|
2317
|
+
if (!plans.some((plan) => plan.tool === tool)) plans.push({ tool, purpose });
|
|
2318
|
+
};
|
|
2319
|
+
push("credits_info", "Check account balance and rates before scheduling recurring rank checks.");
|
|
2320
|
+
if (includes(modes, "maps")) {
|
|
2321
|
+
push("directory_workflow", "Seed or refresh city-by-city Maps datasets when building local market coverage.");
|
|
2322
|
+
push("maps_search", "Run recurring Google Maps category checks for each keyword and location.");
|
|
2323
|
+
push("maps_place_intel", "Hydrate selected target or competitor GBP details when profile-level evidence is needed.");
|
|
2324
|
+
}
|
|
2325
|
+
if (includes(modes, "organic")) {
|
|
2326
|
+
push("search_serp", "Capture localized organic rankings, local pack data, and AI Overview status without PAA expansion.");
|
|
2327
|
+
}
|
|
2328
|
+
if (includes(modes, "ai_overview")) {
|
|
2329
|
+
push("search_serp", "Capture quick AI Overview detection and organic context for each tracked query.");
|
|
2330
|
+
push("harvest_paa", "Use when AI Overview text, PAA context, or source-level evidence needs deeper capture.");
|
|
2331
|
+
}
|
|
2332
|
+
if (includes(modes, "paa")) {
|
|
2333
|
+
push("harvest_paa", "Capture People Also Ask questions, answers, and source sites for PAA presence tracking.");
|
|
2334
|
+
}
|
|
2335
|
+
return plans;
|
|
2336
|
+
}
|
|
2337
|
+
function buildTables(modes, includeDashboard, includeAlerts, hasCompetitors) {
|
|
2338
|
+
const tables = [
|
|
2339
|
+
{
|
|
2340
|
+
name: "rank_tracker_projects",
|
|
2341
|
+
purpose: "One row per client, campaign, or tracked property.",
|
|
2342
|
+
keyColumns: ["id", "name", "target_domain", "target_business_name", "database_mode", "timezone", "created_at"]
|
|
2343
|
+
},
|
|
2344
|
+
{
|
|
2345
|
+
name: "rank_tracker_targets",
|
|
2346
|
+
purpose: "Normalized target identities and match rules for domains, URLs, business names, CIDs, and competitors.",
|
|
2347
|
+
keyColumns: ["id", "project_id", "kind", "value", "display_name", "match_rules_json", "active"]
|
|
2348
|
+
},
|
|
2349
|
+
{
|
|
2350
|
+
name: "rank_tracker_keywords",
|
|
2351
|
+
purpose: "Tracked keywords or service queries, separated from location so calls can localize correctly.",
|
|
2352
|
+
keyColumns: ["id", "project_id", "keyword", "intent", "tags_json", "active"]
|
|
2353
|
+
},
|
|
2354
|
+
{
|
|
2355
|
+
name: "rank_tracker_locations",
|
|
2356
|
+
purpose: "Markets, cities, ZIPs, country/language settings, and proxy targeting hints.",
|
|
2357
|
+
keyColumns: ["id", "project_id", "label", "gl", "hl", "proxy_zip", "device", "active"]
|
|
2358
|
+
},
|
|
2359
|
+
{
|
|
2360
|
+
name: "rank_tracker_runs",
|
|
2361
|
+
purpose: "Heartbeat table for every scheduled or manual rank check batch.",
|
|
2362
|
+
keyColumns: ["id", "project_id", "scheduled_for", "started_at", "completed_at", "status", "idempotency_key", "error_message"]
|
|
2363
|
+
}
|
|
2364
|
+
];
|
|
2365
|
+
if (includes(modes, "organic")) {
|
|
2366
|
+
tables.push({
|
|
2367
|
+
name: "rank_tracker_organic_results",
|
|
2368
|
+
purpose: "SERP organic result rows from search_serp, including target-domain matches and competitor rows.",
|
|
2369
|
+
keyColumns: ["id", "run_id", "keyword_id", "location_id", "device", "position", "title", "url", "domain", "is_target"]
|
|
2370
|
+
});
|
|
2371
|
+
}
|
|
2372
|
+
if (includes(modes, "maps")) {
|
|
2373
|
+
tables.push({
|
|
2374
|
+
name: "rank_tracker_maps_results",
|
|
2375
|
+
purpose: "Google Maps result rows from maps_search and directory_workflow, including GBP identity fields.",
|
|
2376
|
+
keyColumns: ["id", "run_id", "keyword_id", "location_id", "position", "business_name", "place_url", "cid", "website_url", "rating", "review_count", "is_target"]
|
|
2377
|
+
});
|
|
2378
|
+
tables.push({
|
|
2379
|
+
name: "rank_tracker_market_seeds",
|
|
2380
|
+
purpose: "Directory workflow city, population, ZIP-group, and market seed records used to build local tracking coverage.",
|
|
2381
|
+
keyColumns: ["id", "project_id", "query", "city", "state", "population", "zip_group_json", "last_seeded_run_id"]
|
|
2382
|
+
});
|
|
2383
|
+
}
|
|
2384
|
+
if (includes(modes, "ai_overview")) {
|
|
2385
|
+
tables.push({
|
|
2386
|
+
name: "rank_tracker_ai_overviews",
|
|
2387
|
+
purpose: "AI Overview detection, text snapshot, citation domains, and target citation status per query/location.",
|
|
2388
|
+
keyColumns: ["id", "run_id", "keyword_id", "location_id", "detected", "target_cited", "cited_domains_json", "overview_text"]
|
|
2389
|
+
});
|
|
2390
|
+
}
|
|
2391
|
+
if (includes(modes, "paa")) {
|
|
2392
|
+
tables.push({
|
|
2393
|
+
name: "rank_tracker_paa_sources",
|
|
2394
|
+
purpose: "People Also Ask questions, answers, and source sites, with target source presence flags.",
|
|
2395
|
+
keyColumns: ["id", "run_id", "keyword_id", "location_id", "question", "answer", "source_title", "source_site", "target_present"]
|
|
2396
|
+
});
|
|
2397
|
+
}
|
|
2398
|
+
if (hasCompetitors) {
|
|
2399
|
+
tables.push({
|
|
2400
|
+
name: "rank_tracker_competitor_snapshots",
|
|
2401
|
+
purpose: "Per-run competitor visibility summaries across organic, Maps, AI Overview, and PAA surfaces.",
|
|
2402
|
+
keyColumns: ["id", "run_id", "target_id", "surface", "best_position", "presence_count", "share_of_surface"]
|
|
2403
|
+
});
|
|
2404
|
+
}
|
|
2405
|
+
if (includeDashboard) {
|
|
2406
|
+
tables.push({
|
|
2407
|
+
name: "rank_tracker_daily_metrics",
|
|
2408
|
+
purpose: "Rollup table for dashboard charts, trend lines, and share-of-visibility summaries.",
|
|
2409
|
+
keyColumns: ["id", "project_id", "metric_date", "keyword_id", "location_id", "surface", "metric_name", "metric_value"]
|
|
2410
|
+
});
|
|
2411
|
+
}
|
|
2412
|
+
if (includeAlerts) {
|
|
2413
|
+
tables.push({
|
|
2414
|
+
name: "rank_tracker_alert_events",
|
|
2415
|
+
purpose: "Deduplicated gain/loss events for rank movement, Maps top 3, AI Overview citations, and PAA source presence.",
|
|
2416
|
+
keyColumns: ["id", "project_id", "run_id", "surface", "severity", "event_key", "message", "created_at", "acknowledged_at"]
|
|
2417
|
+
});
|
|
2418
|
+
}
|
|
2419
|
+
return tables;
|
|
2420
|
+
}
|
|
2421
|
+
function buildCronJobs(modes, includeDashboard, includeAlerts) {
|
|
2422
|
+
const jobs = [
|
|
2423
|
+
{
|
|
2424
|
+
name: "rank-tracker-dispatch",
|
|
2425
|
+
purpose: "Find due keyword/location/mode combinations, create a rank_tracker_runs row, and enqueue idempotent work items.",
|
|
2426
|
+
modes,
|
|
2427
|
+
recommendedTools: ["credits_info"]
|
|
2428
|
+
}
|
|
2429
|
+
];
|
|
2430
|
+
if (includes(modes, "maps")) {
|
|
2431
|
+
jobs.push({
|
|
2432
|
+
name: "maps-rank-check",
|
|
2433
|
+
purpose: "Run maps_search per keyword/location and optionally directory_workflow for market seed refreshes.",
|
|
2434
|
+
modes: ["maps"],
|
|
2435
|
+
recommendedTools: ["maps_search", "directory_workflow", "maps_place_intel"]
|
|
2436
|
+
});
|
|
2437
|
+
}
|
|
2438
|
+
if (includes(modes, "organic") || includes(modes, "ai_overview")) {
|
|
2439
|
+
jobs.push({
|
|
2440
|
+
name: "serp-rank-check",
|
|
2441
|
+
purpose: "Run search_serp per keyword/location/device and persist organic ranks, local pack, and AI Overview status.",
|
|
2442
|
+
modes: modes.filter((mode) => mode === "organic" || mode === "ai_overview"),
|
|
2443
|
+
recommendedTools: ["search_serp"]
|
|
2444
|
+
});
|
|
2445
|
+
}
|
|
2446
|
+
if (includes(modes, "paa") || includes(modes, "ai_overview")) {
|
|
2447
|
+
jobs.push({
|
|
2448
|
+
name: "paa-ai-evidence-check",
|
|
2449
|
+
purpose: "Run harvest_paa for PAA source presence and deeper AI Overview/PAA evidence where needed.",
|
|
2450
|
+
modes: modes.filter((mode) => mode === "paa" || mode === "ai_overview"),
|
|
2451
|
+
recommendedTools: ["harvest_paa"]
|
|
2452
|
+
});
|
|
2453
|
+
}
|
|
2454
|
+
if (includeDashboard) {
|
|
2455
|
+
jobs.push({
|
|
2456
|
+
name: "rank-tracker-rollups",
|
|
2457
|
+
purpose: "Aggregate raw rows into daily metrics for charts, comparison tables, and trend lines.",
|
|
2458
|
+
modes,
|
|
2459
|
+
recommendedTools: []
|
|
2460
|
+
});
|
|
2461
|
+
}
|
|
2462
|
+
if (includeAlerts) {
|
|
2463
|
+
jobs.push({
|
|
2464
|
+
name: "rank-tracker-alerts",
|
|
2465
|
+
purpose: "Compare latest run against prior snapshots and emit deduplicated visibility gain/loss events.",
|
|
2466
|
+
modes,
|
|
2467
|
+
recommendedTools: []
|
|
2468
|
+
});
|
|
2469
|
+
}
|
|
2470
|
+
return jobs;
|
|
2471
|
+
}
|
|
2472
|
+
function buildMetrics(modes, includeDashboard, includeAlerts) {
|
|
2473
|
+
const metrics = ["run_success_rate", "last_successful_run_at", "stale_keyword_location_count"];
|
|
2474
|
+
if (includes(modes, "maps")) {
|
|
2475
|
+
metrics.push("maps_best_position", "maps_top_3_presence", "maps_target_found", "maps_competitor_count_above_target");
|
|
2476
|
+
}
|
|
2477
|
+
if (includes(modes, "organic")) {
|
|
2478
|
+
metrics.push("organic_best_position", "organic_top_3_presence", "organic_top_10_presence", "organic_best_url");
|
|
2479
|
+
}
|
|
2480
|
+
if (includes(modes, "ai_overview")) {
|
|
2481
|
+
metrics.push("ai_overview_detected", "target_ai_overview_cited", "ai_overview_citation_domain_count");
|
|
2482
|
+
}
|
|
2483
|
+
if (includes(modes, "paa")) {
|
|
2484
|
+
metrics.push("paa_question_count", "target_paa_source_count", "paa_presence_rate", "paa_source_domains");
|
|
2485
|
+
}
|
|
2486
|
+
if (includeDashboard) metrics.push("share_of_visibility", "position_delta_7d", "position_delta_30d");
|
|
2487
|
+
if (includeAlerts) metrics.push("visibility_gain_events", "visibility_loss_events");
|
|
2488
|
+
return metrics;
|
|
2489
|
+
}
|
|
2490
|
+
function listOrPlaceholder(values, fallback) {
|
|
2491
|
+
const clean = values?.map((value) => value.trim()).filter(Boolean) ?? [];
|
|
2492
|
+
if (!clean.length) return fallback;
|
|
2493
|
+
return clean.map((value) => `- ${value}`).join("\n");
|
|
2494
|
+
}
|
|
2495
|
+
function buildPrompt(input, modes, tools, tables, jobs, metrics, expression, targetDomain, targetBusinessName) {
|
|
2496
|
+
const keywords = listOrPlaceholder(input.keywords, "- TODO: add tracked keywords");
|
|
2497
|
+
const locations = listOrPlaceholder(input.locations, "- TODO: add tracked markets/locations");
|
|
2498
|
+
const competitors = listOrPlaceholder(input.competitors, "- Optional: add competitor domains or GBP names");
|
|
2499
|
+
const notes = input.notes?.trim() || "No extra implementation notes.";
|
|
2500
|
+
return [
|
|
2501
|
+
"You are building a production rank tracker powered by MCP Scraper. Create the database migrations, scheduled worker, ingestion functions, and dashboard/query layer described below.",
|
|
2502
|
+
"",
|
|
2503
|
+
`Project: ${input.projectName || "MCP Scraper Rank Tracker"}`,
|
|
2504
|
+
`Target domain: ${targetDomain || "TODO_TARGET_DOMAIN"}`,
|
|
2505
|
+
`Target business/GBP name: ${targetBusinessName || "TODO_TARGET_BUSINESS_NAME"}`,
|
|
2506
|
+
`Database target: ${input.database || "postgres"}`,
|
|
2507
|
+
`Tracking modes: ${modes.join(", ")}`,
|
|
2508
|
+
"",
|
|
2509
|
+
"Seed keywords:",
|
|
2510
|
+
keywords,
|
|
2511
|
+
"",
|
|
2512
|
+
"Seed locations:",
|
|
2513
|
+
locations,
|
|
2514
|
+
"",
|
|
2515
|
+
"Competitors:",
|
|
2516
|
+
competitors,
|
|
2517
|
+
"",
|
|
2518
|
+
"MCP Scraper calls to use:",
|
|
2519
|
+
tools.map((tool) => `- ${tool.tool}: ${tool.purpose}`).join("\n"),
|
|
2520
|
+
"",
|
|
2521
|
+
"Database tables to create:",
|
|
2522
|
+
tables.map((table) => `- ${table.name}: ${table.keyColumns.join(", ")}`).join("\n"),
|
|
2523
|
+
"",
|
|
2524
|
+
"Scheduler and heartbeat requirements:",
|
|
2525
|
+
`- Use cron expression "${expression}" in timezone "${input.timezone || "UTC"}" unless the user changes cadence.`,
|
|
2526
|
+
"- Every scheduled batch must insert or update rank_tracker_runs before calling MCP tools.",
|
|
2527
|
+
"- Use idempotency_key = project_id + keyword_id + location_id + mode + device + scheduled_date.",
|
|
2528
|
+
"- Mark runs as running, succeeded, failed, or skipped; persist error_message on failures.",
|
|
2529
|
+
"- Run MCP calls sequentially by default because accounts have one concurrency slot unless the user bought more.",
|
|
2530
|
+
"- Retry retryable upstream failures with backoff, but do not duplicate rows for the same idempotency key.",
|
|
2531
|
+
"",
|
|
2532
|
+
"Mode-specific ingestion rules:",
|
|
2533
|
+
"- maps: Use directory_workflow to seed/refresh market coverage and maps_search for recurring keyword/location rank checks. Match the target by targetBusinessName, website domain, CID, and place URL when available.",
|
|
2534
|
+
"- organic: Use search_serp and persist every organic result row. Compute the best targetDomain position and best ranking URL per keyword/location/device.",
|
|
2535
|
+
"- ai_overview: Use search_serp for quick detection and harvest_paa when deeper text/source evidence is needed. Track detected, overview_text, cited domains, and whether targetDomain is cited.",
|
|
2536
|
+
"- paa: Use harvest_paa and persist each question/source pair. Track whether sourceSite or answer/source evidence matches targetDomain, then compute PAA presence rate.",
|
|
2537
|
+
"",
|
|
2538
|
+
"Metrics to expose:",
|
|
2539
|
+
metrics.map((metric) => `- ${metric}`).join("\n"),
|
|
2540
|
+
"",
|
|
2541
|
+
"Alert requirements:",
|
|
2542
|
+
input.includeAlerts ? "- Emit deduplicated alert_events when target gains/loses Maps top 3, organic top 10, AI Overview citation, or PAA source presence." : "- Alerts are out of scope for this build unless the user asks for them.",
|
|
2543
|
+
"",
|
|
2544
|
+
"Dashboard requirements:",
|
|
2545
|
+
input.includeDashboard ? "- Build views for latest position by keyword/location, trend deltas, competitors above target, AI Overview citations, and PAA source wins/losses." : "- Dashboard is out of scope for this build unless the user asks for it.",
|
|
2546
|
+
"",
|
|
2547
|
+
"Extra notes:",
|
|
2548
|
+
notes
|
|
2549
|
+
].join("\n");
|
|
2550
|
+
}
|
|
2551
|
+
function buildRankTrackerBlueprint(input) {
|
|
2552
|
+
const trackingModes = uniqueModes(input.trackingModes);
|
|
2553
|
+
const targetDomain = normalizeDomain(input.targetDomain);
|
|
2554
|
+
const targetBusinessName = input.targetBusinessName?.trim() || null;
|
|
2555
|
+
const projectName = input.projectName?.trim() || "MCP Scraper Rank Tracker";
|
|
2556
|
+
const database = input.database || "postgres";
|
|
2557
|
+
const expression = cronExpression(input.scheduleCadence || "weekly", input.customCron);
|
|
2558
|
+
const tools = buildToolPlan(trackingModes);
|
|
2559
|
+
const tables = buildTables(trackingModes, input.includeDashboard !== false, input.includeAlerts !== false, Boolean(input.competitors?.length));
|
|
2560
|
+
const jobs = input.includeCron === false ? [] : buildCronJobs(trackingModes, input.includeDashboard !== false, input.includeAlerts !== false);
|
|
2561
|
+
const metrics = buildMetrics(trackingModes, input.includeDashboard !== false, input.includeAlerts !== false);
|
|
2562
|
+
const implementationPrompt = buildPrompt(input, trackingModes, tools, tables, jobs, metrics, expression, targetDomain, targetBusinessName);
|
|
2563
|
+
const blueprint = {
|
|
2564
|
+
projectName,
|
|
2565
|
+
targetDomain,
|
|
2566
|
+
targetBusinessName,
|
|
2567
|
+
trackingModes,
|
|
2568
|
+
database,
|
|
2569
|
+
recommendedTools: tools,
|
|
2570
|
+
tables,
|
|
2571
|
+
cron: {
|
|
2572
|
+
enabled: input.includeCron !== false,
|
|
2573
|
+
cadence: input.scheduleCadence || "weekly",
|
|
2574
|
+
expression: input.includeCron === false ? "disabled" : expression,
|
|
2575
|
+
timezone: input.timezone || "UTC",
|
|
2576
|
+
jobs
|
|
2577
|
+
},
|
|
2578
|
+
metrics,
|
|
2579
|
+
implementationPrompt
|
|
2580
|
+
};
|
|
2581
|
+
const text = [
|
|
2582
|
+
`# ${projectName}`,
|
|
2583
|
+
"",
|
|
2584
|
+
"Rank tracker build blueprint generated from MCP Scraper tool capabilities.",
|
|
2585
|
+
"",
|
|
2586
|
+
"## Modes",
|
|
2587
|
+
trackingModes.map((mode) => `- ${mode}`).join("\n"),
|
|
2588
|
+
"",
|
|
2589
|
+
"## Recommended MCP Tools",
|
|
2590
|
+
tools.map((tool) => `- \`${tool.tool}\` - ${tool.purpose}`).join("\n"),
|
|
2591
|
+
"",
|
|
2592
|
+
"## Database Tables",
|
|
2593
|
+
tables.map((table) => `- \`${table.name}\` - ${table.purpose}`).join("\n"),
|
|
2594
|
+
"",
|
|
2595
|
+
"## Cron / Heartbeat",
|
|
2596
|
+
blueprint.cron.enabled ? `- Cadence: ${blueprint.cron.cadence}
|
|
2597
|
+
- Cron: \`${blueprint.cron.expression}\`
|
|
2598
|
+
- Timezone: ${blueprint.cron.timezone}
|
|
2599
|
+
- Jobs: ${jobs.map((job) => job.name).join(", ")}` : "- Disabled by input. Still create rank_tracker_runs for manual runs.",
|
|
2600
|
+
"",
|
|
2601
|
+
"## Implementation Prompt",
|
|
2602
|
+
"```text",
|
|
2603
|
+
implementationPrompt,
|
|
2604
|
+
"```"
|
|
2605
|
+
].join("\n");
|
|
2606
|
+
return {
|
|
2607
|
+
content: [{ type: "text", text }],
|
|
2608
|
+
structuredContent: blueprint
|
|
2609
|
+
};
|
|
2610
|
+
}
|
|
2611
|
+
|
|
1805
2612
|
// src/mcp/paa-mcp-server.ts
|
|
1806
2613
|
function liveWebToolAnnotations(title) {
|
|
1807
2614
|
return {
|
|
@@ -1812,10 +2619,19 @@ function liveWebToolAnnotations(title) {
|
|
|
1812
2619
|
openWorldHint: true
|
|
1813
2620
|
};
|
|
1814
2621
|
}
|
|
2622
|
+
function localPlanningToolAnnotations(title) {
|
|
2623
|
+
return {
|
|
2624
|
+
title,
|
|
2625
|
+
readOnlyHint: true,
|
|
2626
|
+
destructiveHint: false,
|
|
2627
|
+
idempotentHint: true,
|
|
2628
|
+
openWorldHint: false
|
|
2629
|
+
};
|
|
2630
|
+
}
|
|
1815
2631
|
function listSavedReports() {
|
|
1816
2632
|
try {
|
|
1817
2633
|
const dir = outputBaseDir2();
|
|
1818
|
-
return (0, import_node_fs3.readdirSync)(dir).filter((f) => f.endsWith(".md")).map((f) => ({ filename: f, mtimeMs: (0, import_node_fs3.statSync)((0,
|
|
2634
|
+
return (0, import_node_fs3.readdirSync)(dir).filter((f) => f.endsWith(".md")).map((f) => ({ filename: f, mtimeMs: (0, import_node_fs3.statSync)((0, import_node_path4.join)(dir, f)).mtimeMs })).sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, 100);
|
|
1819
2635
|
} catch {
|
|
1820
2636
|
return [];
|
|
1821
2637
|
}
|
|
@@ -1839,9 +2655,9 @@ function registerSavedReportResources(server2) {
|
|
|
1839
2655
|
},
|
|
1840
2656
|
async (uri, variables) => {
|
|
1841
2657
|
const requested = Array.isArray(variables.filename) ? variables.filename[0] : variables.filename;
|
|
1842
|
-
const filename = (0,
|
|
2658
|
+
const filename = (0, import_node_path4.basename)(decodeURIComponent(String(requested ?? "")));
|
|
1843
2659
|
if (!filename.endsWith(".md")) throw new Error("Only saved .md reports can be read");
|
|
1844
|
-
const text = (0, import_node_fs3.readFileSync)((0,
|
|
2660
|
+
const text = (0, import_node_fs3.readFileSync)((0, import_node_path4.join)(outputBaseDir2(), filename), "utf8");
|
|
1845
2661
|
return { contents: [{ uri: uri.href, mimeType: "text/markdown", text }] };
|
|
1846
2662
|
}
|
|
1847
2663
|
);
|
|
@@ -1940,6 +2756,13 @@ function registerPaaExtractorMcpTools(server2, executor, options = {}) {
|
|
|
1940
2756
|
outputSchema: DirectoryWorkflowOutputSchema,
|
|
1941
2757
|
annotations: liveWebToolAnnotations("Directory Workflow: Markets + Maps")
|
|
1942
2758
|
}, async (input) => formatDirectoryWorkflow(await executor.directoryWorkflow(input), input));
|
|
2759
|
+
server2.registerTool("rank_tracker_blueprint", {
|
|
2760
|
+
title: "Rank Tracker Blueprint Builder",
|
|
2761
|
+
description: "Generate a build-ready database, cron/heartbeat, ingestion, metrics, and AI implementation prompt for a rank tracker powered by MCP Scraper. Supports Maps rankings through directory_workflow/maps_search, organic rankings through search_serp, AI Overview citation tracking, and People Also Ask source presence tracking. This tool is local planning only; it does not call the web or spend credits.",
|
|
2762
|
+
inputSchema: RankTrackerBlueprintInputSchema,
|
|
2763
|
+
outputSchema: RankTrackerBlueprintOutputSchema,
|
|
2764
|
+
annotations: localPlanningToolAnnotations("Rank Tracker Blueprint Builder")
|
|
2765
|
+
}, async (input) => buildRankTrackerBlueprint(input));
|
|
1943
2766
|
server2.registerTool("credits_info", {
|
|
1944
2767
|
title: "MCP Scraper Credits & Costs",
|
|
1945
2768
|
description: "Answer questions about MCP Scraper credits: current credit balance, what a specific tool/action costs, the full cost table, and optionally recent credit ledger entries. Does not expose payment methods or credit card information.",
|
|
@@ -1958,7 +2781,7 @@ function registerPaaExtractorMcpTools(server2, executor, options = {}) {
|
|
|
1958
2781
|
// bin/mcp-scraper-combined-stdio-server.ts
|
|
1959
2782
|
function readApiKeyFile() {
|
|
1960
2783
|
const explicitPath = process.env.MCP_SCRAPER_KEY_PATH?.trim();
|
|
1961
|
-
const paths = [explicitPath, (0,
|
|
2784
|
+
const paths = [explicitPath, (0, import_node_path5.join)((0, import_node_os4.homedir)(), ".mcp-scraper-key")].filter(Boolean);
|
|
1962
2785
|
for (const path of paths) {
|
|
1963
2786
|
try {
|
|
1964
2787
|
const value = (0, import_node_fs4.readFileSync)(path, "utf8").trim();
|