mcp-scraper 0.3.15 → 0.3.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/bin/api-server.cjs +420 -25
  2. package/dist/bin/api-server.cjs.map +1 -1
  3. package/dist/bin/api-server.js +1 -1
  4. package/dist/bin/browser-agent-stdio-server.cjs +1 -1
  5. package/dist/bin/browser-agent-stdio-server.cjs.map +1 -1
  6. package/dist/bin/browser-agent-stdio-server.js +2 -2
  7. package/dist/bin/mcp-scraper-cli.cjs +1 -1
  8. package/dist/bin/mcp-scraper-cli.cjs.map +1 -1
  9. package/dist/bin/mcp-scraper-cli.js +1 -1
  10. package/dist/bin/mcp-scraper-combined-stdio-server.cjs +371 -20
  11. package/dist/bin/mcp-scraper-combined-stdio-server.cjs.map +1 -1
  12. package/dist/bin/mcp-scraper-combined-stdio-server.js +4 -4
  13. package/dist/bin/mcp-scraper-install.cjs +3 -3
  14. package/dist/bin/mcp-scraper-install.cjs.map +1 -1
  15. package/dist/bin/mcp-scraper-install.js +2 -2
  16. package/dist/bin/mcp-stdio-server.cjs +369 -18
  17. package/dist/bin/mcp-stdio-server.cjs.map +1 -1
  18. package/dist/bin/mcp-stdio-server.js +2 -2
  19. package/dist/{chunk-7Y6JDBML.js → chunk-ACIUCZ27.js} +3 -3
  20. package/dist/chunk-ACIUCZ27.js.map +1 -0
  21. package/dist/{chunk-WQAIOY2D.js → chunk-N33YEY6W.js} +374 -19
  22. package/dist/chunk-N33YEY6W.js.map +1 -0
  23. package/dist/chunk-PVWWY5NV.js +7 -0
  24. package/dist/chunk-PVWWY5NV.js.map +1 -0
  25. package/dist/{chunk-WJ2LWHPA.js → chunk-UFD7N36R.js} +2 -2
  26. package/dist/{server-UOQ2JIUQ.js → server-TKWY47JA.js} +43 -10
  27. package/dist/server-TKWY47JA.js.map +1 -0
  28. package/docs/mcp-tool-manifest.generated.json +45 -10
  29. package/package.json +1 -1
  30. package/dist/chunk-2MX5WOII.js +0 -7
  31. package/dist/chunk-2MX5WOII.js.map +0 -1
  32. package/dist/chunk-7Y6JDBML.js.map +0 -1
  33. package/dist/chunk-WQAIOY2D.js.map +0 -1
  34. package/dist/server-UOQ2JIUQ.js.map +0 -1
  35. /package/dist/{chunk-WJ2LWHPA.js.map → chunk-UFD7N36R.js.map} +0 -0
@@ -9054,6 +9054,104 @@ var init_seo_link_graph = __esm({
9054
9054
  }
9055
9055
  });
9056
9056
 
9057
+ // src/api/seo-link-report.ts
9058
+ function registrableDomain(host) {
9059
+ const h = host.replace(/^www\./, "").toLowerCase();
9060
+ const parts = h.split(".");
9061
+ return parts.length <= 2 ? h : parts.slice(-2).join(".");
9062
+ }
9063
+ function buildLinkReport(edges, metrics, siteUrl) {
9064
+ let siteReg = "";
9065
+ try {
9066
+ siteReg = registrableDomain(new URL(siteUrl).hostname);
9067
+ } catch {
9068
+ siteReg = "";
9069
+ }
9070
+ const internalEdges = edges.filter((e) => e.internal);
9071
+ const pages = metrics.length;
9072
+ const orphans = metrics.filter((m) => m.orphan).length;
9073
+ const brokenInternal = internalEdges.filter((e) => e.targetStatus != null && e.targetStatus >= 400).length;
9074
+ const sumInlinks = metrics.reduce((a, m) => a + m.inlinks, 0);
9075
+ const sumOutlinks = metrics.reduce((a, m) => a + m.outlinksInternal + m.outlinksExternal, 0);
9076
+ const distribution = { zero: 0, oneToTwo: 0, threeToTen: 0, elevenPlus: 0 };
9077
+ for (const m of metrics) {
9078
+ if (m.inlinks === 0) distribution.zero++;
9079
+ else if (m.inlinks <= 2) distribution.oneToTwo++;
9080
+ else if (m.inlinks <= 10) distribution.threeToTen++;
9081
+ else distribution.elevenPlus++;
9082
+ }
9083
+ const topByInlinks = [...metrics].sort((a, b) => b.inlinks - a.inlinks).slice(0, 20).map((m) => ({ url: m.url, inlinks: m.inlinks, outlinksInternal: m.outlinksInternal, outlinksExternal: m.outlinksExternal }));
9084
+ const domMap = /* @__PURE__ */ new Map();
9085
+ let externalTotal = 0;
9086
+ for (const e of edges) {
9087
+ let domain;
9088
+ try {
9089
+ domain = registrableDomain(new URL(e.to).hostname);
9090
+ } catch {
9091
+ continue;
9092
+ }
9093
+ if (!domain || domain === siteReg) continue;
9094
+ externalTotal++;
9095
+ const d = domMap.get(domain) ?? { links: 0, nofollow: 0, pages: /* @__PURE__ */ new Set() };
9096
+ d.links++;
9097
+ if (e.nofollow) d.nofollow++;
9098
+ d.pages.add(e.from);
9099
+ domMap.set(domain, d);
9100
+ }
9101
+ const externalDomains = [...domMap.entries()].map(([domain, d]) => ({ domain, links: d.links, nofollow: d.nofollow, pages: d.pages.size })).sort((a, b) => b.links - a.links);
9102
+ const round = (n) => Math.round(n * 10) / 10;
9103
+ return {
9104
+ summary: {
9105
+ internal: {
9106
+ totalLinks: internalEdges.length,
9107
+ pages,
9108
+ orphans,
9109
+ brokenInternal,
9110
+ avgInlinks: pages ? round(sumInlinks / pages) : 0,
9111
+ avgOutlinks: pages ? round(sumOutlinks / pages) : 0,
9112
+ distribution,
9113
+ topByInlinks
9114
+ },
9115
+ external: {
9116
+ totalLinks: externalTotal,
9117
+ uniqueDomains: externalDomains.length,
9118
+ topDomains: externalDomains.slice(0, 20)
9119
+ }
9120
+ },
9121
+ externalDomains
9122
+ };
9123
+ }
9124
+ function renderLinkReport(r) {
9125
+ const s = r.summary;
9126
+ const lines = [
9127
+ `## Link analysis`,
9128
+ `**Internal:** ${s.internal.totalLinks} links \xB7 ${s.internal.pages} pages \xB7 avg ${s.internal.avgInlinks} inlinks/page \xB7 ${s.internal.orphans} orphans \xB7 ${s.internal.brokenInternal} broken`,
9129
+ `**External:** ${s.external.totalLinks} links to ${s.external.uniqueDomains} domains`,
9130
+ ``,
9131
+ `### Inlink distribution`,
9132
+ `- 0 inlinks (orphans): ${s.internal.distribution.zero}`,
9133
+ `- 1\u20132: ${s.internal.distribution.oneToTwo}`,
9134
+ `- 3\u201310: ${s.internal.distribution.threeToTen}`,
9135
+ `- 11+: ${s.internal.distribution.elevenPlus}`,
9136
+ ``,
9137
+ `### Top 20 internal pages by inlinks`,
9138
+ `| inlinks | out (int/ext) | URL |`,
9139
+ `|---|---|---|`,
9140
+ ...s.internal.topByInlinks.map((p) => `| ${p.inlinks} | ${p.outlinksInternal}/${p.outlinksExternal} | ${p.url} |`),
9141
+ ``,
9142
+ `### Top 20 external domains by links`,
9143
+ `| links | nofollow | from pages | domain |`,
9144
+ `|---|---|---|---|`,
9145
+ ...s.external.topDomains.map((d) => `| ${d.links} | ${d.nofollow} | ${d.pages} | ${d.domain} |`)
9146
+ ];
9147
+ return lines.join("\n");
9148
+ }
9149
+ var init_seo_link_report = __esm({
9150
+ "src/api/seo-link-report.ts"() {
9151
+ "use strict";
9152
+ }
9153
+ });
9154
+
9057
9155
  // src/api/seo-issues.ts
