mcp-scraper 0.3.14 → 0.3.16
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/dist/bin/api-server.cjs +2261 -511
- package/dist/bin/api-server.cjs.map +1 -1
- package/dist/bin/api-server.js +3 -3
- package/dist/bin/browser-agent-stdio-server.cjs +6 -6
- 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-cli.cjs +1 -1
- package/dist/bin/mcp-scraper-cli.cjs.map +1 -1
- package/dist/bin/mcp-scraper-cli.js +1 -1
- package/dist/bin/mcp-scraper-combined-stdio-server.cjs +663 -48
- package/dist/bin/mcp-scraper-combined-stdio-server.cjs.map +1 -1
- package/dist/bin/mcp-scraper-combined-stdio-server.js +6 -5
- package/dist/bin/mcp-scraper-combined-stdio-server.js.map +1 -1
- package/dist/bin/mcp-scraper-install.cjs +4 -4
- package/dist/bin/mcp-scraper-install.cjs.map +1 -1
- package/dist/bin/mcp-scraper-install.js +2 -2
- package/dist/bin/mcp-stdio-server.cjs +655 -40
- package/dist/bin/mcp-stdio-server.cjs.map +1 -1
- package/dist/bin/mcp-stdio-server.js +2 -2
- package/dist/{chunk-DBQDG7EH.js → chunk-5H22TOXQ.js} +28 -1
- package/dist/chunk-5H22TOXQ.js.map +1 -0
- package/dist/{chunk-HM6FHV5U.js → chunk-7XARJHS6.js} +662 -41
- package/dist/chunk-7XARJHS6.js.map +1 -0
- package/dist/{chunk-KPXMPAJ3.js → chunk-ACIUCZ27.js} +4 -4
- package/dist/chunk-ACIUCZ27.js.map +1 -0
- package/dist/{chunk-AX7UBYLG.js → chunk-ISZWGIWL.js} +7 -7
- package/dist/chunk-ISZWGIWL.js.map +1 -0
- package/dist/chunk-SOMBZK3V.js +7 -0
- package/dist/chunk-SOMBZK3V.js.map +1 -0
- package/dist/{chunk-AZ5PKU4F.js → chunk-ZAUMSBV3.js} +2 -2
- package/dist/{db-BE4JVB3V.js → db-P76GVIFN.js} +2 -2
- package/dist/{server-ILIVSPNY.js → server-LPVBSE2E.js} +1133 -93
- package/dist/server-LPVBSE2E.js.map +1 -0
- package/dist/{worker-FG7ZWEGA.js → worker-2WVKKCC7.js} +3 -3
- package/docs/final-tooling-spec.md +206 -0
- package/docs/mcp-tool-design-guide.md +225 -0
- package/docs/mcp-tool-manifest.generated.json +57 -22
- package/docs/seo-crawl-report-spec.md +287 -0
- package/docs/tool-catalog-spec.md +388 -0
- package/package.json +2 -1
- package/dist/chunk-3QHZPR4U.js +0 -7
- package/dist/chunk-3QHZPR4U.js.map +0 -1
- package/dist/chunk-AX7UBYLG.js.map +0 -1
- package/dist/chunk-DBQDG7EH.js.map +0 -1
- package/dist/chunk-HM6FHV5U.js.map +0 -1
- package/dist/chunk-KPXMPAJ3.js.map +0 -1
- package/dist/server-ILIVSPNY.js.map +0 -1
- /package/dist/{chunk-AZ5PKU4F.js.map → chunk-ZAUMSBV3.js.map} +0 -0
- /package/dist/{db-BE4JVB3V.js.map → db-P76GVIFN.js.map} +0 -0
- /package/dist/{worker-FG7ZWEGA.js.map → worker-2WVKKCC7.js.map} +0 -0
|
@@ -209,6 +209,9 @@ var HttpMcpToolExecutor = class {
|
|
|
209
209
|
extractSite(input) {
|
|
210
210
|
return this.call("/extract-site", input);
|
|
211
211
|
}
|
|
212
|
+
auditSite(input) {
|
|
213
|
+
return this.call("/extract-site", input);
|
|
214
|
+
}
|
|
212
215
|
youtubeHarvest(input) {
|
|
213
216
|
return this.call("/youtube/harvest", input);
|
|
214
217
|
}
|
|
@@ -300,7 +303,7 @@ var import_node_fs2 = require("fs");
|
|
|
300
303
|
var import_node_path2 = require("path");
|
|
301
304
|
|
|
302
305
|
// src/version.ts
|
|
303
|
-
var PACKAGE_VERSION = "0.3.
|
|
306
|
+
var PACKAGE_VERSION = "0.3.16";
|
|
304
307
|
|
|
305
308
|
// src/mcp/mcp-response-formatter.ts
|
|
306
309
|
var import_node_fs = require("fs");
|
|
@@ -427,6 +430,322 @@ function suggestWorkflowRecipes(goal, limit = 3) {
|
|
|
427
430
|
return scored.sort((a, b) => b.score - a.score || a.recipe.title.localeCompare(b.recipe.title)).slice(0, Math.max(1, limit)).map((item) => item.recipe);
|
|
428
431
|
}
|
|
429
432
|
|
|
433
|
+
// src/api/seo-link-graph.ts
|
|
434
|
+
function normalize2(u) {
|
|
435
|
+
return u.split("#")[0].replace(/\/$/, "");
|
|
436
|
+
}
|
|
437
|
+
function buildLinkGraph(pages, startUrl) {
|
|
438
|
+
const statusByUrl = /* @__PURE__ */ new Map();
|
|
439
|
+
for (const p of pages) statusByUrl.set(normalize2(p.url), p.status);
|
|
440
|
+
const edges = [];
|
|
441
|
+
for (const p of pages) {
|
|
442
|
+
for (const l of p.outlinks ?? []) {
|
|
443
|
+
const nofollow = (l.rel ?? "").split(/\s+/).includes("nofollow");
|
|
444
|
+
edges.push({
|
|
445
|
+
from: p.url,
|
|
446
|
+
to: l.href,
|
|
447
|
+
anchor: l.anchor,
|
|
448
|
+
rel: l.rel,
|
|
449
|
+
internal: l.internal,
|
|
450
|
+
nofollow,
|
|
451
|
+
targetStatus: statusByUrl.get(normalize2(l.href)) ?? null
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const inboundFrom = /* @__PURE__ */ new Map();
|
|
456
|
+
const inboundAnchors = /* @__PURE__ */ new Map();
|
|
457
|
+
const adjacency = /* @__PURE__ */ new Map();
|
|
458
|
+
for (const e of edges) {
|
|
459
|
+
if (!e.internal) continue;
|
|
460
|
+
const to = normalize2(e.to);
|
|
461
|
+
const from = normalize2(e.from);
|
|
462
|
+
if (!inboundFrom.has(to)) inboundFrom.set(to, /* @__PURE__ */ new Set());
|
|
463
|
+
inboundFrom.get(to).add(from);
|
|
464
|
+
if (e.anchor) {
|
|
465
|
+
if (!inboundAnchors.has(to)) inboundAnchors.set(to, []);
|
|
466
|
+
inboundAnchors.get(to).push(e.anchor);
|
|
467
|
+
}
|
|
468
|
+
if (!e.nofollow && (statusByUrl.get(to) === 200 || !statusByUrl.has(to))) {
|
|
469
|
+
if (!adjacency.has(from)) adjacency.set(from, /* @__PURE__ */ new Set());
|
|
470
|
+
adjacency.get(from).add(to);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const depth = /* @__PURE__ */ new Map();
|
|
474
|
+
const start = normalize2(startUrl);
|
|
475
|
+
const queue = [start];
|
|
476
|
+
depth.set(start, 0);
|
|
477
|
+
while (queue.length > 0) {
|
|
478
|
+
const cur = queue.shift();
|
|
479
|
+
const d = depth.get(cur);
|
|
480
|
+
for (const next of adjacency.get(cur) ?? []) {
|
|
481
|
+
if (!depth.has(next)) {
|
|
482
|
+
depth.set(next, d + 1);
|
|
483
|
+
queue.push(next);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
const topAnchorsFor = (url) => {
|
|
488
|
+
const counts = /* @__PURE__ */ new Map();
|
|
489
|
+
for (const a of inboundAnchors.get(url) ?? []) counts.set(a, (counts.get(a) ?? 0) + 1);
|
|
490
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map((e) => e[0]);
|
|
491
|
+
};
|
|
492
|
+
const metrics = /* @__PURE__ */ new Map();
|
|
493
|
+
for (const p of pages) {
|
|
494
|
+
const key = normalize2(p.url);
|
|
495
|
+
const inbound = inboundFrom.get(key) ?? /* @__PURE__ */ new Set();
|
|
496
|
+
metrics.set(p.url, {
|
|
497
|
+
url: p.url,
|
|
498
|
+
inlinks: inbound.size,
|
|
499
|
+
uniqueInlinks: inbound.size,
|
|
500
|
+
outlinksInternal: (p.outlinks ?? []).filter((l) => l.internal).length,
|
|
501
|
+
outlinksExternal: (p.outlinks ?? []).filter((l) => !l.internal).length,
|
|
502
|
+
crawlDepth: depth.has(key) ? depth.get(key) : null,
|
|
503
|
+
orphan: inbound.size === 0 && key !== start,
|
|
504
|
+
topAnchors: topAnchorsFor(key)
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
return { edges, metrics };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// src/api/seo-issues.ts
|
|
511
|
+
var THIN_WORDS = 200;
|
|
512
|
+
var TITLE_MAX = 60;
|
|
513
|
+
var TITLE_PX_MAX = 561;
|
|
514
|
+
var TITLE_MIN = 30;
|
|
515
|
+
var META_MAX = 155;
|
|
516
|
+
var META_MIN = 70;
|
|
517
|
+
var H1_MAX = 70;
|
|
518
|
+
var URL_MAX = 115;
|
|
519
|
+
function dupes(pages, key) {
|
|
520
|
+
const groups = /* @__PURE__ */ new Map();
|
|
521
|
+
for (const p of pages) {
|
|
522
|
+
const k = key(p);
|
|
523
|
+
if (k === null || k === void 0 || k === "") continue;
|
|
524
|
+
const ks = String(k);
|
|
525
|
+
if (!groups.has(ks)) groups.set(ks, []);
|
|
526
|
+
groups.get(ks).push(p.url);
|
|
527
|
+
}
|
|
528
|
+
return [...groups.values()].filter((g) => g.length > 1).flat();
|
|
529
|
+
}
|
|
530
|
+
function computeIssues(pages, metrics) {
|
|
531
|
+
const group = (urls) => ({ count: urls.length, urls: urls.slice(0, 200) });
|
|
532
|
+
const where = (fn) => pages.filter(fn).map((p) => p.url);
|
|
533
|
+
const ok = (p) => p.status === 200;
|
|
534
|
+
const pathname = (u) => {
|
|
535
|
+
try {
|
|
536
|
+
return new URL(u).pathname;
|
|
537
|
+
} catch {
|
|
538
|
+
return "";
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
const report = {
|
|
542
|
+
"title.missing": group(where((p) => ok(p) && !p.title)),
|
|
543
|
+
"title.duplicate": group(dupes(pages.filter(ok), (p) => p.title)),
|
|
544
|
+
"title.tooLong": group(where((p) => (p.titleLength ?? 0) > TITLE_MAX || (p.titlePixels ?? 0) > TITLE_PX_MAX)),
|
|
545
|
+
"title.tooShort": group(where((p) => p.title != null && (p.titleLength ?? 0) < TITLE_MIN)),
|
|
546
|
+
"title.sameAsH1": group(where((p) => !!p.title && !!p.h1 && p.title.trim() === p.h1.trim())),
|
|
547
|
+
"meta.missing": group(where((p) => ok(p) && !p.metaDescription)),
|
|
548
|
+
"meta.duplicate": group(dupes(pages.filter(ok), (p) => p.metaDescription)),
|
|
549
|
+
"meta.tooLong": group(where((p) => (p.metaDescLength ?? 0) > META_MAX)),
|
|
550
|
+
"meta.tooShort": group(where((p) => p.metaDescription != null && (p.metaDescLength ?? 0) < META_MIN)),
|
|
551
|
+
"h1.missing": group(where((p) => ok(p) && !p.h1)),
|
|
552
|
+
"h1.multiple": group(where((p) => !!p.h1_2)),
|
|
553
|
+
"h1.duplicate": group(dupes(pages.filter(ok), (p) => p.h1)),
|
|
554
|
+
"h1.tooLong": group(where((p) => (p.h1?.length ?? 0) > H1_MAX)),
|
|
555
|
+
"h2.missing": group(where((p) => ok(p) && p.h2Count === 0)),
|
|
556
|
+
"indexability.nonIndexable": group(where((p) => !p.indexable)),
|
|
557
|
+
"indexability.noindex": group(where((p) => p.indexabilityReason === "noindex")),
|
|
558
|
+
"canonical.missing": group(where((p) => ok(p) && !p.canonicalUrl)),
|
|
559
|
+
"canonical.canonicalised": group(where((p) => p.indexabilityReason === "canonicalised")),
|
|
560
|
+
"response.broken4xx": group(where((p) => (p.status ?? 0) >= 400 && (p.status ?? 0) < 500)),
|
|
561
|
+
"response.error5xx": group(where((p) => (p.status ?? 0) >= 500)),
|
|
562
|
+
"response.redirect3xx": group(where((p) => (p.status ?? 0) >= 300 && (p.status ?? 0) < 400)),
|
|
563
|
+
"response.noResponse": group(where((p) => p.status === null)),
|
|
564
|
+
"content.thin": group(where((p) => ok(p) && p.wordCount > 0 && p.wordCount < THIN_WORDS)),
|
|
565
|
+
"content.exactDuplicate": group(dupes(pages.filter((p) => ok(p) && p.contentHash), (p) => p.contentHash)),
|
|
566
|
+
"images.missingAlt": group(where((p) => p.imagesMissingAlt > 0)),
|
|
567
|
+
"schema.missing": group(where((p) => ok(p) && p.schemaTypes.length === 0)),
|
|
568
|
+
"url.tooLong": group(where((p) => p.url.length > URL_MAX)),
|
|
569
|
+
"url.uppercase": group(where((p) => /[A-Z]/.test(pathname(p.url)))),
|
|
570
|
+
"url.underscores": group(where((p) => pathname(p.url).includes("_"))),
|
|
571
|
+
"links.orphan": group([...metrics.values()].filter((m) => m.orphan).map((m) => m.url))
|
|
572
|
+
};
|
|
573
|
+
const normUrl = (u) => {
|
|
574
|
+
try {
|
|
575
|
+
const x = new URL(u);
|
|
576
|
+
x.hash = "";
|
|
577
|
+
return (x.origin + x.pathname).replace(/\/+$/, "") + x.search;
|
|
578
|
+
} catch {
|
|
579
|
+
return u.replace(/#.*$/, "").replace(/\/+$/, "");
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
const statusByUrl = new Map(pages.map((p) => [normUrl(p.url), p.status]));
|
|
583
|
+
const brokenLinkPages = /* @__PURE__ */ new Set();
|
|
584
|
+
for (const p of pages) {
|
|
585
|
+
for (const l of p.outlinks ?? []) {
|
|
586
|
+
if (!l.internal) continue;
|
|
587
|
+
const st = statusByUrl.get(normUrl(l.href));
|
|
588
|
+
if (st != null && st >= 400) brokenLinkPages.add(p.url);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
report["links.brokenInternal"] = group([...brokenLinkPages]);
|
|
592
|
+
return report;
|
|
593
|
+
}
|
|
594
|
+
function renderIssueReport(siteUrl, pages, report, metrics) {
|
|
595
|
+
const total = pages.length;
|
|
596
|
+
const sev = (key) => key.startsWith("response.broken") || key.startsWith("response.error") || key.startsWith("links.broken") ? "\u{1F534}" : key.startsWith("title.missing") || key.startsWith("h1.missing") || key.startsWith("indexability") || key.startsWith("canonical.missing") ? "\u{1F7E0}" : "\u{1F7E1}";
|
|
597
|
+
const rows = Object.entries(report).filter(([, g]) => g.count > 0).sort((a, b) => b[1].count - a[1].count).map(([k, g]) => `| ${sev(k)} | \`${k}\` | ${g.count} | ${g.urls.slice(0, 3).join(" \xB7 ")}${g.urls.length > 3 ? " \u2026" : ""} |`);
|
|
598
|
+
const depths = [...metrics.values()].map((m) => m.crawlDepth).filter((d) => d != null);
|
|
599
|
+
const maxDepth = depths.length ? Math.max(...depths) : 0;
|
|
600
|
+
const orphans = [...metrics.values()].filter((m) => m.orphan).length;
|
|
601
|
+
const topLinked = [...metrics.values()].sort((a, b) => b.inlinks - a.inlinks).slice(0, 5);
|
|
602
|
+
return [
|
|
603
|
+
`# SEO Crawl Report: ${siteUrl}`,
|
|
604
|
+
`**${total} pages crawled** \xB7 max crawl depth ${maxDepth} \xB7 ${orphans} orphan page(s)`,
|
|
605
|
+
`
|
|
606
|
+
## Issues
|
|
607
|
+
| | Issue | Count | Examples |
|
|
608
|
+
|---|-------|-------|----------|
|
|
609
|
+
${rows.join("\n") || "| \u2705 | none | 0 | \u2014 |"}`,
|
|
610
|
+
`
|
|
611
|
+
## Most-linked pages
|
|
612
|
+
${topLinked.map((m) => `- ${m.inlinks} inlinks \xB7 depth ${m.crawlDepth ?? "\u2014"} \xB7 ${m.url}`).join("\n")}`,
|
|
613
|
+
`
|
|
614
|
+
_Thresholds: title ${TITLE_MAX}ch/${TITLE_PX_MAX}px, meta ${META_MAX}ch, H1 ${H1_MAX}ch, thin <${THIN_WORDS} words, URL ${URL_MAX}ch. Pixel widths are estimates._`
|
|
615
|
+
].join("\n");
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// src/api/image-audit.ts
|
|
619
|
+
var OVER_BYTES = 100 * 1024;
|
|
620
|
+
var MODERN = /* @__PURE__ */ new Set(["webp", "avif", "svg", "svg+xml"]);
|
|
621
|
+
function formatBytes(n) {
|
|
622
|
+
if (n == null) return null;
|
|
623
|
+
if (n < 1024) return `${n} B`;
|
|
624
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
625
|
+
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
|
|
626
|
+
}
|
|
627
|
+
var decodeEntities = (u) => u.replace(/&/g, "&").replace(/�?38;/g, "&").replace(/&/gi, "&").trim();
|
|
628
|
+
var dedupKey = (u) => u.replace(/^https?:\/\//i, "//").replace(/\/$/, "");
|
|
629
|
+
var formatOf = (ct, url) => {
|
|
630
|
+
if (ct) {
|
|
631
|
+
const m = ct.split(";")[0].trim().toLowerCase();
|
|
632
|
+
if (m.startsWith("image/")) return m.replace("image/", "");
|
|
633
|
+
}
|
|
634
|
+
return (url.split("?")[0].match(/\.([a-z0-9]+)$/i)?.[1] || "unknown").toLowerCase();
|
|
635
|
+
};
|
|
636
|
+
function collectUrls(pages) {
|
|
637
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
638
|
+
for (const p of pages) for (const raw of p.imageLinks || []) {
|
|
639
|
+
const u = decodeEntities(raw);
|
|
640
|
+
if (!/^https?:\/\//i.test(u)) continue;
|
|
641
|
+
if (/[{}]|%7[bd]/i.test(u)) continue;
|
|
642
|
+
const k = dedupKey(u);
|
|
643
|
+
const prev = byKey.get(k);
|
|
644
|
+
if (!prev || /^https:/i.test(u) && /^http:/i.test(prev)) byKey.set(k, u);
|
|
645
|
+
}
|
|
646
|
+
return [...byKey.values()];
|
|
647
|
+
}
|
|
648
|
+
async function sizeAndType(url, timeoutMs) {
|
|
649
|
+
const ctrl = new AbortController();
|
|
650
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
651
|
+
const read = (res) => ({
|
|
652
|
+
len: res.headers.get("content-length"),
|
|
653
|
+
cr: res.headers.get("content-range"),
|
|
654
|
+
ct: res.headers.get("content-type"),
|
|
655
|
+
status: res.status
|
|
656
|
+
});
|
|
657
|
+
try {
|
|
658
|
+
let bytes = null;
|
|
659
|
+
let ct = null;
|
|
660
|
+
let status = null;
|
|
661
|
+
try {
|
|
662
|
+
const h = read(await fetch(url, { method: "HEAD", redirect: "follow", signal: ctrl.signal }));
|
|
663
|
+
status = h.status;
|
|
664
|
+
ct = h.ct;
|
|
665
|
+
if (h.len) bytes = Number(h.len);
|
|
666
|
+
} catch {
|
|
667
|
+
}
|
|
668
|
+
if (bytes == null) {
|
|
669
|
+
const g = await fetch(url, { method: "GET", headers: { Range: "bytes=0-0" }, redirect: "follow", signal: ctrl.signal });
|
|
670
|
+
const r = read(g);
|
|
671
|
+
status = status ?? r.status;
|
|
672
|
+
ct = ct ?? r.ct;
|
|
673
|
+
if (r.cr && r.cr.includes("/")) {
|
|
674
|
+
const tail = r.cr.split("/")[1];
|
|
675
|
+
if (tail && tail !== "*") bytes = Number(tail);
|
|
676
|
+
} else if (r.len) bytes = Number(r.len);
|
|
677
|
+
try {
|
|
678
|
+
await g.body?.cancel();
|
|
679
|
+
} catch {
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return { url, status, bytes: Number.isFinite(bytes) ? bytes : null, contentType: ct };
|
|
683
|
+
} catch (e) {
|
|
684
|
+
return { url, status: null, bytes: null, contentType: null, error: String(e.message || e) };
|
|
685
|
+
} finally {
|
|
686
|
+
clearTimeout(t);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
async function pool(items, n, fn) {
|
|
690
|
+
const out = new Array(items.length);
|
|
691
|
+
let i = 0;
|
|
692
|
+
await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => {
|
|
693
|
+
while (i < items.length) {
|
|
694
|
+
const idx = i++;
|
|
695
|
+
out[idx] = await fn(items[idx]);
|
|
696
|
+
}
|
|
697
|
+
}));
|
|
698
|
+
return out;
|
|
699
|
+
}
|
|
700
|
+
async function auditImages(pages, opts = {}) {
|
|
701
|
+
const concurrency = opts.concurrency ?? 12;
|
|
702
|
+
const timeoutMs = opts.timeoutMs ?? 12e3;
|
|
703
|
+
const urls = collectUrls(pages).slice(0, opts.max ?? 5e3);
|
|
704
|
+
const heads = await pool(urls, concurrency, (u) => sizeAndType(u, timeoutMs));
|
|
705
|
+
const rows = heads.map((r) => {
|
|
706
|
+
const format = formatOf(r.contentType, r.url);
|
|
707
|
+
return {
|
|
708
|
+
...r,
|
|
709
|
+
size: formatBytes(r.bytes),
|
|
710
|
+
format,
|
|
711
|
+
over100kb: r.bytes != null && r.bytes > OVER_BYTES,
|
|
712
|
+
legacyFormat: format !== "unknown" && !MODERN.has(format)
|
|
713
|
+
};
|
|
714
|
+
});
|
|
715
|
+
const sized = rows.filter((r) => r.bytes != null);
|
|
716
|
+
const totalBytes = sized.reduce((a, r) => a + r.bytes, 0);
|
|
717
|
+
const formatCounts = {};
|
|
718
|
+
for (const r of rows) formatCounts[r.format] = (formatCounts[r.format] || 0) + 1;
|
|
719
|
+
return {
|
|
720
|
+
rows: rows.sort((a, b) => (b.bytes ?? 0) - (a.bytes ?? 0)),
|
|
721
|
+
summary: {
|
|
722
|
+
unique: rows.length,
|
|
723
|
+
sized: sized.length,
|
|
724
|
+
totalBytes,
|
|
725
|
+
totalSize: formatBytes(totalBytes) ?? "0 B",
|
|
726
|
+
avgSize: formatBytes(Math.round(totalBytes / (sized.length || 1))) ?? "0 B",
|
|
727
|
+
over100kb: rows.filter((r) => r.over100kb).length,
|
|
728
|
+
legacyFormat: rows.filter((r) => r.legacyFormat).length,
|
|
729
|
+
formatCounts
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
function renderImageSection(audit) {
|
|
734
|
+
const s = audit.summary;
|
|
735
|
+
const fmt = Object.entries(s.formatCounts).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k}:${v}`).join(" ");
|
|
736
|
+
const heaviest = audit.rows.filter((r) => r.over100kb).slice(0, 15);
|
|
737
|
+
const lines = [
|
|
738
|
+
`## Images`,
|
|
739
|
+
`**${s.unique} unique images** \xB7 ${s.totalSize} total \xB7 ${s.avgSize} avg \xB7 ${s.over100kb} over 100 KB \xB7 ${s.legacyFormat} legacy format`,
|
|
740
|
+
`Formats: ${fmt}`
|
|
741
|
+
];
|
|
742
|
+
if (heaviest.length) {
|
|
743
|
+
lines.push("", "| Size | Format | URL |", "|------|--------|-----|");
|
|
744
|
+
for (const r of heaviest) lines.push(`| ${r.size} | ${r.format} | ${r.url} |`);
|
|
745
|
+
}
|
|
746
|
+
return lines.join("\n");
|
|
747
|
+
}
|
|
748
|
+
|
|
430
749
|
// src/mcp/mcp-response-formatter.ts
|
|
431
750
|
var reportSavingEnabled = true;
|
|
432
751
|
function sanitizeVendorText(text) {
|
|
@@ -457,6 +776,111 @@ function saveFullReport(full) {
|
|
|
457
776
|
return null;
|
|
458
777
|
}
|
|
459
778
|
}
|
|
779
|
+
var BULK_PAGE_THRESHOLD = 25;
|
|
780
|
+
var BULK_URL_THRESHOLD = 500;
|
|
781
|
+
function reportSavingActive() {
|
|
782
|
+
return reportSavingEnabled && process.env.MCP_SCRAPER_SAVE_REPORTS !== "false";
|
|
783
|
+
}
|
|
784
|
+
function saveBulkSite(siteUrl, pages, seo, imageAudit) {
|
|
785
|
+
if (!reportSavingActive()) return null;
|
|
786
|
+
try {
|
|
787
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
788
|
+
const dir = (0, import_node_path.join)(outputBaseDir(), `extract-${slugifyReportName(siteUrl)}-${stamp}`);
|
|
789
|
+
const pagesDir = (0, import_node_path.join)(dir, "pages");
|
|
790
|
+
(0, import_node_fs.mkdirSync)(pagesDir, { recursive: true });
|
|
791
|
+
const indexRows = pages.map((p, i) => {
|
|
792
|
+
const num = String(i + 1).padStart(4, "0");
|
|
793
|
+
const slug = slugifyReportName(p.url.replace(/^https?:\/\//, "")).slice(0, 60) || "page";
|
|
794
|
+
const fname = `${num}-${slug}.md`;
|
|
795
|
+
const body = (p.bodyMarkdown ?? "").trim();
|
|
796
|
+
const content = [
|
|
797
|
+
`# ${p.title ?? "Untitled"}`,
|
|
798
|
+
`- **URL:** ${p.url}`,
|
|
799
|
+
p.metaDescription ? `- **Description:** ${p.metaDescription}` : "",
|
|
800
|
+
p.schemaTypes?.length ? `- **Schema:** ${p.schemaTypes.join(", ")}` : "",
|
|
801
|
+
"",
|
|
802
|
+
body || "_(no content extracted)_"
|
|
803
|
+
].filter(Boolean).join("\n");
|
|
804
|
+
(0, import_node_fs.writeFileSync)((0, import_node_path.join)(pagesDir, fname), content, "utf8");
|
|
805
|
+
return `| ${i + 1} | ${cell(p.title ?? "Untitled")} | ${p.url} | pages/${fname} |`;
|
|
806
|
+
});
|
|
807
|
+
const dataFilesSection = seo ? [
|
|
808
|
+
`
|
|
809
|
+
## Data files (in this folder)`,
|
|
810
|
+
`- \`report.md\` \u2014 SEO issues summary (counts + example URLs)`,
|
|
811
|
+
`- \`issues.json\` \u2014 every finding with the full list of offender URLs`,
|
|
812
|
+
`- \`pages.jsonl\` \u2014 per-page SEO fields, one JSON object per page`,
|
|
813
|
+
`- \`links.jsonl\` \u2014 internal/external link edges (anchor, rel, target status)`,
|
|
814
|
+
`- \`link-metrics.jsonl\` \u2014 inlinks, crawl depth, orphan flags per page`,
|
|
815
|
+
...imageAudit ? [
|
|
816
|
+
`- \`images.jsonl\` \u2014 every image with byte size, format, over-100KB and legacy-format flags`,
|
|
817
|
+
`- \`images-summary.json\` \u2014 image totals (count, total weight, over-100KB, legacy)`
|
|
818
|
+
] : [],
|
|
819
|
+
...seo.branding ? [`- \`branding.json\` \u2014 site logo, colors, fonts`] : []
|
|
820
|
+
].join("\n") : "";
|
|
821
|
+
const index = [
|
|
822
|
+
`# Site Extract: ${siteUrl}`,
|
|
823
|
+
`**${pages.length} pages** crawled. Everything is in this folder.`,
|
|
824
|
+
`- Page content: one Markdown file per page under \`pages/\` (listed in the table below).`,
|
|
825
|
+
seo ? `- SEO crawl data: the data files listed below.` : "",
|
|
826
|
+
dataFilesSection,
|
|
827
|
+
`
|
|
828
|
+
## Pages
|
|
829
|
+
| # | Title | URL | File |
|
|
830
|
+
|---|-------|-----|------|
|
|
831
|
+
${indexRows.join("\n")}`
|
|
832
|
+
].filter(Boolean).join("\n");
|
|
833
|
+
const indexFile = (0, import_node_path.join)(dir, "index.md");
|
|
834
|
+
(0, import_node_fs.writeFileSync)(indexFile, index, "utf8");
|
|
835
|
+
let seoFiles;
|
|
836
|
+
if (seo) {
|
|
837
|
+
const toJsonl = (rows) => rows.map((r) => JSON.stringify(r)).join("\n");
|
|
838
|
+
const pagesJsonl = (0, import_node_path.join)(dir, "pages.jsonl");
|
|
839
|
+
const linksJsonl = (0, import_node_path.join)(dir, "links.jsonl");
|
|
840
|
+
const metricsJsonl = (0, import_node_path.join)(dir, "link-metrics.jsonl");
|
|
841
|
+
const issuesFile = (0, import_node_path.join)(dir, "issues.json");
|
|
842
|
+
const reportFile = (0, import_node_path.join)(dir, "report.md");
|
|
843
|
+
(0, import_node_fs.writeFileSync)(pagesJsonl, toJsonl(seo.pageRows), "utf8");
|
|
844
|
+
(0, import_node_fs.writeFileSync)(linksJsonl, toJsonl(seo.edges), "utf8");
|
|
845
|
+
(0, import_node_fs.writeFileSync)(metricsJsonl, toJsonl(seo.metrics), "utf8");
|
|
846
|
+
(0, import_node_fs.writeFileSync)(issuesFile, JSON.stringify(seo.issues, null, 2), "utf8");
|
|
847
|
+
(0, import_node_fs.writeFileSync)(reportFile, seo.reportMd + (imageAudit ? `
|
|
848
|
+
|
|
849
|
+
${renderImageSection(imageAudit)}` : ""), "utf8");
|
|
850
|
+
seoFiles = [pagesJsonl, linksJsonl, metricsJsonl, issuesFile, reportFile];
|
|
851
|
+
if (imageAudit) {
|
|
852
|
+
const imagesJsonl = (0, import_node_path.join)(dir, "images.jsonl");
|
|
853
|
+
const imagesSummary = (0, import_node_path.join)(dir, "images-summary.json");
|
|
854
|
+
(0, import_node_fs.writeFileSync)(imagesJsonl, imageAudit.rows.map((r) => JSON.stringify(r)).join("\n"), "utf8");
|
|
855
|
+
(0, import_node_fs.writeFileSync)(imagesSummary, JSON.stringify(imageAudit.summary, null, 2), "utf8");
|
|
856
|
+
seoFiles.push(imagesJsonl, imagesSummary);
|
|
857
|
+
}
|
|
858
|
+
if (seo.branding) {
|
|
859
|
+
const brandingFile = (0, import_node_path.join)(dir, "branding.json");
|
|
860
|
+
(0, import_node_fs.writeFileSync)(brandingFile, JSON.stringify(seo.branding, null, 2), "utf8");
|
|
861
|
+
seoFiles.push(brandingFile);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
return { dir, indexFile, fileCount: pages.length, seoFiles };
|
|
865
|
+
} catch {
|
|
866
|
+
return null;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
function saveUrlInventory(siteUrl, urls) {
|
|
870
|
+
if (!reportSavingActive()) return null;
|
|
871
|
+
try {
|
|
872
|
+
const outDir = outputBaseDir();
|
|
873
|
+
(0, import_node_fs.mkdirSync)(outDir, { recursive: true });
|
|
874
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
875
|
+
const file = (0, import_node_path.join)(outDir, `${stamp}-urlmap-${slugifyReportName(siteUrl.replace(/^https?:\/\//, ""))}.csv`);
|
|
876
|
+
const csv = (v) => /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
|
|
877
|
+
const rows = ["url,status", ...urls.map((u) => `${csv(u.url)},${u.status ?? ""}`)];
|
|
878
|
+
(0, import_node_fs.writeFileSync)(file, rows.join("\n"), "utf8");
|
|
879
|
+
return file;
|
|
880
|
+
} catch {
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
460
884
|
function persistScreenshotLocally(base64, url) {
|
|
461
885
|
if (!reportSavingEnabled || process.env.MCP_SCRAPER_SAVE_REPORTS === "false") return null;
|
|
462
886
|
try {
|
|
@@ -471,8 +895,8 @@ function persistScreenshotLocally(base64, url) {
|
|
|
471
895
|
return null;
|
|
472
896
|
}
|
|
473
897
|
}
|
|
474
|
-
function oneBlock(content) {
|
|
475
|
-
const filePath = saveFullReport(content);
|
|
898
|
+
function oneBlock(content, diskContent) {
|
|
899
|
+
const filePath = saveFullReport(diskContent ?? content);
|
|
476
900
|
const text = filePath ? `${content}
|
|
477
901
|
|
|
478
902
|
\u{1F4C4} Saved: \`${filePath}\`` : content;
|
|
@@ -741,7 +1165,12 @@ ${[h1Lines, h2Lines].filter(Boolean).join("\n")}` : "";
|
|
|
741
1165
|
].filter(Boolean).join("\n") : "";
|
|
742
1166
|
const bodySection = bodyMd ? `
|
|
743
1167
|
## Page Content
|
|
744
|
-
${bodyMd.slice(0, 3e3)}${bodyMd.length > 3e3 ?
|
|
1168
|
+
${bodyMd.slice(0, 3e3)}${bodyMd.length > 3e3 ? `
|
|
1169
|
+
|
|
1170
|
+
*(truncated to 3,000 of ${bodyMd.length.toLocaleString()} chars \u2014 full content in the saved report)*` : ""}` : "";
|
|
1171
|
+
const bodySectionFull = bodyMd ? `
|
|
1172
|
+
## Page Content
|
|
1173
|
+
${bodyMd}` : "";
|
|
745
1174
|
const screenshotSection = screenshotMeta ? `
|
|
746
1175
|
## Screenshot
|
|
747
1176
|
- **File:** ${screenshotPath ?? "(returned inline only \u2014 disk write unavailable in this environment)"}
|
|
@@ -772,7 +1201,10 @@ ${bodyMd.slice(0, 3e3)}${bodyMd.length > 3e3 ? "\n\n*(truncated)*" : ""}` : "";
|
|
|
772
1201
|
const full = `# URL Extract: ${url}
|
|
773
1202
|
**${title}**
|
|
774
1203
|
${headingSection}${kpoSection}${brandingSection}${bodySection}${screenshotSection}${mediaSection}${tips}`;
|
|
775
|
-
const
|
|
1204
|
+
const diskReport = `# URL Extract: ${url}
|
|
1205
|
+
**${title}**
|
|
1206
|
+
${headingSection}${kpoSection}${brandingSection}${bodySectionFull}${screenshotSection}${mediaSection}${tips}`;
|
|
1207
|
+
const textResult = oneBlock(full, diskReport);
|
|
776
1208
|
const structuredContent = {
|
|
777
1209
|
url,
|
|
778
1210
|
title: d.title ?? null,
|
|
@@ -805,7 +1237,16 @@ function formatMapSiteUrls(raw, input) {
|
|
|
805
1237
|
const ok = urls.filter((u) => (u.status ?? 0) >= 200 && (u.status ?? 0) < 300);
|
|
806
1238
|
const broken = urls.filter((u) => u.status !== null && u.status >= 400);
|
|
807
1239
|
const redirects = urls.filter((u) => u.status !== null && u.status >= 300 && u.status < 400);
|
|
808
|
-
const
|
|
1240
|
+
const isBulk = urls.length > BULK_URL_THRESHOLD;
|
|
1241
|
+
const inventoryFile = isBulk ? saveUrlInventory(input.url, urls.map((u) => ({ url: u.url, status: u.status ?? null }))) : null;
|
|
1242
|
+
const inlineCount = isBulk ? BULK_URL_THRESHOLD : 200;
|
|
1243
|
+
const urlRows = urls.slice(0, inlineCount).map((u, i) => `| ${i + 1} | ${u.url} | ${u.status ?? "\u2014"} |`).join("\n");
|
|
1244
|
+
const inventoryNote = isBulk ? inventoryFile ? `
|
|
1245
|
+
## \u{1F4C1} Full inventory saved
|
|
1246
|
+
- **File:** \`${inventoryFile}\` (CSV: url, status \u2014 all ${urls.length} URLs)
|
|
1247
|
+
Showing the first ${inlineCount} below; read the file for the complete list.` : `
|
|
1248
|
+
## \u26A0\uFE0F Full inventory not saved
|
|
1249
|
+
Report saving is disabled, so only the first ${inlineCount} of ${urls.length} URLs are shown. Enable \`MCP_SCRAPER_SAVE_REPORTS\` to capture the full inventory.` : "";
|
|
809
1250
|
const full = [
|
|
810
1251
|
`# URL Map: ${input.url}`,
|
|
811
1252
|
`**${d.totalFound} URLs** \xB7 ${(d.durationMs / 1e3).toFixed(1)}s${d.truncated ? " \xB7 *truncated*" : ""}`,
|
|
@@ -814,12 +1255,13 @@ function formatMapSiteUrls(raw, input) {
|
|
|
814
1255
|
- \u2705 2xx: ${ok.length}
|
|
815
1256
|
- \u{1F500} 3xx: ${redirects.length}
|
|
816
1257
|
- \u274C 4xx+: ${broken.length}`,
|
|
1258
|
+
inventoryNote,
|
|
817
1259
|
`
|
|
818
|
-
## URL Inventory
|
|
1260
|
+
## URL Inventory${isBulk ? ` (first ${inlineCount} of ${urls.length})` : ""}
|
|
819
1261
|
| # | URL | Status |
|
|
820
1262
|
|---|-----|--------|
|
|
821
1263
|
${urlRows}`,
|
|
822
|
-
broken.length ? `
|
|
1264
|
+
!isBulk && broken.length ? `
|
|
823
1265
|
## Broken URLs
|
|
824
1266
|
${broken.map((u) => `- ${u.url} (${u.status})`).join("\n")}` : "",
|
|
825
1267
|
`
|
|
@@ -837,47 +1279,164 @@ ${broken.map((u) => `- ${u.url} (${u.status})`).join("\n")}` : "",
|
|
|
837
1279
|
okCount: ok.length,
|
|
838
1280
|
redirectCount: redirects.length,
|
|
839
1281
|
brokenCount: broken.length,
|
|
1282
|
+
inventoryFile: inventoryFile ?? null,
|
|
840
1283
|
urls: urls.map((u) => ({ url: u.url, status: u.status ?? null })),
|
|
841
1284
|
durationMs: d.durationMs ?? 0
|
|
842
1285
|
}
|
|
843
1286
|
};
|
|
844
1287
|
}
|
|
1288
|
+
function buildSeoExport(siteUrl, pages, branding) {
|
|
1289
|
+
const { edges, metrics } = buildLinkGraph(pages, siteUrl);
|
|
1290
|
+
const pageRows = pages.map((p) => {
|
|
1291
|
+
const { bodyMarkdown: _b, schema: _s, outlinks: _o, ...rest } = p;
|
|
1292
|
+
const m = metrics.get(p.url);
|
|
1293
|
+
return { ...rest, inlinks: m?.inlinks ?? 0, crawlDepth: m?.crawlDepth ?? null, orphan: m?.orphan ?? false };
|
|
1294
|
+
});
|
|
1295
|
+
const issues = computeIssues(pages, metrics);
|
|
1296
|
+
return {
|
|
1297
|
+
pageRows,
|
|
1298
|
+
edges,
|
|
1299
|
+
metrics: [...metrics.values()],
|
|
1300
|
+
issues,
|
|
1301
|
+
reportMd: renderIssueReport(siteUrl, pages, issues, metrics),
|
|
1302
|
+
branding: branding ?? void 0
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
845
1305
|
function formatExtractSite(raw, input) {
|
|
846
1306
|
const parsed = parseData(raw);
|
|
847
1307
|
if ("error" in parsed) return { content: [{ type: "text", text: parsed.error }], isError: true };
|
|
848
1308
|
const d = parsed.data;
|
|
849
1309
|
const pages = d.pages ?? [];
|
|
850
|
-
const
|
|
851
|
-
|
|
1310
|
+
const schemaTypesOf = (p) => p.kpo?.type ?? (Array.isArray(p.schema) && p.schema.length ? [`${p.schema.length} block(s)`] : []);
|
|
1311
|
+
const pageRow = (p, i) => {
|
|
1312
|
+
const schemaInfo = schemaTypesOf(p).join(", ") || "\u2014";
|
|
852
1313
|
return `| ${i + 1} | ${cell(p.title ?? "Untitled")} | ${p.url} | ${schemaInfo} |`;
|
|
853
|
-
}
|
|
854
|
-
const
|
|
1314
|
+
};
|
|
1315
|
+
const tips = `
|
|
1316
|
+
---
|
|
1317
|
+
\u{1F4A1} **Tips**
|
|
1318
|
+
- Map URLs first: use \`map_site_urls\`
|
|
1319
|
+
- Inspect a single page: use \`extract_url\``;
|
|
1320
|
+
const durationLine = `**${pages.length} pages** \xB7 ${((d.durationMs ?? 0) / 1e3).toFixed(1)}s`;
|
|
1321
|
+
const structuredContent = {
|
|
1322
|
+
url: input.url,
|
|
1323
|
+
pageCount: pages.length,
|
|
1324
|
+
pages: pages.map((p) => ({
|
|
1325
|
+
url: String(p.url ?? ""),
|
|
1326
|
+
title: p.title ?? null,
|
|
1327
|
+
schemaTypes: schemaTypesOf(p)
|
|
1328
|
+
})),
|
|
1329
|
+
durationMs: d.durationMs ?? 0
|
|
1330
|
+
};
|
|
1331
|
+
if (pages.length > BULK_PAGE_THRESHOLD) {
|
|
1332
|
+
const bulk = saveBulkSite(input.url, pages.map((p) => ({
|
|
1333
|
+
url: String(p.url ?? ""),
|
|
1334
|
+
title: p.title ?? null,
|
|
1335
|
+
bodyMarkdown: p.bodyMarkdown ?? null,
|
|
1336
|
+
metaDescription: p.metaDescription ?? null,
|
|
1337
|
+
schemaTypes: schemaTypesOf(p)
|
|
1338
|
+
})));
|
|
1339
|
+
const preview = pages.slice(0, BULK_PAGE_THRESHOLD).map(pageRow).join("\n");
|
|
1340
|
+
const location = bulk ? `
|
|
1341
|
+
## \u{1F4C1} Bulk scrape saved
|
|
1342
|
+
- **Folder:** \`${bulk.dir}\` \u2190 all scraped page content is here
|
|
1343
|
+
- **Start here:** \`${bulk.indexFile}\` \u2014 lists every page and its file
|
|
1344
|
+
- **Page content:** ${bulk.fileCount} Markdown files under \`pages/\` (one per page)
|
|
1345
|
+
|
|
1346
|
+
The page content is NOT inlined here to keep the context window clear \u2014 it is saved to disk. Open \`index.md\`, then open the page file you need from \`pages/\`.
|
|
1347
|
+
|
|
1348
|
+
\u{1F4A1} Want the full technical SEO audit (headings, link graph, indexability, image sizes/formats, issues)? Run \`audit_site\` on this URL instead.` : `
|
|
1349
|
+
## \u26A0\uFE0F Bulk scrape not saved
|
|
1350
|
+
Report saving is disabled in this environment, so the ${pages.length} pages of content could not be written to disk and are omitted to protect the context window. Enable \`MCP_SCRAPER_SAVE_REPORTS\` to capture bulk crawls.`;
|
|
1351
|
+
const full2 = [
|
|
1352
|
+
`# Site Extract: ${input.url}`,
|
|
1353
|
+
durationLine,
|
|
1354
|
+
location,
|
|
1355
|
+
`
|
|
1356
|
+
## Pages (first ${Math.min(BULK_PAGE_THRESHOLD, pages.length)} of ${pages.length})
|
|
1357
|
+
| # | Title | URL | Schema |
|
|
1358
|
+
|---|-------|-----|--------|
|
|
1359
|
+
${preview}`,
|
|
1360
|
+
tips
|
|
1361
|
+
].join("\n");
|
|
1362
|
+
return { content: [{ type: "text", text: full2 }], structuredContent: { ...structuredContent, bulkFolder: bulk?.dir ?? null } };
|
|
1363
|
+
}
|
|
1364
|
+
const header = [
|
|
855
1365
|
`# Site Extract: ${input.url}`,
|
|
856
|
-
|
|
1366
|
+
durationLine,
|
|
857
1367
|
`
|
|
858
1368
|
## Pages
|
|
859
1369
|
| # | Title | URL | Schema |
|
|
860
1370
|
|---|-------|-----|--------|
|
|
861
|
-
${
|
|
862
|
-
`
|
|
863
|
-
---
|
|
864
|
-
\u{1F4A1} **Tips**
|
|
865
|
-
- Map URLs first: use \`map_site_urls\`
|
|
866
|
-
- Inspect a single page: use \`extract_url\``
|
|
1371
|
+
${pages.map(pageRow).join("\n")}`
|
|
867
1372
|
].join("\n");
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
1373
|
+
const full = `${header}${tips}`;
|
|
1374
|
+
const pageDetails = pages.map((p, i) => {
|
|
1375
|
+
const body = (p.bodyMarkdown ?? "").trim();
|
|
1376
|
+
return [
|
|
1377
|
+
`
|
|
1378
|
+
## ${i + 1}. ${p.title ?? "Untitled"}`,
|
|
1379
|
+
`- **URL:** ${p.url}`,
|
|
1380
|
+
p.metaDescription ? `- **Description:** ${p.metaDescription}` : "",
|
|
1381
|
+
body ? `
|
|
1382
|
+
${body}` : "_(no content extracted)_"
|
|
1383
|
+
].filter(Boolean).join("\n");
|
|
1384
|
+
}).join("\n");
|
|
1385
|
+
const diskReport = `${header}
|
|
1386
|
+
|
|
1387
|
+
---
|
|
1388
|
+
# Full Page Content
|
|
1389
|
+
${pageDetails}${tips}`;
|
|
1390
|
+
return { ...oneBlock(full, diskReport), structuredContent };
|
|
1391
|
+
}
|
|
1392
|
+
async function formatAuditSite(raw, input) {
|
|
1393
|
+
const parsed = parseData(raw);
|
|
1394
|
+
if ("error" in parsed) return { content: [{ type: "text", text: parsed.error }], isError: true };
|
|
1395
|
+
const d = parsed.data;
|
|
1396
|
+
const pages = d.pages ?? [];
|
|
1397
|
+
const schemaTypesOf = (p) => p.kpo?.type ?? (Array.isArray(p.schema) && p.schema.length ? [`${p.schema.length} block(s)`] : []);
|
|
1398
|
+
const seo = buildSeoExport(input.url, pages, d.branding);
|
|
1399
|
+
const imageAudit = await auditImages(pages, { concurrency: 12 });
|
|
1400
|
+
const bulk = saveBulkSite(input.url, pages.map((p) => ({
|
|
1401
|
+
url: String(p.url ?? ""),
|
|
1402
|
+
title: p.title ?? null,
|
|
1403
|
+
bodyMarkdown: p.bodyMarkdown ?? null,
|
|
1404
|
+
metaDescription: p.metaDescription ?? null,
|
|
1405
|
+
schemaTypes: schemaTypesOf(p)
|
|
1406
|
+
})), seo, imageAudit);
|
|
1407
|
+
const topIssues = Object.entries(seo.issues).sort((a, b) => b[1].count - a[1].count).slice(0, 8).map(([k, v]) => `- \`${k}\` \u2014 ${v.count}`).join("\n");
|
|
1408
|
+
const img = imageAudit.summary;
|
|
1409
|
+
const imgLine = `${img.unique} images \xB7 ${img.totalSize} total \xB7 ${img.over100kb} over 100 KB \xB7 ${img.legacyFormat} legacy format`;
|
|
1410
|
+
const durationLine = `**${pages.length} pages** \xB7 ${((d.durationMs ?? 0) / 1e3).toFixed(1)}s`;
|
|
1411
|
+
const structuredContent = {
|
|
1412
|
+
url: input.url,
|
|
1413
|
+
pageCount: pages.length,
|
|
1414
|
+
durationMs: d.durationMs ?? 0,
|
|
1415
|
+
bulkFolder: bulk?.dir ?? null,
|
|
1416
|
+
issues: Object.fromEntries(Object.entries(seo.issues).map(([k, v]) => [k, v.count])),
|
|
1417
|
+
images: { unique: img.unique, totalBytes: img.totalBytes, over100kb: img.over100kb, legacyFormat: img.legacyFormat }
|
|
880
1418
|
};
|
|
1419
|
+
const location = bulk ? `
|
|
1420
|
+
## \u{1F4C1} Technical audit saved
|
|
1421
|
+
- **Folder:** \`${bulk.dir}\` \u2190 full audit + page content here
|
|
1422
|
+
- **Start here:** \`${bulk.indexFile}\` \u2014 maps every file in the folder
|
|
1423
|
+
- **Data:** \`report.md\` (issues + image audit), \`issues.json\`, \`pages.jsonl\` (per-page fields), \`links.jsonl\`, \`link-metrics.jsonl\`, \`images.jsonl\`
|
|
1424
|
+
|
|
1425
|
+
Detail is saved to disk, not inlined. Open \`report.md\` for the audit, \`pages.jsonl\` for per-page fields (headings, canonical, indexability), \`images.jsonl\` for image sizes/formats.` : `
|
|
1426
|
+
## \u26A0\uFE0F Audit not saved
|
|
1427
|
+
Report saving is disabled in this environment. Enable \`MCP_SCRAPER_SAVE_REPORTS\` to capture the technical audit.`;
|
|
1428
|
+
const full = [
|
|
1429
|
+
`# Technical SEO Audit: ${input.url}`,
|
|
1430
|
+
durationLine,
|
|
1431
|
+
location,
|
|
1432
|
+
`
|
|
1433
|
+
## Top issues
|
|
1434
|
+
${topIssues || "_none found_"}`,
|
|
1435
|
+
`
|
|
1436
|
+
## Images
|
|
1437
|
+
${imgLine}`
|
|
1438
|
+
].join("\n");
|
|
1439
|
+
return { content: [{ type: "text", text: full }], structuredContent };
|
|
881
1440
|
}
|
|
882
1441
|
function formatYoutubeHarvest(raw, input) {
|
|
883
1442
|
const parsed = parseData(raw);
|
|
@@ -1964,6 +2523,33 @@ ${limitations.map((l) => `- ${l}`).join("\n")}` : "",
|
|
|
1964
2523
|
};
|
|
1965
2524
|
}
|
|
1966
2525
|
|
|
2526
|
+
// src/mcp/server-instructions.ts
|
|
2527
|
+
var SERVER_INSTRUCTIONS = `
|
|
2528
|
+
This server scrapes and analyzes web, social, search, maps, and site data. Pick a tool by NAME using
|
|
2529
|
+
this routing map, then load its schema before calling.
|
|
2530
|
+
|
|
2531
|
+
ROUTING
|
|
2532
|
+
- One web page -> extract_url. Whole site (crawl + SEO report) -> extract_site. Just the URL list -> map_site_urls.
|
|
2533
|
+
- Web search (organic SERP) -> search_serp. Full SERP + People-Also-Ask / AI-Overview detail -> harvest_paa.
|
|
2534
|
+
- Google Maps: find places -> maps_search; one place deep-dive + reviews -> maps_place_intel.
|
|
2535
|
+
- YouTube: find or list videos -> youtube_harvest; transcribe one video -> youtube_transcribe.
|
|
2536
|
+
- Facebook: find ads -> facebook_ad_search; transcribe an ad -> facebook_ad_transcribe; transcribe a
|
|
2537
|
+
video -> facebook_video_transcribe; page profile/intel -> facebook_page_intel.
|
|
2538
|
+
- Instagram: profile inventory -> instagram_profile_content; one post or reel -> instagram_media_download.
|
|
2539
|
+
- Workflows (multi-step): local directory build -> directory_workflow; rank blueprint -> rank_tracker_workflow;
|
|
2540
|
+
AI-answer citation fan-out (AEO) -> query_fanout_workflow; deep research -> deep_research_workflow.
|
|
2541
|
+
- Anything without a dedicated tool (e.g. Reddit, arbitrary logged-in sites) -> the browser_* agent
|
|
2542
|
+
(browser_open, then navigate/read); save/reuse logins via browser_profile_*.
|
|
2543
|
+
|
|
2544
|
+
NOTES
|
|
2545
|
+
- Bulk / full-site crawls: call extract_site with rotateProxies:true for blocked or rate-limited sites.
|
|
2546
|
+
It discovers URLs and returns a saved folder/artifact plus a summary, not the full content inline.
|
|
2547
|
+
- Tools that open a browser session or transcribe media cost credits and take longer. Prefer the
|
|
2548
|
+
cheapest tool that answers the question (plain search/extract before browser agents).
|
|
2549
|
+
- Large results are saved to disk or an artifact and returned as a summary plus a path; read the path
|
|
2550
|
+
for full detail rather than expecting the whole payload inline.
|
|
2551
|
+
`.trim();
|
|
2552
|
+
|
|
1967
2553
|
// src/mcp/mcp-tool-schemas.ts
|
|
1968
2554
|
var import_zod2 = require("zod");
|
|
1969
2555
|
|
|
@@ -2088,11 +2674,20 @@ var ExtractUrlInputSchema = {
|
|
|
2088
2674
|
};
|
|
2089
2675
|
var MapSiteUrlsInputSchema = {
|
|
2090
2676
|
url: import_zod2.z.string().url().describe("Public website URL or domain to crawl for internal URLs. Use before extract_site when the user asks to audit/map/crawl a site."),
|
|
2091
|
-
maxUrls: import_zod2.z.number().int().min(1).max(
|
|
2677
|
+
maxUrls: import_zod2.z.number().int().min(1).max(1e4).optional().describe("Maximum URLs to discover. Use 100 for normal maps, up to 10000 for a full inventory. Large maps (over 500 URLs) write the complete inventory to a local file and return only a summary plus the file path instead of the full list inline.")
|
|
2092
2678
|
};
|
|
2093
2679
|
var ExtractSiteInputSchema = {
|
|
2094
|
-
url: import_zod2.z.string().url().describe("Public website URL or domain to
|
|
2095
|
-
maxPages: import_zod2.z.number().int().min(1).max(
|
|
2680
|
+
url: import_zod2.z.string().url().describe("Public website URL or domain to crawl for page CONTENT across multiple pages (map + scrape). Use when the user wants the content/text/markdown of a site's pages. For a technical SEO audit (issues, link graph, indexability, headings, image weights) use audit_site instead \u2014 extract_site returns content only, not analysis."),
|
|
2681
|
+
maxPages: import_zod2.z.number().int().min(1).max(1e4).optional().describe("Maximum pages to extract. Use 50 for a normal crawl, up to 10000 for a full-site bulk scrape. Bulk crawls (over 25 pages) switch to folder mode: every page is saved as its own Markdown file in a local folder and the response returns only a summary plus the folder path, so the full content never floods the context window."),
|
|
2682
|
+
rotateProxies: import_zod2.z.boolean().optional().describe("Route page fetches through rotating residential proxies in headful browsers to defeat rate-limiting and bot blocks (403/429). Discovers URLs from the sitemap, fetches in batches with a fresh proxy per batch, retries failures on a new proxy, and automatically parallelizes across the account's concurrency slots. Slower and pricier than the default fetch path, so use only when a site blocks normal crawling."),
|
|
2683
|
+
rotateProxyEvery: import_zod2.z.number().int().min(1).max(100).optional().describe("When rotateProxies is on, how many pages to fetch per browser/proxy before discarding it and minting a fresh one. Default 30. Lower values rotate IPs more aggressively against strict rate limits."),
|
|
2684
|
+
formats: import_zod2.z.array(import_zod2.z.enum(["markdown", "links", "json", "images", "branding"])).optional().describe("Which data formats to include in the per-page output. markdown=page content as Markdown, links=internal/external link graph, json=structured data/schema, images=per-page image URLs (links only, no downloads), branding=site-level logo/colors/fonts captured once from the homepage (requires a browser, adds time). markdown, links, json, and images are always captured cheaply from the HTML; branding is the only opt-in capture. Defaults to markdown+links when omitted.")
|
|
2685
|
+
};
|
|
2686
|
+
var AuditSiteInputSchema = {
|
|
2687
|
+
url: import_zod2.z.string().url().describe("Public website URL or domain to run a full technical SEO audit on. Use when the user asks for a technical audit, SEO audit, site health check, or a Screaming-Frog-style crawl \u2014 i.e. they want ANALYSIS (issues, internal link graph, indexability, heading breakdown, image sizes/formats), not just page content. For plain content scraping use extract_site instead."),
|
|
2688
|
+
maxPages: import_zod2.z.number().int().min(1).max(1e4).optional().describe("Maximum pages to crawl and audit. Use 50 for a normal audit, up to 10000 for a full-site audit. The audit always writes a folder of analysis files (report.md, issues.json, pages.jsonl, links.jsonl, link-metrics.jsonl, images.jsonl) plus per-page content, and returns a headline summary plus the folder path."),
|
|
2689
|
+
rotateProxies: import_zod2.z.boolean().optional().describe("Route page fetches through rotating residential proxies to defeat rate-limiting and bot blocks (403/429). Slower and pricier than the default fetch path, so use only when a site blocks normal crawling."),
|
|
2690
|
+
rotateProxyEvery: import_zod2.z.number().int().min(1).max(100).optional().describe("When rotateProxies is on, how many pages to fetch per browser/proxy before discarding it and minting a fresh one. Default 30.")
|
|
2096
2691
|
};
|
|
2097
2692
|
var YoutubeHarvestInputSchema = {
|
|
2098
2693
|
mode: import_zod2.z.enum(["search", "channel"]).describe("Use search for topic/keyword requests. Use channel when the user provides @handle, channel ID, or channel URL."),
|
|
@@ -2394,6 +2989,19 @@ var ExtractSiteOutputSchema = {
|
|
|
2394
2989
|
})),
|
|
2395
2990
|
durationMs: import_zod2.z.number().min(0)
|
|
2396
2991
|
};
|
|
2992
|
+
var AuditSiteOutputSchema = {
|
|
2993
|
+
url: import_zod2.z.string(),
|
|
2994
|
+
pageCount: import_zod2.z.number().int().min(0),
|
|
2995
|
+
durationMs: import_zod2.z.number().min(0),
|
|
2996
|
+
bulkFolder: import_zod2.z.string().nullable(),
|
|
2997
|
+
issues: import_zod2.z.record(import_zod2.z.string(), import_zod2.z.number()),
|
|
2998
|
+
images: import_zod2.z.object({
|
|
2999
|
+
unique: import_zod2.z.number().int().min(0),
|
|
3000
|
+
totalBytes: import_zod2.z.number().min(0),
|
|
3001
|
+
over100kb: import_zod2.z.number().int().min(0),
|
|
3002
|
+
legacyFormat: import_zod2.z.number().int().min(0)
|
|
3003
|
+
})
|
|
3004
|
+
};
|
|
2397
3005
|
var MapsPlaceIntelOutputSchema = {
|
|
2398
3006
|
name: import_zod2.z.string(),
|
|
2399
3007
|
rating: NullableString,
|
|
@@ -3197,7 +3805,7 @@ function registerSavedReportResources(server2) {
|
|
|
3197
3805
|
);
|
|
3198
3806
|
}
|
|
3199
3807
|
function buildPaaExtractorMcpServer(executor2, options = {}) {
|
|
3200
|
-
const server2 = new import_mcp.McpServer({ name: "mcp-scraper", version: PACKAGE_VERSION });
|
|
3808
|
+
const server2 = new import_mcp.McpServer({ name: "mcp-scraper", version: PACKAGE_VERSION }, { instructions: SERVER_INSTRUCTIONS });
|
|
3201
3809
|
registerPaaExtractorMcpTools(server2, executor2, options);
|
|
3202
3810
|
return server2;
|
|
3203
3811
|
}
|
|
@@ -3229,18 +3837,25 @@ function registerPaaExtractorMcpTools(server2, executor2, options = {}) {
|
|
|
3229
3837
|
}, async (input) => formatExtractUrl(await executor2.extractUrl(input), input));
|
|
3230
3838
|
server2.registerTool("map_site_urls", {
|
|
3231
3839
|
title: "Site URL Map",
|
|
3232
|
-
description: withReportNote("Map/crawl a public website when the user asks for a sitemap, URL inventory, broken-link scan, redirect scan, or crawl planning. Returns internal URLs with HTTP status and truncation metadata. Use this before extract_site when choosing pages for an audit; use extract_url for one known page."),
|
|
3840
|
+
description: withReportNote("Map/crawl a public website when the user asks for a sitemap, URL inventory, broken-link scan, redirect scan, or crawl planning. Returns internal URLs with HTTP status and truncation metadata. Supports up to maxUrls=10000; maps over 500 URLs write the complete inventory to a local CSV file and return a summary plus the file path instead of the full list inline. Use this before extract_site when choosing pages for an audit; use extract_url for one known page."),
|
|
3233
3841
|
inputSchema: MapSiteUrlsInputSchema,
|
|
3234
3842
|
outputSchema: MapSiteUrlsOutputSchema,
|
|
3235
3843
|
annotations: liveWebToolAnnotations("Site URL Map")
|
|
3236
3844
|
}, async (input) => formatMapSiteUrls(await executor2.mapSiteUrls(input), input));
|
|
3237
3845
|
server2.registerTool("extract_site", {
|
|
3238
|
-
title: "Multi-Page Site
|
|
3239
|
-
description: withReportNote("
|
|
3846
|
+
title: "Multi-Page Site Content Crawl",
|
|
3847
|
+
description: withReportNote("Crawl a public website and return the page CONTENT across multiple pages (map + scrape). Returns per-page titles and Markdown content. Supports up to maxPages=10000; bulk crawls over 25 pages switch to folder mode: each page is saved as its own Markdown file in a local folder and the response returns a summary plus the folder path instead of inlining all content. This tool returns content only \u2014 for a technical SEO audit (issues, internal link graph, indexability, heading breakdown, image sizes/formats) use audit_site. Use map_site_urls first when URL selection matters; use extract_url for one page."),
|
|
3240
3848
|
inputSchema: ExtractSiteInputSchema,
|
|
3241
3849
|
outputSchema: ExtractSiteOutputSchema,
|
|
3242
|
-
annotations: liveWebToolAnnotations("Multi-Page Site
|
|
3850
|
+
annotations: liveWebToolAnnotations("Multi-Page Site Content Crawl")
|
|
3243
3851
|
}, async (input) => formatExtractSite(await executor2.extractSite(input), input));
|
|
3852
|
+
server2.registerTool("audit_site", {
|
|
3853
|
+
title: "Technical SEO Audit",
|
|
3854
|
+
description: withReportNote("Run a full technical SEO audit (Screaming-Frog-style) on a public website. Crawls up to maxPages and produces a complete on-page + crawl analysis: per-page title/meta lengths, heading breakdown, indexability and canonical status, schema, an internal link graph with inlinks/orphans/crawl-depth, an issues report, and an image audit (byte size, format, over-100KB and legacy-format flags). Writes everything to a local folder (report.md, issues.json, pages.jsonl, links.jsonl, link-metrics.jsonl, images.jsonl) plus per-page content, and returns a headline summary plus the folder path. Use this when the user wants an audit or analysis; use extract_site when they just want page content."),
|
|
3855
|
+
inputSchema: AuditSiteInputSchema,
|
|
3856
|
+
outputSchema: AuditSiteOutputSchema,
|
|
3857
|
+
annotations: liveWebToolAnnotations("Technical SEO Audit")
|
|
3858
|
+
}, async (input) => formatAuditSite(await executor2.auditSite(input), input));
|
|
3244
3859
|
server2.registerTool("youtube_harvest", {
|
|
3245
3860
|
title: "YouTube Video Harvest",
|
|
3246
3861
|
description: withReportNote('Harvest YouTube video metadata when the user wants to find videos by topic, inspect a channel library, compare video angles, or get videoIds for later transcription. Use mode "search" for keyword/topic requests and mode "channel" for @handles, channel IDs, or channel URLs. Returns titles, views, durations, URLs, and videoIds for follow-up transcription. Use youtube_transcribe after selecting one video.'),
|
|
@@ -3360,7 +3975,7 @@ function registerPaaExtractorMcpTools(server2, executor2, options = {}) {
|
|
|
3360
3975
|
outputSchema: WorkflowArtifactReadOutputSchema,
|
|
3361
3976
|
annotations: liveWebToolAnnotations("Read Workflow Artifact")
|
|
3362
3977
|
}, async (input) => formatWorkflowArtifactRead(await executor2.workflowArtifactRead(input), input));
|
|
3363
|
-
server2.registerTool("
|
|
3978
|
+
server2.registerTool("rank_tracker_workflow", {
|
|
3364
3979
|
title: "Rank Tracker Blueprint Builder",
|
|
3365
3980
|
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.",
|
|
3366
3981
|
inputSchema: RankTrackerBlueprintInputSchema,
|