mcp-scraper 0.3.15 → 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.
Files changed (35) hide show
  1. package/dist/bin/api-server.cjs +290 -23
  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 +253 -19
  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 +251 -17
  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-WQAIOY2D.js → chunk-7XARJHS6.js} +254 -18
  20. package/dist/chunk-7XARJHS6.js.map +1 -0
  21. package/dist/{chunk-7Y6JDBML.js → chunk-ACIUCZ27.js} +3 -3
  22. package/dist/chunk-ACIUCZ27.js.map +1 -0
  23. package/dist/{chunk-WJ2LWHPA.js → chunk-ISZWGIWL.js} +2 -2
  24. package/dist/chunk-SOMBZK3V.js +7 -0
  25. package/dist/chunk-SOMBZK3V.js.map +1 -0
  26. package/dist/{server-UOQ2JIUQ.js → server-LPVBSE2E.js} +35 -8
  27. package/dist/server-LPVBSE2E.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-ISZWGIWL.js.map} +0 -0
@@ -9168,6 +9168,143 @@ var init_seo_issues = __esm({
9168
9168
  }
9169
9169
  });
9170
9170
 
9171
+ // src/api/image-audit.ts
9172
+ function formatBytes(n) {
9173
+ if (n == null) return null;
9174
+ if (n < 1024) return `${n} B`;
9175
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
9176
+ return `${(n / (1024 * 1024)).toFixed(2)} MB`;
9177
+ }
9178
+ function collectUrls(pages) {
9179
+ const byKey = /* @__PURE__ */ new Map();
9180
+ for (const p of pages) for (const raw of p.imageLinks || []) {
9181
+ const u = decodeEntities(raw);
9182
+ if (!/^https?:\/\//i.test(u)) continue;
9183
+ if (/[{}]|%7[bd]/i.test(u)) continue;
9184
+ const k = dedupKey(u);
9185
+ const prev = byKey.get(k);
9186
+ if (!prev || /^https:/i.test(u) && /^http:/i.test(prev)) byKey.set(k, u);
9187
+ }
9188
+ return [...byKey.values()];
9189
+ }
9190
+ async function sizeAndType(url, timeoutMs) {
9191
+ const ctrl = new AbortController();
9192
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
9193
+ const read = (res) => ({
9194
+ len: res.headers.get("content-length"),
9195
+ cr: res.headers.get("content-range"),
9196
+ ct: res.headers.get("content-type"),
9197
+ status: res.status
9198
+ });
9199
+ try {
9200
+ let bytes = null;
9201
+ let ct = null;
9202
+ let status = null;
9203
+ try {
9204
+ const h = read(await fetch(url, { method: "HEAD", redirect: "follow", signal: ctrl.signal }));
9205
+ status = h.status;
9206
+ ct = h.ct;
9207
+ if (h.len) bytes = Number(h.len);
9208
+ } catch {
9209
+ }
9210
+ if (bytes == null) {
9211
+ const g = await fetch(url, { method: "GET", headers: { Range: "bytes=0-0" }, redirect: "follow", signal: ctrl.signal });
9212
+ const r = read(g);
9213
+ status = status ?? r.status;
9214
+ ct = ct ?? r.ct;
9215
+ if (r.cr && r.cr.includes("/")) {
9216
+ const tail = r.cr.split("/")[1];
9217
+ if (tail && tail !== "*") bytes = Number(tail);
9218
+ } else if (r.len) bytes = Number(r.len);
9219
+ try {
9220
+ await g.body?.cancel();
9221
+ } catch {
9222
+ }
9223
+ }
9224
+ return { url, status, bytes: Number.isFinite(bytes) ? bytes : null, contentType: ct };
9225
+ } catch (e) {
9226
+ return { url, status: null, bytes: null, contentType: null, error: String(e.message || e) };
9227
+ } finally {
9228
+ clearTimeout(t);
9229
+ }
9230
+ }
9231
+ async function pool(items, n, fn) {
9232
+ const out = new Array(items.length);
9233
+ let i = 0;
9234
+ await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => {
9235
+ while (i < items.length) {
9236
+ const idx = i++;
9237
+ out[idx] = await fn(items[idx]);
9238
+ }
9239
+ }));
9240
+ return out;
9241
+ }
9242
+ async function auditImages(pages, opts = {}) {
9243
+ const concurrency = opts.concurrency ?? 12;
9244
+ const timeoutMs = opts.timeoutMs ?? 12e3;
9245
+ const urls = collectUrls(pages).slice(0, opts.max ?? 5e3);
9246
+ const heads = await pool(urls, concurrency, (u) => sizeAndType(u, timeoutMs));
9247
+ const rows = heads.map((r) => {
9248
+ const format = formatOf(r.contentType, r.url);
9249
+ return {
9250
+ ...r,
9251
+ size: formatBytes(r.bytes),
9252
+ format,
9253
+ over100kb: r.bytes != null && r.bytes > OVER_BYTES,
9254
+ legacyFormat: format !== "unknown" && !MODERN.has(format)
9255
+ };
9256
+ });
9257
+ const sized = rows.filter((r) => r.bytes != null);
9258
+ const totalBytes = sized.reduce((a, r) => a + r.bytes, 0);
9259
+ const formatCounts = {};
9260
+ for (const r of rows) formatCounts[r.format] = (formatCounts[r.format] || 0) + 1;
9261
+ return {
9262
+ rows: rows.sort((a, b) => (b.bytes ?? 0) - (a.bytes ?? 0)),
9263
+ summary: {
9264
+ unique: rows.length,
9265
+ sized: sized.length,
9266
+ totalBytes,
9267
+ totalSize: formatBytes(totalBytes) ?? "0 B",
9268
+ avgSize: formatBytes(Math.round(totalBytes / (sized.length || 1))) ?? "0 B",
9269
+ over100kb: rows.filter((r) => r.over100kb).length,
9270
+ legacyFormat: rows.filter((r) => r.legacyFormat).length,
9271
+ formatCounts
9272
+ }
9273
+ };
9274
+ }
9275
+ function renderImageSection(audit) {
9276
+ const s = audit.summary;
9277
+ const fmt = Object.entries(s.formatCounts).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k}:${v}`).join(" ");
9278
+ const heaviest = audit.rows.filter((r) => r.over100kb).slice(0, 15);
9279
+ const lines = [
9280
+ `## Images`,
9281
+ `**${s.unique} unique images** \xB7 ${s.totalSize} total \xB7 ${s.avgSize} avg \xB7 ${s.over100kb} over 100 KB \xB7 ${s.legacyFormat} legacy format`,
9282
+ `Formats: ${fmt}`
9283
+ ];
9284
+ if (heaviest.length) {
9285
+ lines.push("", "| Size | Format | URL |", "|------|--------|-----|");
9286
+ for (const r of heaviest) lines.push(`| ${r.size} | ${r.format} | ${r.url} |`);
9287
+ }
9288
+ return lines.join("\n");
9289
+ }
9290
+ var OVER_BYTES, MODERN, decodeEntities, dedupKey, formatOf;
9291
+ var init_image_audit = __esm({
9292
+ "src/api/image-audit.ts"() {
9293
+ "use strict";
9294
+ OVER_BYTES = 100 * 1024;
9295
+ MODERN = /* @__PURE__ */ new Set(["webp", "avif", "svg", "svg+xml"]);
9296
+ decodeEntities = (u) => u.replace(/&amp;/g, "&").replace(/&#0?38;/g, "&").replace(/&#x26;/gi, "&").trim();
9297
+ dedupKey = (u) => u.replace(/^https?:\/\//i, "//").replace(/\/$/, "");
9298
+ formatOf = (ct, url) => {
9299
+ if (ct) {
9300
+ const m = ct.split(";")[0].trim().toLowerCase();
9301
+ if (m.startsWith("image/")) return m.replace("image/", "");
9302
+ }
9303
+ return (url.split("?")[0].match(/\.([a-z0-9]+)$/i)?.[1] || "unknown").toLowerCase();
9304
+ };
9305
+ }
9306
+ });
9307
+
9171
9308
  // src/api/blob-store.ts
9172
9309
  function byteLength(data) {
9173
9310
  return Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data);
@@ -9582,10 +9719,13 @@ function chunk(items, size) {
9582
9719
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
9583
9720
  return out;
9584
9721
  }
9585
- function buildArtifacts(job, pages, branding) {
9722
+ function buildArtifacts(job, pages, branding, imageAudit) {
9586
9723
  const { edges, metrics } = buildLinkGraph(pages, job.startUrl);
9587
9724
  const issues = computeIssues(pages, metrics);
9588
- const reportMd = renderIssueReport(job.startUrl, pages, issues, metrics);
9725
+ let reportMd = renderIssueReport(job.startUrl, pages, issues, metrics);
9726
+ if (imageAudit) reportMd += `
9727
+
9728
+ ${renderImageSection(imageAudit)}`;
9589
9729
  const pageRows = pages.map((p) => {
9590
9730
  const { bodyMarkdown: _b, schema: _s, outlinks: _o, ...rest } = p;
9591
9731
  const m = metrics.get(p.url);
@@ -9599,6 +9739,10 @@ function buildArtifacts(job, pages, branding) {
9599
9739
  { name: "links.jsonl", body: jsonl(edges), type: "application/x-ndjson" },
9600
9740
  { name: "link-metrics.jsonl", body: jsonl([...metrics.values()]), type: "application/x-ndjson" }
9601
9741
  ];
9742
+ if (imageAudit) {
9743
+ files.push({ name: "images.jsonl", body: jsonl(imageAudit.rows), type: "application/x-ndjson" });
9744
+ files.push({ name: "images-summary.json", body: JSON.stringify(imageAudit.summary, null, 2), type: "application/json" });
9745
+ }
9602
9746
  if (branding) files.push({ name: "branding.json", body: JSON.stringify(branding, null, 2), type: "application/json" });
9603
9747
  return files;
9604
9748
  }
@@ -9610,6 +9754,7 @@ var init_site_extract = __esm({
9610
9754
  init_site_extractor();
9611
9755
  init_seo_link_graph();
9612
9756
  init_seo_issues();
9757
+ init_image_audit();
9613
9758
  init_blob_store();
9614
9759
  init_screenshot();
9615
9760
  init_browser_service_env();
@@ -9657,10 +9802,14 @@ var init_site_extract = __esm({
9657
9802
  });
9658
9803
  }
9659
9804
  const branding = Array.isArray(job.options.formats) && job.options.formats.includes("branding") ? await step.run("branding", () => extractBranding(job.startUrl, key).catch(() => null)) : null;
9805
+ const imageAudit = Array.isArray(job.options.formats) && job.options.formats.includes("images") ? await step.run("image-audit", async () => {
9806
+ const pages = await getExtractedPages(jobId);
9807
+ return auditImages(pages, { concurrency: 12, timeoutMs: 12e3 });
9808
+ }) : null;
9660
9809
  const artifacts = await step.run("finalize", async () => {
9661
9810
  const pages = await getExtractedPages(jobId);
9662
9811
  const store = getBlobStore();
9663
- const files = buildArtifacts(job, pages, branding);
9812
+ const files = buildArtifacts(job, pages, branding, imageAudit);
9664
9813
  const stored = [];
9665
9814
  for (const f of files) stored.push(await store.put(`extract/${jobId}/${f.name}`, f.body, f.type));
9666
9815
  await completeExtractJob(jobId, stored);
@@ -9935,7 +10084,7 @@ var init_catalog_seed = __esm({
9935
10084
  kind: "simple",
9936
10085
  endpoint: "/extract-site",
9937
10086
  mcpName: "extract_site",
9938
- tagline: "Map + bulk scrape + SEO report",
10087
+ tagline: "Map + bulk scrape (content)",
9939
10088
  params: [
9940
10089
  URL_PARAM,
9941
10090
  { key: "maxPages", label: "Max pages", type: "number", min: 1, max: 1e4, default: 100 },
@@ -9943,7 +10092,21 @@ var init_catalog_seed = __esm({
9943
10092
  { key: "rotateProxies", label: "Rotate proxies (blocked sites)", type: "boolean", default: true },
9944
10093
  { key: "background", label: "Run in background", type: "boolean", default: true, advanced: true }
9945
10094
  ],
9946
- examples: ["https://steenshoney.com/ (full-site SEO crawl)"]
10095
+ examples: ["https://steenshoney.com/ (full-site content crawl)"]
10096
+ },
10097
+ {
10098
+ key: "audit_site",
10099
+ label: "Audit",
10100
+ kind: "simple",
10101
+ endpoint: "/extract-site",
10102
+ mcpName: "audit_site",
10103
+ tagline: "Technical SEO audit (Screaming-Frog style)",
10104
+ params: [
10105
+ URL_PARAM,
10106
+ { key: "maxPages", label: "Max pages", type: "number", min: 1, max: 1e4, default: 100 },
10107
+ { key: "rotateProxies", label: "Rotate proxies (blocked sites)", type: "boolean", default: true }
10108
+ ],
10109
+ examples: ["https://steenshoney.com/ (issues + link graph + image audit)"]
9947
10110
  }
9948
10111
  ]
9949
10112
  },
@@ -16037,7 +16200,7 @@ function saveFullReport(full) {
16037
16200
  function reportSavingActive() {
16038
16201
  return reportSavingEnabled && process.env.MCP_SCRAPER_SAVE_REPORTS !== "false";
16039
16202
  }
16040
- function saveBulkSite(siteUrl, pages, seo) {
16203
+ function saveBulkSite(siteUrl, pages, seo, imageAudit) {
16041
16204
  if (!reportSavingActive()) return null;
16042
16205
  try {
16043
16206
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
@@ -16060,15 +16223,32 @@ function saveBulkSite(siteUrl, pages, seo) {
16060
16223
  (0, import_node_fs5.writeFileSync)((0, import_node_path7.join)(pagesDir, fname), content, "utf8");
16061
16224
  return `| ${i + 1} | ${cell(p.title ?? "Untitled")} | ${p.url} | pages/${fname} |`;
16062
16225
  });
16226
+ const dataFilesSection = seo ? [
16227
+ `
16228
+ ## Data files (in this folder)`,
16229
+ `- \`report.md\` \u2014 SEO issues summary (counts + example URLs)`,
16230
+ `- \`issues.json\` \u2014 every finding with the full list of offender URLs`,
16231
+ `- \`pages.jsonl\` \u2014 per-page SEO fields, one JSON object per page`,
16232
+ `- \`links.jsonl\` \u2014 internal/external link edges (anchor, rel, target status)`,
16233
+ `- \`link-metrics.jsonl\` \u2014 inlinks, crawl depth, orphan flags per page`,
16234
+ ...imageAudit ? [
16235
+ `- \`images.jsonl\` \u2014 every image with byte size, format, over-100KB and legacy-format flags`,
16236
+ `- \`images-summary.json\` \u2014 image totals (count, total weight, over-100KB, legacy)`
16237
+ ] : [],
16238
+ ...seo.branding ? [`- \`branding.json\` \u2014 site logo, colors, fonts`] : []
16239
+ ].join("\n") : "";
16063
16240
  const index = [
16064
16241
  `# Site Extract: ${siteUrl}`,
16065
- `**${pages.length} pages** \u2014 each saved as an individual Markdown file under \`pages/\`.`,
16242
+ `**${pages.length} pages** crawled. Everything is in this folder.`,
16243
+ `- Page content: one Markdown file per page under \`pages/\` (listed in the table below).`,
16244
+ seo ? `- SEO crawl data: the data files listed below.` : "",
16245
+ dataFilesSection,
16066
16246
  `
16067
16247
  ## Pages
16068
16248
  | # | Title | URL | File |
16069
16249
  |---|-------|-----|------|
16070
16250
  ${indexRows.join("\n")}`
16071
- ].join("\n");
16251
+ ].filter(Boolean).join("\n");
16072
16252
  const indexFile = (0, import_node_path7.join)(dir, "index.md");
16073
16253
  (0, import_node_fs5.writeFileSync)(indexFile, index, "utf8");
16074
16254
  let seoFiles;
@@ -16083,8 +16263,17 @@ ${indexRows.join("\n")}`
16083
16263
  (0, import_node_fs5.writeFileSync)(linksJsonl, toJsonl(seo.edges), "utf8");
16084
16264
  (0, import_node_fs5.writeFileSync)(metricsJsonl, toJsonl(seo.metrics), "utf8");
16085
16265
  (0, import_node_fs5.writeFileSync)(issuesFile, JSON.stringify(seo.issues, null, 2), "utf8");
16086
- (0, import_node_fs5.writeFileSync)(reportFile, seo.reportMd, "utf8");
16266
+ (0, import_node_fs5.writeFileSync)(reportFile, seo.reportMd + (imageAudit ? `
16267
+
16268
+ ${renderImageSection(imageAudit)}` : ""), "utf8");
16087
16269
  seoFiles = [pagesJsonl, linksJsonl, metricsJsonl, issuesFile, reportFile];
16270
+ if (imageAudit) {
16271
+ const imagesJsonl = (0, import_node_path7.join)(dir, "images.jsonl");
16272
+ const imagesSummary = (0, import_node_path7.join)(dir, "images-summary.json");
16273
+ (0, import_node_fs5.writeFileSync)(imagesJsonl, imageAudit.rows.map((r) => JSON.stringify(r)).join("\n"), "utf8");
16274
+ (0, import_node_fs5.writeFileSync)(imagesSummary, JSON.stringify(imageAudit.summary, null, 2), "utf8");
16275
+ seoFiles.push(imagesJsonl, imagesSummary);
16276
+ }
16088
16277
  if (seo.branding) {
16089
16278
  const brandingFile = (0, import_node_path7.join)(dir, "branding.json");
16090
16279
  (0, import_node_fs5.writeFileSync)(brandingFile, JSON.stringify(seo.branding, null, 2), "utf8");
@@ -16559,24 +16748,23 @@ function formatExtractSite(raw, input) {
16559
16748
  durationMs: d.durationMs ?? 0
16560
16749
  };
16561
16750
  if (pages.length > BULK_PAGE_THRESHOLD) {
16562
- const seo = buildSeoExport(input.url, pages, d.branding);
16563
16751
  const bulk = saveBulkSite(input.url, pages.map((p) => ({
16564
16752
  url: String(p.url ?? ""),
16565
16753
  title: p.title ?? null,
16566
16754
  bodyMarkdown: p.bodyMarkdown ?? null,
16567
16755
  metaDescription: p.metaDescription ?? null,
16568
16756
  schemaTypes: schemaTypesOf(p)
16569
- })), seo);
16757
+ })));
16570
16758
  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
16759
  const location2 = bulk ? `
16574
16760
  ## \u{1F4C1} Bulk scrape saved
16575
- - **Folder:** \`${bulk.dir}\`
16576
- - **Index:** \`${bulk.indexFile}\`
16577
- - **Files:** ${bulk.fileCount} page files under \`pages/\`${seoLine}
16761
+ - **Folder:** \`${bulk.dir}\` \u2190 all scraped page content is here
16762
+ - **Start here:** \`${bulk.indexFile}\` \u2014 lists every page and its file
16763
+ - **Page content:** ${bulk.fileCount} Markdown files under \`pages/\` (one per page)
16764
+
16765
+ 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
16766
 
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.` : `
16767
+ \u{1F4A1} Want the full technical SEO audit (headings, link graph, indexability, image sizes/formats, issues)? Run \`audit_site\` on this URL instead.` : `
16580
16768
  ## \u26A0\uFE0F Bulk scrape not saved
16581
16769
  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
16770
  const full2 = [
@@ -16620,6 +16808,55 @@ ${body}` : "_(no content extracted)_"
16620
16808
  ${pageDetails}${tips}`;
16621
16809
  return { ...oneBlock(full, diskReport), structuredContent };
16622
16810
  }
16811
+ async function formatAuditSite(raw, input) {
16812
+ const parsed = parseData(raw);
16813
+ if ("error" in parsed) return { content: [{ type: "text", text: parsed.error }], isError: true };
16814
+ const d = parsed.data;
16815
+ const pages = d.pages ?? [];
16816
+ const schemaTypesOf = (p) => p.kpo?.type ?? (Array.isArray(p.schema) && p.schema.length ? [`${p.schema.length} block(s)`] : []);
16817
+ const seo = buildSeoExport(input.url, pages, d.branding);
16818
+ const imageAudit = await auditImages(pages, { concurrency: 12 });
16819
+ const bulk = saveBulkSite(input.url, pages.map((p) => ({
16820
+ url: String(p.url ?? ""),
16821
+ title: p.title ?? null,
16822
+ bodyMarkdown: p.bodyMarkdown ?? null,
16823
+ metaDescription: p.metaDescription ?? null,
16824
+ schemaTypes: schemaTypesOf(p)
16825
+ })), seo, imageAudit);
16826
+ 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");
16827
+ const img = imageAudit.summary;
16828
+ const imgLine = `${img.unique} images \xB7 ${img.totalSize} total \xB7 ${img.over100kb} over 100 KB \xB7 ${img.legacyFormat} legacy format`;
16829
+ const durationLine = `**${pages.length} pages** \xB7 ${((d.durationMs ?? 0) / 1e3).toFixed(1)}s`;
16830
+ const structuredContent = {
16831
+ url: input.url,
16832
+ pageCount: pages.length,
16833
+ durationMs: d.durationMs ?? 0,
16834
+ bulkFolder: bulk?.dir ?? null,
16835
+ issues: Object.fromEntries(Object.entries(seo.issues).map(([k, v]) => [k, v.count])),
16836
+ images: { unique: img.unique, totalBytes: img.totalBytes, over100kb: img.over100kb, legacyFormat: img.legacyFormat }
16837
+ };
16838
+ const location2 = bulk ? `
16839
+ ## \u{1F4C1} Technical audit saved
16840
+ - **Folder:** \`${bulk.dir}\` \u2190 full audit + page content here
16841
+ - **Start here:** \`${bulk.indexFile}\` \u2014 maps every file in the folder
16842
+ - **Data:** \`report.md\` (issues + image audit), \`issues.json\`, \`pages.jsonl\` (per-page fields), \`links.jsonl\`, \`link-metrics.jsonl\`, \`images.jsonl\`
16843
+
16844
+ 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.` : `
16845
+ ## \u26A0\uFE0F Audit not saved
16846
+ Report saving is disabled in this environment. Enable \`MCP_SCRAPER_SAVE_REPORTS\` to capture the technical audit.`;
16847
+ const full = [
16848
+ `# Technical SEO Audit: ${input.url}`,
16849
+ durationLine,
16850
+ location2,
16851
+ `
16852
+ ## Top issues
16853
+ ${topIssues || "_none found_"}`,
16854
+ `
16855
+ ## Images
16856
+ ${imgLine}`
16857
+ ].join("\n");
16858
+ return { content: [{ type: "text", text: full }], structuredContent };
16859
+ }
16623
16860
  function formatYoutubeHarvest(raw, input) {
16624
16861
  const parsed = parseData(raw);
16625
16862
  if ("error" in parsed) return { content: [{ type: "text", text: parsed.error }], isError: true };
@@ -17805,6 +18042,7 @@ var init_mcp_response_formatter = __esm({
17805
18042
  init_workflow_catalog();
17806
18043
  init_seo_link_graph();
17807
18044
  init_seo_issues();
18045
+ init_image_audit();
17808
18046
  reportSavingEnabled = true;
17809
18047
  BULK_PAGE_THRESHOLD = 25;
17810
18048
  BULK_URL_THRESHOLD = 500;
@@ -23339,7 +23577,7 @@ var PACKAGE_VERSION;
23339
23577
  var init_version = __esm({
23340
23578
  "src/version.ts"() {
23341
23579
  "use strict";
23342
- PACKAGE_VERSION = "0.3.15";
23580
+ PACKAGE_VERSION = "0.3.16";
23343
23581
  }
23344
23582
  });
23345
23583
 
@@ -23377,7 +23615,7 @@ NOTES
23377
23615
  });
23378
23616
 
23379
23617
  // 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;
23618
+ 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
23619
  var init_mcp_tool_schemas = __esm({
23382
23620
  "src/mcp/mcp-tool-schemas.ts"() {
23383
23621
  "use strict";
@@ -23408,12 +23646,18 @@ var init_mcp_tool_schemas = __esm({
23408
23646
  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
23647
  };
23410
23648
  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."),
23649
+ 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
23650
  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
23651
  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
23652
  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
23653
  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
23654
  };
23655
+ AuditSiteInputSchema = {
23656
+ 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."),
23657
+ 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."),
23658
+ 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."),
23659
+ 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.")
23660
+ };
23417
23661
  YoutubeHarvestInputSchema = {
23418
23662
  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
23663
  query: import_zod27.z.string().optional().describe("Required when mode is search. The YouTube search topic in the user\u2019s words."),
@@ -23714,6 +23958,19 @@ var init_mcp_tool_schemas = __esm({
23714
23958
  })),
23715
23959
  durationMs: import_zod27.z.number().min(0)
23716
23960
  };
23961
+ AuditSiteOutputSchema = {
23962
+ url: import_zod27.z.string(),
23963
+ pageCount: import_zod27.z.number().int().min(0),
23964
+ durationMs: import_zod27.z.number().min(0),
23965
+ bulkFolder: import_zod27.z.string().nullable(),
23966
+ issues: import_zod27.z.record(import_zod27.z.string(), import_zod27.z.number()),
23967
+ images: import_zod27.z.object({
23968
+ unique: import_zod27.z.number().int().min(0),
23969
+ totalBytes: import_zod27.z.number().min(0),
23970
+ over100kb: import_zod27.z.number().int().min(0),
23971
+ legacyFormat: import_zod27.z.number().int().min(0)
23972
+ })
23973
+ };
23717
23974
  MapsPlaceIntelOutputSchema = {
23718
23975
  name: import_zod27.z.string(),
23719
23976
  rating: NullableString,
@@ -24563,12 +24820,19 @@ function registerPaaExtractorMcpTools(server, executor, options = {}) {
24563
24820
  annotations: liveWebToolAnnotations("Site URL Map")
24564
24821
  }, async (input) => formatMapSiteUrls(await executor.mapSiteUrls(input), input));
24565
24822
  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."),
24823
+ title: "Multi-Page Site Content Crawl",
24824
+ 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
24825
  inputSchema: ExtractSiteInputSchema,
24569
24826
  outputSchema: ExtractSiteOutputSchema,
24570
- annotations: liveWebToolAnnotations("Multi-Page Site Extract")
24827
+ annotations: liveWebToolAnnotations("Multi-Page Site Content Crawl")
24571
24828
  }, async (input) => formatExtractSite(await executor.extractSite(input), input));
24829
+ server.registerTool("audit_site", {
24830
+ title: "Technical SEO Audit",
24831
+ 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."),
24832
+ inputSchema: AuditSiteInputSchema,
24833
+ outputSchema: AuditSiteOutputSchema,
24834
+ annotations: liveWebToolAnnotations("Technical SEO Audit")
24835
+ }, async (input) => formatAuditSite(await executor.auditSite(input), input));
24572
24836
  server.registerTool("youtube_harvest", {
24573
24837
  title: "YouTube Video Harvest",
24574
24838
  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 +25170,9 @@ var init_http_mcp_tool_executor = __esm({
24906
25170
  extractSite(input) {
24907
25171
  return this.call("/extract-site", input);
24908
25172
  }
25173
+ auditSite(input) {
25174
+ return this.call("/extract-site", input);
25175
+ }
24909
25176
  youtubeHarvest(input) {
24910
25177
  return this.call("/youtube/harvest", input);
24911
25178
  }