9058
9156
  function dupes(pages, key) {
9059
9157
  const groups = /* @__PURE__ */ new Map();
@@ -9168,6 +9266,143 @@ var init_seo_issues = __esm({
9168
9266
  }
9169
9267
  });
9170
9268
 
9269
+ // src/api/image-audit.ts
9270
+ function formatBytes(n) {
9271
+ if (n == null) return null;
9272
+ if (n < 1024) return `${n} B`;
9273
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
9274
+ return `${(n / (1024 * 1024)).toFixed(2)} MB`;
9275
+ }
9276
+ function collectUrls(pages) {
9277
+ const byKey = /* @__PURE__ */ new Map();
9278
+ for (const p of pages) for (const raw of p.imageLinks || []) {
9279
+ const u = decodeEntities(raw);
9280
+ if (!/^https?:\/\//i.test(u)) continue;
9281
+ if (/[{}]|%7[bd]/i.test(u)) continue;
9282
+ const k = dedupKey(u);
9283
+ const prev = byKey.get(k);
9284
+ if (!prev || /^https:/i.test(u) && /^http:/i.test(prev)) byKey.set(k, u);
9285
+ }
9286
+ return [...byKey.values()];
9287
+ }
9288
+ async function sizeAndType(url, timeoutMs) {
9289
+ const ctrl = new AbortController();
9290
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
9291
+ const read = (res) => ({
9292
+ len: res.headers.get("content-length"),
9293
+ cr: res.headers.get("content-range"),
9294
+ ct: res.headers.get("content-type"),
9295
+ status: res.status
9296
+ });
9297
+ try {
9298
+ let bytes = null;
9299
+ let ct = null;
9300
+ let status = null;
9301
+ try {
9302
+ const h = read(await fetch(url, { method: "HEAD", redirect: "follow", signal: ctrl.signal }));
9303
+ status = h.status;
9304
+ ct = h.ct;
9305
+ if (h.len) bytes = Number(h.len);
9306
+ } catch {
9307
+ }
9308
+ if (bytes == null) {
9309
+ const g = await fetch(url, { method: "GET", headers: { Range: "bytes=0-0" }, redirect: "follow", signal: ctrl.signal });
9310
+ const r = read(g);
9311
+ status = status ?? r.status;
9312
+ ct = ct ?? r.ct;
9313
+ if (r.cr && r.cr.includes("/")) {
9314
+ const tail = r.cr.split("/")[1];
9315
+ if (tail && tail !== "*") bytes = Number(tail);
9316
+ } else if (r.len) bytes = Number(r.len);
9317
+ try {
9318
+ await g.body?.cancel();
9319
+ } catch {
9320
+ }
9321
+ }
9322
+ return { url, status, bytes: Number.isFinite(bytes) ? bytes : null, contentType: ct };
9323
+ } catch (e) {
9324
+ return { url, status: null, bytes: null, contentType: null, error: String(e.message || e) };
9325
+ } finally {
9326
+ clearTimeout(t);
9327
+ }
9328
+ }
9329
+ async function pool(items, n, fn) {
9330
+ const out = new Array(items.length);
9331
+ let i = 0;
9332
+ await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => {
9333
+ while (i < items.length) {
9334
+ const idx = i++;
9335
+ out[idx] = await fn(items[idx]);
9336
+ }
9337
+ }));
9338
+ return out;
9339
+ }
9340
+ async function auditImages(pages, opts = {}) {
9341
+ const concurrency = opts.concurrency ?? 12;
9342
+ const timeoutMs = opts.timeoutMs ?? 12e3;
9343
+ const urls = collectUrls(pages).slice(0, opts.max ?? 5e3);
9344
+ const heads = await pool(urls, concurrency, (u) => sizeAndType(u, timeoutMs));
9345
+ const rows = heads.map((r) => {
9346
+ const format = formatOf(r.contentType, r.url);
9347
+ return {
9348
+ ...r,
9349
+ size: formatBytes(r.bytes),
9350
+ format,
9351
+ over100kb: r.bytes != null && r.bytes > OVER_BYTES,
9352
+ legacyFormat: format !== "unknown" && !MODERN.has(format)
9353
+ };
9354
+ });
9355
+ const sized = rows.filter((r) => r.bytes != null);
9356
+ const totalBytes = sized.reduce((a, r) => a + r.bytes, 0);
9357
+ const formatCounts = {};
9358
+ for (const r of rows) formatCounts[r.format] = (formatCounts[r.format] || 0) + 1;
9359
+ return {
9360
+ rows: rows.sort((a, b) => (b.bytes ?? 0) - (a.bytes ?? 0)),
9361
+ summary: {
9362
+ unique: rows.length,
9363
+ sized: sized.length,
9364
+ totalBytes,
9365
+ totalSize: formatBytes(totalBytes) ?? "0 B",
9366
+ avgSize: formatBytes(Math.round(totalBytes / (sized.length || 1))) ?? "0 B",
9367
+ over100kb: rows.filter((r) => r.over100kb).length,
9368
+ legacyFormat: rows.filter((r) => r.legacyFormat).length,
9369
+ formatCounts
9370
+ }
9371
+ };
9372
+ }
9373
+ function renderImageSection(audit) {
9374
+ const s = audit.summary;
9375
+ const fmt = Object.entries(s.formatCounts).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k}:${v}`).join(" ");
9376
+ const heaviest = audit.rows.filter((r) => r.over100kb).slice(0, 15);
9377
+ const lines = [
9378
+ `## Images`,
9379
+ `**${s.unique} unique images** \xB7 ${s.totalSize} total \xB7 ${s.avgSize} avg \xB7 ${s.over100kb} over 100 KB \xB7 ${s.legacyFormat} legacy format`,
9380
+ `Formats: ${fmt}`
9381
+ ];
9382
+ if (heaviest.length) {
9383
+ lines.push("", "| Size | Format | URL |", "|------|--------|-----|");
9384
+ for (const r of heaviest) lines.push(`| ${r.size} | ${r.format} | ${r.url} |`);
9385
+ }
9386
+ return lines.join("\n");
9387
+ }
9388
+ var OVER_BYTES, MODERN, decodeEntities, dedupKey, formatOf;
9389
+ var init_image_audit = __esm({
9390
+ "src/api/image-audit.ts"() {
9391
+ "use strict";
9392
+ OVER_BYTES = 100 * 1024;
9393
+ MODERN = /* @__PURE__ */ new Set(["webp", "avif", "svg", "svg+xml"]);
9394
+ decodeEntities = (u) => u.replace(/&amp;/g, "&").replace(/&#0?38;/g, "&").replace(/&#x26;/gi, "&").trim();
9395
+ dedupKey = (u) => u.replace(/^https?:\/\//i, "//").replace(/\/$/, "");
9396
+ formatOf = (ct, url) => {
9397
+ if (ct) {
9398
+ const m = ct.split(";")[0].trim().toLowerCase();
9399
+ if (m.startsWith("image/")) return m.replace("image/", "");
9400
+ }
9401
+ return (url.split("?")[0].match(/\.([a-z0-9]+)$/i)?.[1] || "unknown").toLowerCase();
9402
+ };
9403
+ }
9404
+ });
9405
+
9171
9406
  // src/api/blob-store.ts
9172
9407
  function byteLength(data) {
9173
9408
  return Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data);
@@ -9582,10 +9817,14 @@ function chunk(items, size) {
9582
9817
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
9583
9818
  return out;
9584
9819
  }
9585
- function buildArtifacts(job, pages, branding) {
9820
+ function buildArtifacts(job, pages, branding, imageAudit) {
9586
9821
  const { edges, metrics } = buildLinkGraph(pages, job.startUrl);
9587
9822
  const issues = computeIssues(pages, metrics);
9588
- const reportMd = renderIssueReport(job.startUrl, pages, issues, metrics);
9823
+ const linkReport = buildLinkReport(edges, [...metrics.values()], job.startUrl);
9824
+ let reportMd = renderIssueReport(job.startUrl, pages, issues, metrics);
9825
+ if (imageAudit) reportMd += `
9826
+
9827
+ ${renderImageSection(imageAudit)}`;
9589
9828
  const pageRows = pages.map((p) => {
9590
9829
  const { bodyMarkdown: _b, schema: _s, outlinks: _o, ...rest } = p;
9591
9830
  const m = metrics.get(p.url);
@@ -9597,8 +9836,15 @@ function buildArtifacts(job, pages, branding) {
9597
9836
  { name: "issues.json", body: JSON.stringify(issues, null, 2), type: "application/json" },
9598
9837
  { name: "pages.jsonl", body: jsonl(pageRows), type: "application/x-ndjson" },
9599
9838
  { name: "links.jsonl", body: jsonl(edges), type: "application/x-ndjson" },
9600
- { name: "link-metrics.jsonl", body: jsonl([...metrics.values()]), type: "application/x-ndjson" }
9839
+ { name: "link-metrics.jsonl", body: jsonl([...metrics.values()]), type: "application/x-ndjson" },
9840
+ { name: "link-report.md", body: renderLinkReport(linkReport), type: "text/markdown" },
9841
+ { name: "links-summary.json", body: JSON.stringify(linkReport.summary, null, 2), type: "application/json" },
9842
+ { name: "external-domains.json", body: JSON.stringify(linkReport.externalDomains, null, 2), type: "application/json" }
9601
9843
  ];
9844
+ if (imageAudit) {
9845
+ files.push({ name: "images.jsonl", body: jsonl(imageAudit.rows), type: "application/x-ndjson" });
9846
+ files.push({ name: "images-summary.json", body: JSON.stringify(imageAudit.summary, null, 2), type: "application/json" });
9847
+ }
9602
9848
  if (branding) files.push({ name: "branding.json", body: JSON.stringify(branding, null, 2), type: "application/json" });
9603
9849
  return files;
9604
9850
  }
@@ -9609,7 +9855,9 @@ var init_site_extract = __esm({
9609
9855
  init_client();
9610
9856
  init_site_extractor();
9611
9857
  init_seo_link_graph();
9858
+ init_seo_link_report();
9612
9859
  init_seo_issues();
9860
+ init_image_audit();
9613
9861
  init_blob_store();
9614
9862
  init_screenshot();
9615
9863
  init_browser_service_env();
@@ -9657,10 +9905,14 @@ var init_site_extract = __esm({
9657
9905
  });
9658
9906
  }
9659
9907
  const branding = Array.isArray(job.options.formats) && job.options.formats.includes("branding") ? await step.run("branding", () => extractBranding(job.startUrl, key).catch(() => null)) : null;
9908
+ const imageAudit = Array.isArray(job.options.formats) && job.options.formats.includes("images") ? await step.run("image-audit", async () => {
9909
+ const pages = await getExtractedPages(jobId);
9910
+ return auditImages(pages, { concurrency: 12, timeoutMs: 12e3 });
9911
+ }) : null;
9660
9912
  const artifacts = await step.run("finalize", async () => {
9661
9913
  const pages = await getExtractedPages(jobId);
9662
9914
  const store = getBlobStore();
9663
- const files = buildArtifacts(job, pages, branding);
9915
+ const files = buildArtifacts(job, pages, branding, imageAudit);
9664
9916
  const stored = [];
9665
9917
  for (const f of files) stored.push(await store.put(`extract/${jobId}/${f.name}`, f.body, f.type));
9666
9918
  await completeExtractJob(jobId, stored);
@@ -9935,7 +10187,7 @@ var init_catalog_seed = __esm({
9935
10187
  kind: "simple",
9936
10188
  endpoint: "/extract-site",
9937
10189
  mcpName: "extract_site",
9938
- tagline: "Map + bulk scrape + SEO report",
10190
+ tagline: "Map + bulk scrape (content)",
9939
10191
  params: [
9940
10192
  URL_PARAM,
9941
10193
  { key: "maxPages", label: "Max pages", type: "number", min: 1, max: 1e4, default: 100 },
@@ -9943,7 +10195,21 @@ var init_catalog_seed = __esm({
9943
10195
  { key: "rotateProxies", label: "Rotate proxies (blocked sites)", type: "boolean", default: true },
9944
10196
  { key: "background", label: "Run in background", type: "boolean", default: true, advanced: true }
9945
10197
  ],
9946
- examples: ["https://steenshoney.com/ (full-site SEO crawl)"]
10198
+ examples: ["https://steenshoney.com/ (full-site content crawl)"]
10199
+ },
10200
+ {
10201
+ key: "audit_site",
10202
+ label: "Audit",
10203
+ kind: "simple",
10204
+ endpoint: "/extract-site",
10205
+ mcpName: "audit_site",
10206
+ tagline: "Technical SEO audit (Screaming-Frog style)",
10207
+ params: [
10208
+ URL_PARAM,
10209
+ { key: "maxPages", label: "Max pages", type: "number", min: 1, max: 1e4, default: 100 },
10210
+ { key: "rotateProxies", label: "Rotate proxies (blocked sites)", type: "boolean", default: true }
10211
+ ],
10212
+ examples: ["https://steenshoney.com/ (issues + link graph + image audit)"]
9947
10213
  }
9948
10214
  ]
9949
10215
  },
@@ -16037,7 +16303,7 @@ function saveFullReport(full) {
16037
16303
  function reportSavingActive() {
16038
16304
  return reportSavingEnabled && process.env.MCP_SCRAPER_SAVE_REPORTS !== "false";
16039
16305
  }
16040
- function saveBulkSite(siteUrl, pages, seo) {
16306
+ function saveBulkSite(siteUrl, pages, seo, imageAudit) {
16041
16307
  if (!reportSavingActive()) return null;
16042
16308
  try {
16043
16309
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
@@ -16060,15 +16326,35 @@ function saveBulkSite(siteUrl, pages, seo) {
16060
16326
  (0, import_node_fs5.writeFileSync)((0, import_node_path7.join)(pagesDir, fname), content, "utf8");
16061
16327
  return `| ${i + 1} | ${cell(p.title ?? "Untitled")} | ${p.url} | pages/${fname} |`;
16062
16328
  });
16329
+ const dataFilesSection = seo ? [
16330
+ `
16331
+ ## Data files (in this folder)`,
16332
+ `- \`report.md\` \u2014 SEO issues summary (counts + example URLs)`,
16333
+ `- \`issues.json\` \u2014 every finding with the full list of offender URLs`,
16334
+ `- \`pages.jsonl\` \u2014 per-page SEO fields, one JSON object per page`,
16335
+ `- \`links.jsonl\` \u2014 internal/external link edges (anchor, rel, target status)`,
16336
+ `- \`link-metrics.jsonl\` \u2014 inlinks, crawl depth, orphan flags per page`,
16337
+ `- \`link-report.md\` \u2014 link analysis: top internal pages by inlinks, top external domains, distribution`,
16338
+ `- \`links-summary.json\` \u2014 link totals + top-20 internal pages + top-20 external domains`,
16339
+ `- \`external-domains.json\` \u2014 every external domain linked to, ordered by link count`,
16340
+ ...imageAudit ? [
16341
+ `- \`images.jsonl\` \u2014 every image with byte size, format, over-100KB and legacy-format flags`,
16342
+ `- \`images-summary.json\` \u2014 image totals (count, total weight, over-100KB, legacy)`
16343
+ ] : [],
16344
+ ...seo.branding ? [`- \`branding.json\` \u2014 site logo, colors, fonts`] : []
16345
+ ].join("\n") : "";
16063
16346
  const index = [
16064
16347
  `# Site Extract: ${siteUrl}`,
16065
- `**${pages.length} pages** \u2014 each saved as an individual Markdown file under \`pages/\`.`,
16348
+ `**${pages.length} pages** crawled. Everything is in this folder.`,
16349
+ `- Page content: one Markdown file per page under \`pages/\` (listed in the table below).`,
16350
+ seo ? `- SEO crawl data: the data files listed below.` : "",
16351
+ dataFilesSection,
16066
16352
  `
16067
16353
  ## Pages
16068
16354
  | # | Title | URL | File |
16069
16355
  |---|-------|-----|------|
16070
16356
  ${indexRows.join("\n")}`
16071
- ].join("\n");
16357
+ ].filter(Boolean).join("\n");
16072
16358
  const indexFile = (0, import_node_path7.join)(dir, "index.md");
16073
16359
  (0, import_node_fs5.writeFileSync)(indexFile, index, "utf8");
16074
16360
  let seoFiles;
@@ -16083,8 +16369,23 @@ ${indexRows.join("\n")}`
16083
16369
  (0, import_node_fs5.writeFileSync)(linksJsonl, toJsonl(seo.edges), "utf8");
16084
16370
  (0, import_node_fs5.writeFileSync)(metricsJsonl, toJsonl(seo.metrics), "utf8");
16085
16371
  (0, import_node_fs5.writeFileSync)(issuesFile, JSON.stringify(seo.issues, null, 2), "utf8");
16086
- (0, import_node_fs5.writeFileSync)(reportFile, seo.reportMd, "utf8");
16087
- seoFiles = [pagesJsonl, linksJsonl, metricsJsonl, issuesFile, reportFile];
16372
+ (0, import_node_fs5.writeFileSync)(reportFile, seo.reportMd + (imageAudit ? `
16373
+
16374
+ ${renderImageSection(imageAudit)}` : ""), "utf8");
16375
+ const linkReportFile = (0, import_node_path7.join)(dir, "link-report.md");
16376
+ const linksSummaryFile = (0, import_node_path7.join)(dir, "links-summary.json");
16377
+ const externalDomainsFile = (0, import_node_path7.join)(dir, "external-domains.json");
16378
+ (0, import_node_fs5.writeFileSync)(linkReportFile, renderLinkReport(seo.linkReport), "utf8");
16379
+ (0, import_node_fs5.writeFileSync)(linksSummaryFile, JSON.stringify(seo.linkReport.summary, null, 2), "utf8");
16380
+ (0, import_node_fs5.writeFileSync)(externalDomainsFile, JSON.stringify(seo.linkReport.externalDomains, null, 2), "utf8");
16381
+ seoFiles = [pagesJsonl, linksJsonl, metricsJsonl, issuesFile, reportFile, linkReportFile, linksSummaryFile, externalDomainsFile];
16382
+ if (imageAudit) {
16383
+ const imagesJsonl = (0, import_node_path7.join)(dir, "images.jsonl");
16384
+ const imagesSummary = (0, import_node_path7.join)(dir, "images-summary.json");
16385
+ (0, import_node_fs5.writeFileSync)(imagesJsonl, imageAudit.rows.map((r) => JSON.stringify(r)).join("\n"), "utf8");
16386
+ (0, import_node_fs5.writeFileSync)(imagesSummary, JSON.stringify(imageAudit.summary, null, 2), "utf8");
16387
+ seoFiles.push(imagesJsonl, imagesSummary);
16388
+ }
16088
16389
  if (seo.branding) {
16089
16390
  const brandingFile = (0, import_node_path7.join)(dir, "branding.json");
16090
16391
  (0, import_node_fs5.writeFileSync)(brandingFile, JSON.stringify(seo.branding, null, 2), "utf8");
@@ -16529,6 +16830,7 @@ function buildSeoExport(siteUrl, pages, branding) {
16529
16830
  metrics: [...metrics.values()],
16530
16831
  issues,
16531
16832
  reportMd: renderIssueReport(siteUrl, pages, issues, metrics),
16833
+ linkReport: buildLinkReport(edges, [...metrics.values()], siteUrl),
16532
16834
  branding: branding ?? void 0
16533
16835
  };
16534
16836
  }
@@ -16559,24 +16861,23 @@ function formatExtractSite(raw, input) {
16559
16861
  durationMs: d.durationMs ?? 0
16560
16862
  };
16561
16863
  if (pages.length > BULK_PAGE_THRESHOLD) {
16562
- const seo = buildSeoExport(input.url, pages, d.branding);
16563
16864
  const bulk = saveBulkSite(input.url, pages.map((p) => ({
16564
16865
  url: String(p.url ?? ""),
16565
16866
  title: p.title ?? null,
16566
16867
  bodyMarkdown: p.bodyMarkdown ?? null,
16567
16868
  metaDescription: p.metaDescription ?? null,
16568
16869
  schemaTypes: schemaTypesOf(p)
16569
- })), seo);
16870
+ })));
16570
16871
  const preview = pages.slice(0, BULK_PAGE_THRESHOLD).map(pageRow).join("\n");
16571
- const seoLine = bulk?.seoFiles?.length ? `
16572
- - **SEO data:** \`report.md\` (issues summary), \`issues.json\` (findings + offender URLs), \`pages.jsonl\` (per-page fields), \`links.jsonl\` (link edges), \`link-metrics.jsonl\` (inlinks / crawl depth / orphans)` : "";
16573
16872
  const location2 = bulk ? `
16574
16873
  ## \u{1F4C1} Bulk scrape saved
16575
- - **Folder:** \`${bulk.dir}\`
16576
- - **Index:** \`${bulk.indexFile}\`
16577
- - **Files:** ${bulk.fileCount} page files under \`pages/\`${seoLine}
16874
+ - **Folder:** \`${bulk.dir}\` \u2190 all scraped page content is here
16875
+ - **Start here:** \`${bulk.indexFile}\` \u2014 lists every page and its file
16876
+ - **Page content:** ${bulk.fileCount} Markdown files under \`pages/\` (one per page)
16877
+
16878
+ 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/\`.
16578
16879
 
16579
- Read \`index.md\` for the full page list, open files in \`pages/\` for full content, or read \`pages.jsonl\` / \`link-metrics.jsonl\` for the SEO crawl data. Page bodies are not inlined here to keep the context window clear.` : `
16880
+ \u{1F4A1} Want the full technical SEO audit (headings, link graph, indexability, image sizes/formats, issues)? Run \`audit_site\` on this URL instead.` : `
16580
16881
  ## \u26A0\uFE0F Bulk scrape not saved
16581
16882
  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.`;
16582
16883
  const full2 = [
@@ -16620,6 +16921,62 @@ ${body}` : "_(no content extracted)_"
16620
16921
  ${pageDetails}${tips}`;
16621
16922
  return { ...oneBlock(full, diskReport), structuredContent };
16622
16923
  }
16924
+ async function formatAuditSite(raw, input) {
16925
+ const parsed = parseData(raw);
16926
+ if ("error" in parsed) return { content: [{ type: "text", text: parsed.error }], isError: true };
16927
+ const d = parsed.data;
16928
+ const pages = d.pages ?? [];
16929
+ const schemaTypesOf = (p) => p.kpo?.type ?? (Array.isArray(p.schema) && p.schema.length ? [`${p.schema.length} block(s)`] : []);
16930
+ const seo = buildSeoExport(input.url, pages, d.branding);
16931
+ const imageAudit = await auditImages(pages, { concurrency: 12 });
16932
+ const bulk = saveBulkSite(input.url, pages.map((p) => ({
16933
+ url: String(p.url ?? ""),
16934
+ title: p.title ?? null,
16935
+ bodyMarkdown: p.bodyMarkdown ?? null,
16936
+ metaDescription: p.metaDescription ?? null,
16937
+ schemaTypes: schemaTypesOf(p)
16938
+ })), seo, imageAudit);
16939
+ 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");
16940
+ const img = imageAudit.summary;
16941
+ const imgLine = `${img.unique} images \xB7 ${img.totalSize} total \xB7 ${img.over100kb} over 100 KB \xB7 ${img.legacyFormat} legacy format`;
16942
+ const lr = seo.linkReport.summary;
16943
+ const topExt = lr.external.topDomains.slice(0, 3).map((dm) => `${dm.domain} (${dm.links})`).join(", ") || "\u2014";
16944
+ const linkLine = `${lr.internal.totalLinks} internal / ${lr.external.totalLinks} external links \xB7 ${lr.internal.orphans} orphans \xB7 ${lr.internal.brokenInternal} broken \xB7 avg ${lr.internal.avgInlinks} inlinks/page \xB7 top external: ${topExt}`;
16945
+ const durationLine = `**${pages.length} pages** \xB7 ${((d.durationMs ?? 0) / 1e3).toFixed(1)}s`;
16946
+ const structuredContent = {
16947
+ url: input.url,
16948
+ pageCount: pages.length,
16949
+ durationMs: d.durationMs ?? 0,
16950
+ bulkFolder: bulk?.dir ?? null,
16951
+ issues: Object.fromEntries(Object.entries(seo.issues).map(([k, v]) => [k, v.count])),
16952
+ images: { unique: img.unique, totalBytes: img.totalBytes, over100kb: img.over100kb, legacyFormat: img.legacyFormat },
16953
+ links: { internal: lr.internal.totalLinks, external: lr.external.totalLinks, orphans: lr.internal.orphans, brokenInternal: lr.internal.brokenInternal, externalDomains: lr.external.uniqueDomains }
16954
+ };
16955
+ const location2 = bulk ? `
16956
+ ## \u{1F4C1} Technical audit saved
16957
+ - **Folder:** \`${bulk.dir}\` \u2190 full audit + page content here
16958
+ - **Start here:** \`${bulk.indexFile}\` \u2014 maps every file in the folder
16959
+ - **Data:** \`report.md\` (issues + image audit), \`issues.json\`, \`pages.jsonl\` (per-page fields), \`links.jsonl\`, \`link-metrics.jsonl\`, \`images.jsonl\`
16960
+
16961
+ 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.` : `
16962
+ ## \u26A0\uFE0F Audit not saved
16963
+ Report saving is disabled in this environment. Enable \`MCP_SCRAPER_SAVE_REPORTS\` to capture the technical audit.`;
16964
+ const full = [
16965
+ `# Technical SEO Audit: ${input.url}`,
16966
+ durationLine,
16967
+ location2,
16968
+ `
16969
+ ## Top issues
16970
+ ${topIssues || "_none found_"}`,
16971
+ `
16972
+ ## Links
16973
+ ${linkLine}`,
16974
+ `
16975
+ ## Images
16976
+ ${imgLine}`
16977
+ ].join("\n");
16978
+ return { content: [{ type: "text", text: full }], structuredContent };
16979
+ }
16623
16980
  function formatYoutubeHarvest(raw, input) {
16624
16981
  const parsed = parseData(raw);
16625
16982
  if ("error" in parsed) return { content: [{ type: "text", text: parsed.error }], isError: true };
@@ -17804,7 +18161,9 @@ var init_mcp_response_formatter = __esm({
17804
18161
  init_errors();
17805
18162
  init_workflow_catalog();
17806
18163
  init_seo_link_graph();
18164
+ init_seo_link_report();
17807
18165
  init_seo_issues();
18166
+ init_image_audit();
17808
18167
  reportSavingEnabled = true;
17809
18168
  BULK_PAGE_THRESHOLD = 25;
17810
18169
  BULK_URL_THRESHOLD = 500;
@@ -23339,7 +23698,7 @@ var PACKAGE_VERSION;
23339
23698
  var init_version = __esm({
23340
23699
  "src/version.ts"() {
23341
23700
  "use strict";
23342
- PACKAGE_VERSION = "0.3.15";
23701
+ PACKAGE_VERSION = "0.3.17";
23343
23702
  }
23344
23703
  });
23345
23704
 
@@ -23377,7 +23736,7 @@ NOTES
23377
23736
  });
23378
23737
 
23379
23738
  // src/mcp/mcp-tool-schemas.ts
23380
- var import_zod27, HarvestPaaInputSchema, ExtractUrlInputSchema, MapSiteUrlsInputSchema, ExtractSiteInputSchema, YoutubeHarvestInputSchema, YoutubeTranscribeInputSchema, FacebookPageIntelInputSchema, FacebookAdSearchInputSchema, FacebookAdTranscribeInputSchema, FacebookVideoTranscribeInputSchema, InstagramProfileContentInputSchema, InstagramMediaDownloadInputSchema, MapsPlaceIntelInputSchema, MapsSearchInputSchema, DirectoryWorkflowInputSchema, RankTrackerModeSchema, RankTrackerBlueprintInputSchema, NullableString, MapsSearchAttemptOutput, MapsSearchOutputSchema, DirectoryMapsBusinessOutput, DirectoryWorkflowOutputSchema, RankTrackerToolPlanOutput, RankTrackerTableOutput, RankTrackerCronJobOutput, RankTrackerBlueprintOutputSchema, OrganicResultOutput, AiOverviewOutput, EntityIdsOutput, HarvestPaaOutputSchema, SearchSerpOutputSchema, ExtractUrlOutputSchema, ExtractSiteOutputSchema, MapsPlaceIntelOutputSchema, CreditsInfoOutputSchema, MapSiteUrlsOutputSchema, YoutubeHarvestOutputSchema, FacebookAdSearchOutputSchema, FacebookPageIntelOutputSchema, FacebookVideoTranscribeOutputSchema, TranscriptChunkOutput, InstagramBrowserOutput, InstagramPaginationOutput, InstagramProfileContentOutputSchema, InstagramMediaTrackOutput, InstagramDownloadOutput, InstagramMediaDownloadOutputSchema, YoutubeTranscribeOutputSchema, FacebookAdTranscribeOutputSchema, CaptureSerpSnapshotOutputSchema, CaptureSerpPageSnapshotsOutputSchema, CreditsInfoInputSchema, WorkflowIdSchema2, WorkflowListInputSchema, WorkflowSuggestInputSchema, WorkflowRunInputSchema, WorkflowStepInputSchema, WorkflowStatusInputSchema, WorkflowArtifactReadInputSchema, WorkflowRecipeOutput, WorkflowDefinitionOutput, WorkflowArtifactOutput, WorkflowListOutputSchema, WorkflowSuggestOutputSchema, WorkflowRunOutputSchema, WorkflowStepOutputSchema, WorkflowStatusOutputSchema, WorkflowArtifactReadOutputSchema, SearchSerpInputSchema, CaptureSerpSnapshotInputSchema, ScreenshotInputSchema, CaptureSerpPageSnapshotsInputSchema;
23739
+ var import_zod27, HarvestPaaInputSchema, ExtractUrlInputSchema, MapSiteUrlsInputSchema, ExtractSiteInputSchema, AuditSiteInputSchema, YoutubeHarvestInputSchema, YoutubeTranscribeInputSchema, FacebookPageIntelInputSchema, FacebookAdSearchInputSchema, FacebookAdTranscribeInputSchema, FacebookVideoTranscribeInputSchema, InstagramProfileContentInputSchema, InstagramMediaDownloadInputSchema, MapsPlaceIntelInputSchema, MapsSearchInputSchema, DirectoryWorkflowInputSchema, RankTrackerModeSchema, RankTrackerBlueprintInputSchema, NullableString, MapsSearchAttemptOutput, MapsSearchOutputSchema, DirectoryMapsBusinessOutput, DirectoryWorkflowOutputSchema, RankTrackerToolPlanOutput, RankTrackerTableOutput, RankTrackerCronJobOutput, RankTrackerBlueprintOutputSchema, OrganicResultOutput, AiOverviewOutput, EntityIdsOutput, HarvestPaaOutputSchema, SearchSerpOutputSchema, ExtractUrlOutputSchema, ExtractSiteOutputSchema, AuditSiteOutputSchema, MapsPlaceIntelOutputSchema, CreditsInfoOutputSchema, MapSiteUrlsOutputSchema, YoutubeHarvestOutputSchema, FacebookAdSearchOutputSchema, FacebookPageIntelOutputSchema, FacebookVideoTranscribeOutputSchema, TranscriptChunkOutput, InstagramBrowserOutput, InstagramPaginationOutput, InstagramProfileContentOutputSchema, InstagramMediaTrackOutput, InstagramDownloadOutput, InstagramMediaDownloadOutputSchema, YoutubeTranscribeOutputSchema, FacebookAdTranscribeOutputSchema, CaptureSerpSnapshotOutputSchema, CaptureSerpPageSnapshotsOutputSchema, CreditsInfoInputSchema, WorkflowIdSchema2, WorkflowListInputSchema, WorkflowSuggestInputSchema, WorkflowRunInputSchema, WorkflowStepInputSchema, WorkflowStatusInputSchema, WorkflowArtifactReadInputSchema, WorkflowRecipeOutput, WorkflowDefinitionOutput, WorkflowArtifactOutput, WorkflowListOutputSchema, WorkflowSuggestOutputSchema, WorkflowRunOutputSchema, WorkflowStepOutputSchema, WorkflowStatusOutputSchema, WorkflowArtifactReadOutputSchema, SearchSerpInputSchema, CaptureSerpSnapshotInputSchema, ScreenshotInputSchema, CaptureSerpPageSnapshotsInputSchema;
23381
23740
  var init_mcp_tool_schemas = __esm({
23382
23741
  "src/mcp/mcp-tool-schemas.ts"() {
23383
23742
  "use strict";
@@ -23408,12 +23767,18 @@ var init_mcp_tool_schemas = __esm({
23408
23767
  maxUrls: import_zod27.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.")
23409
23768
  };
23410
23769
  ExtractSiteInputSchema = {
23411
- url: import_zod27.z.string().url().describe("Public website URL or domain to extract across multiple pages. Use when the user asks for a site audit, website crawl, or full-site content/schema extraction."),
23770
+ url: import_zod27.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."),
23412
23771
  maxPages: import_zod27.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."),
23413
23772
  rotateProxies: import_zod27.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."),
23414
23773
  rotateProxyEvery: import_zod27.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."),
23415
23774
  formats: import_zod27.z.array(import_zod27.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.")
23416
23775
  };
23776
+ AuditSiteInputSchema = {
23777
+ url: import_zod27.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."),
23778
+ maxPages: import_zod27.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."),
23779
+ rotateProxies: import_zod27.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."),
23780
+ rotateProxyEvery: import_zod27.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.")
23781
+ };
23417
23782
  YoutubeHarvestInputSchema = {
23418
23783
  mode: import_zod27.z.enum(["search", "channel"]).describe("Use search for topic/keyword requests. Use channel when the user provides @handle, channel ID, or channel URL."),
23419
23784
  query: import_zod27.z.string().optional().describe("Required when mode is search. The YouTube search topic in the user\u2019s words."),
@@ -23714,6 +24079,26 @@ var init_mcp_tool_schemas = __esm({
23714
24079
  })),
23715
24080
  durationMs: import_zod27.z.number().min(0)
23716
24081
  };
24082
+ AuditSiteOutputSchema = {
24083
+ url: import_zod27.z.string(),
24084
+ pageCount: import_zod27.z.number().int().min(0),
24085
+ durationMs: import_zod27.z.number().min(0),
24086
+ bulkFolder: import_zod27.z.string().nullable(),
24087
+ issues: import_zod27.z.record(import_zod27.z.string(), import_zod27.z.number()),
24088
+ images: import_zod27.z.object({
24089
+ unique: import_zod27.z.number().int().min(0),
24090
+ totalBytes: import_zod27.z.number().min(0),
24091
+ over100kb: import_zod27.z.number().int().min(0),
24092
+ legacyFormat: import_zod27.z.number().int().min(0)
24093
+ }),
24094
+ links: import_zod27.z.object({
24095
+ internal: import_zod27.z.number().int().min(0),
24096
+ external: import_zod27.z.number().int().min(0),
24097
+ orphans: import_zod27.z.number().int().min(0),
24098
+ brokenInternal: import_zod27.z.number().int().min(0),
24099
+ externalDomains: import_zod27.z.number().int().min(0)
24100
+ })
24101
+ };
23717
24102
  MapsPlaceIntelOutputSchema = {
23718
24103
  name: import_zod27.z.string(),
23719
24104
  rating: NullableString,
@@ -24563,12 +24948,19 @@ function registerPaaExtractorMcpTools(server, executor, options = {}) {
24563
24948
  annotations: liveWebToolAnnotations("Site URL Map")
24564
24949
  }, async (input) => formatMapSiteUrls(await executor.mapSiteUrls(input), input));
24565
24950
  server.registerTool("extract_site", {
24566
- title: "Multi-Page Site Extract",
24567
- description: withReportNote("Run multi-page extraction across a public website when the user asks for a website audit, competitor audit, full-site content crawl, schema inventory, or page metadata review. Returns per-page titles, H1s, metadata, headings, schema/entity data, canonical URLs, and 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 page content. Use map_site_urls first when URL selection matters; use extract_url for one page."),
24951
+ title: "Multi-Page Site Content Crawl",
24952
+ 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."),
24568
24953
  inputSchema: ExtractSiteInputSchema,
24569
24954
  outputSchema: ExtractSiteOutputSchema,
24570
- annotations: liveWebToolAnnotations("Multi-Page Site Extract")
24955
+ annotations: liveWebToolAnnotations("Multi-Page Site Content Crawl")
24571
24956
  }, async (input) => formatExtractSite(await executor.extractSite(input), input));
24957
+ server.registerTool("audit_site", {
24958
+ title: "Technical SEO Audit",
24959
+ 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 (inlinks/orphans/crawl-depth), a link analysis (top internal pages by inlinks, top external domains by link count, broken/orphan counts), 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, link-report.md, links-summary.json, external-domains.json, 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."),
24960
+ inputSchema: AuditSiteInputSchema,
24961
+ outputSchema: AuditSiteOutputSchema,
24962
+ annotations: liveWebToolAnnotations("Technical SEO Audit")
24963
+ }, async (input) => formatAuditSite(await executor.auditSite(input), input));
24572
24964
  server.registerTool("youtube_harvest", {
24573
24965
  title: "YouTube Video Harvest",
24574
24966
  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.'),
@@ -24906,6 +25298,9 @@ var init_http_mcp_tool_executor = __esm({
24906
25298
  extractSite(input) {
24907
25299
  return this.call("/extract-site", input);
24908
25300
  }
25301
+ auditSite(input) {
25302
+ return this.call("/extract-site", input);
25303
+ }
24909
25304
  youtubeHarvest(input) {
24910
25305
  return this.call("/youtube/harvest", input);
24911
25306
  }