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
@@ -10,7 +10,7 @@ import {
10
10
  import "../chunk-LFATOGDF.js";
11
11
  import {
12
12
  PACKAGE_VERSION
13
- } from "../chunk-2MX5WOII.js";
13
+ } from "../chunk-PVWWY5NV.js";
14
14
 
15
15
  // src/cli/human-cli.ts
16
16
  import { Command } from "commander";
@@ -232,6 +232,9 @@ var HttpMcpToolExecutor = class {
232
232
  extractSite(input) {
233
233
  return this.call("/extract-site", input);
234
234
  }
235
+ auditSite(input) {
236
+ return this.call("/extract-site", input);
237
+ }
235
238
  youtubeHarvest(input) {
236
239
  return this.call("/youtube/harvest", input);
237
240
  }
@@ -453,7 +456,7 @@ render();
453
456
  }
454
457
 
455
458
  // src/version.ts
456
- var PACKAGE_VERSION = "0.3.15";
459
+ var PACKAGE_VERSION = "0.3.17";
457
460
 
458
461
  // src/mcp/browser-agent-tool-schemas.ts
459
462
  var import_zod = require("zod");
@@ -1967,6 +1970,99 @@ function buildLinkGraph(pages, startUrl) {
1967
1970
  return { edges, metrics };
1968
1971
  }
1969
1972
 
1973
+ // src/api/seo-link-report.ts
1974
+ function registrableDomain(host) {
1975
+ const h = host.replace(/^www\./, "").toLowerCase();
1976
+ const parts = h.split(".");
1977
+ return parts.length <= 2 ? h : parts.slice(-2).join(".");
1978
+ }
1979
+ function buildLinkReport(edges, metrics, siteUrl) {
1980
+ let siteReg = "";
1981
+ try {
1982
+ siteReg = registrableDomain(new URL(siteUrl).hostname);
1983
+ } catch {
1984
+ siteReg = "";
1985
+ }
1986
+ const internalEdges = edges.filter((e) => e.internal);
1987
+ const pages = metrics.length;
1988
+ const orphans = metrics.filter((m) => m.orphan).length;
1989
+ const brokenInternal = internalEdges.filter((e) => e.targetStatus != null && e.targetStatus >= 400).length;
1990
+ const sumInlinks = metrics.reduce((a, m) => a + m.inlinks, 0);
1991
+ const sumOutlinks = metrics.reduce((a, m) => a + m.outlinksInternal + m.outlinksExternal, 0);
1992
+ const distribution = { zero: 0, oneToTwo: 0, threeToTen: 0, elevenPlus: 0 };
1993
+ for (const m of metrics) {
1994
+ if (m.inlinks === 0) distribution.zero++;
1995
+ else if (m.inlinks <= 2) distribution.oneToTwo++;
1996
+ else if (m.inlinks <= 10) distribution.threeToTen++;
1997
+ else distribution.elevenPlus++;
1998
+ }
1999
+ 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 }));
2000
+ const domMap = /* @__PURE__ */ new Map();
2001
+ let externalTotal = 0;
2002
+ for (const e of edges) {
2003
+ let domain;
2004
+ try {
2005
+ domain = registrableDomain(new URL(e.to).hostname);
2006
+ } catch {
2007
+ continue;
2008
+ }
2009
+ if (!domain || domain === siteReg) continue;
2010
+ externalTotal++;
2011
+ const d = domMap.get(domain) ?? { links: 0, nofollow: 0, pages: /* @__PURE__ */ new Set() };
2012
+ d.links++;
2013
+ if (e.nofollow) d.nofollow++;
2014
+ d.pages.add(e.from);
2015
+ domMap.set(domain, d);
2016
+ }
2017
+ 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);
2018
+ const round = (n) => Math.round(n * 10) / 10;
2019
+ return {
2020
+ summary: {
2021
+ internal: {
2022
+ totalLinks: internalEdges.length,
2023
+ pages,
2024
+ orphans,
2025
+ brokenInternal,
2026
+ avgInlinks: pages ? round(sumInlinks / pages) : 0,
2027
+ avgOutlinks: pages ? round(sumOutlinks / pages) : 0,
2028
+ distribution,
2029
+ topByInlinks
2030
+ },
2031
+ external: {
2032
+ totalLinks: externalTotal,
2033
+ uniqueDomains: externalDomains.length,
2034
+ topDomains: externalDomains.slice(0, 20)
2035
+ }
2036
+ },
2037
+ externalDomains
2038
+ };
2039
+ }
2040
+ function renderLinkReport(r) {
2041
+ const s = r.summary;
2042
+ const lines = [
2043
+ `## Link analysis`,
2044
+ `**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`,
2045
+ `**External:** ${s.external.totalLinks} links to ${s.external.uniqueDomains} domains`,
2046
+ ``,
2047
+ `### Inlink distribution`,
2048
+ `- 0 inlinks (orphans): ${s.internal.distribution.zero}`,
2049
+ `- 1\u20132: ${s.internal.distribution.oneToTwo}`,
2050
+ `- 3\u201310: ${s.internal.distribution.threeToTen}`,
2051
+ `- 11+: ${s.internal.distribution.elevenPlus}`,
2052
+ ``,
2053
+ `### Top 20 internal pages by inlinks`,
2054
+ `| inlinks | out (int/ext) | URL |`,
2055
+ `|---|---|---|`,
2056
+ ...s.internal.topByInlinks.map((p) => `| ${p.inlinks} | ${p.outlinksInternal}/${p.outlinksExternal} | ${p.url} |`),
2057
+ ``,
2058
+ `### Top 20 external domains by links`,
2059
+ `| links | nofollow | from pages | domain |`,
2060
+ `|---|---|---|---|`,
2061
+ ...s.external.topDomains.map((d) => `| ${d.links} | ${d.nofollow} | ${d.pages} | ${d.domain} |`)
2062
+ ];
2063
+ return lines.join("\n");
2064
+ }
2065
+
1970
2066
  // src/api/seo-issues.ts
1971
2067
  var THIN_WORDS = 200;
1972
2068
  var TITLE_MAX = 60;
@@ -2075,6 +2171,137 @@ _Thresholds: title ${TITLE_MAX}ch/${TITLE_PX_MAX}px, meta ${META_MAX}ch, H1 ${H1
2075
2171
  ].join("\n");
2076
2172
  }
2077
2173
 
2174
+ // src/api/image-audit.ts
2175
+ var OVER_BYTES = 100 * 1024;
2176
+ var MODERN = /* @__PURE__ */ new Set(["webp", "avif", "svg", "svg+xml"]);
2177
+ function formatBytes(n) {
2178
+ if (n == null) return null;
2179
+ if (n < 1024) return `${n} B`;
2180
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
2181
+ return `${(n / (1024 * 1024)).toFixed(2)} MB`;
2182
+ }
2183
+ var decodeEntities = (u) => u.replace(/&amp;/g, "&").replace(/&#0?38;/g, "&").replace(/&#x26;/gi, "&").trim();
2184
+ var dedupKey = (u) => u.replace(/^https?:\/\//i, "//").replace(/\/$/, "");
2185
+ var formatOf = (ct, url) => {
2186
+ if (ct) {
2187
+ const m = ct.split(";")[0].trim().toLowerCase();
2188
+ if (m.startsWith("image/")) return m.replace("image/", "");
2189
+ }
2190
+ return (url.split("?")[0].match(/\.([a-z0-9]+)$/i)?.[1] || "unknown").toLowerCase();
2191
+ };
2192
+ function collectUrls(pages) {
2193
+ const byKey = /* @__PURE__ */ new Map();
2194
+ for (const p of pages) for (const raw of p.imageLinks || []) {
2195
+ const u = decodeEntities(raw);
2196
+ if (!/^https?:\/\//i.test(u)) continue;
2197
+ if (/[{}]|%7[bd]/i.test(u)) continue;
2198
+ const k = dedupKey(u);
2199
+ const prev = byKey.get(k);
2200
+ if (!prev || /^https:/i.test(u) && /^http:/i.test(prev)) byKey.set(k, u);
2201
+ }
2202
+ return [...byKey.values()];
2203
+ }
2204
+ async function sizeAndType(url, timeoutMs) {
2205
+ const ctrl = new AbortController();
2206
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
2207
+ const read = (res) => ({
2208
+ len: res.headers.get("content-length"),
2209
+ cr: res.headers.get("content-range"),
2210
+ ct: res.headers.get("content-type"),
2211
+ status: res.status
2212
+ });
2213
+ try {
2214
+ let bytes = null;
2215
+ let ct = null;
2216
+ let status = null;
2217
+ try {
2218
+ const h = read(await fetch(url, { method: "HEAD", redirect: "follow", signal: ctrl.signal }));
2219
+ status = h.status;
2220
+ ct = h.ct;
2221
+ if (h.len) bytes = Number(h.len);
2222
+ } catch {
2223
+ }
2224
+ if (bytes == null) {
2225
+ const g = await fetch(url, { method: "GET", headers: { Range: "bytes=0-0" }, redirect: "follow", signal: ctrl.signal });
2226
+ const r = read(g);
2227
+ status = status ?? r.status;
2228
+ ct = ct ?? r.ct;
2229
+ if (r.cr && r.cr.includes("/")) {
2230
+ const tail = r.cr.split("/")[1];
2231
+ if (tail && tail !== "*") bytes = Number(tail);
2232
+ } else if (r.len) bytes = Number(r.len);
2233
+ try {
2234
+ await g.body?.cancel();
2235
+ } catch {
2236
+ }
2237
+ }
2238
+ return { url, status, bytes: Number.isFinite(bytes) ? bytes : null, contentType: ct };
2239
+ } catch (e) {
2240
+ return { url, status: null, bytes: null, contentType: null, error: String(e.message || e) };
2241
+ } finally {
2242
+ clearTimeout(t);
2243
+ }
2244
+ }
2245
+ async function pool(items, n, fn) {
2246
+ const out = new Array(items.length);
2247
+ let i = 0;
2248
+ await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => {
2249
+ while (i < items.length) {
2250
+ const idx = i++;
2251
+ out[idx] = await fn(items[idx]);
2252
+ }
2253
+ }));
2254
+ return out;
2255
+ }
2256
+ async function auditImages(pages, opts = {}) {
2257
+ const concurrency = opts.concurrency ?? 12;
2258
+ const timeoutMs = opts.timeoutMs ?? 12e3;
2259
+ const urls = collectUrls(pages).slice(0, opts.max ?? 5e3);
2260
+ const heads = await pool(urls, concurrency, (u) => sizeAndType(u, timeoutMs));
2261
+ const rows = heads.map((r) => {
2262
+ const format = formatOf(r.contentType, r.url);
2263
+ return {
2264
+ ...r,
2265
+ size: formatBytes(r.bytes),
2266
+ format,
2267
+ over100kb: r.bytes != null && r.bytes > OVER_BYTES,
2268
+ legacyFormat: format !== "unknown" && !MODERN.has(format)
2269
+ };
2270
+ });
2271
+ const sized = rows.filter((r) => r.bytes != null);
2272
+ const totalBytes = sized.reduce((a, r) => a + r.bytes, 0);
2273
+ const formatCounts = {};
2274
+ for (const r of rows) formatCounts[r.format] = (formatCounts[r.format] || 0) + 1;
2275
+ return {
2276
+ rows: rows.sort((a, b) => (b.bytes ?? 0) - (a.bytes ?? 0)),
2277
+ summary: {
2278
+ unique: rows.length,
2279
+ sized: sized.length,
2280
+ totalBytes,
2281
+ totalSize: formatBytes(totalBytes) ?? "0 B",
2282
+ avgSize: formatBytes(Math.round(totalBytes / (sized.length || 1))) ?? "0 B",
2283
+ over100kb: rows.filter((r) => r.over100kb).length,
2284
+ legacyFormat: rows.filter((r) => r.legacyFormat).length,
2285
+ formatCounts
2286
+ }
2287
+ };
2288
+ }
2289
+ function renderImageSection(audit) {
2290
+ const s = audit.summary;
2291
+ const fmt = Object.entries(s.formatCounts).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k}:${v}`).join(" ");
2292
+ const heaviest = audit.rows.filter((r) => r.over100kb).slice(0, 15);
2293
+ const lines = [
2294
+ `## Images`,
2295
+ `**${s.unique} unique images** \xB7 ${s.totalSize} total \xB7 ${s.avgSize} avg \xB7 ${s.over100kb} over 100 KB \xB7 ${s.legacyFormat} legacy format`,
2296
+ `Formats: ${fmt}`
2297
+ ];
2298
+ if (heaviest.length) {
2299
+ lines.push("", "| Size | Format | URL |", "|------|--------|-----|");
2300
+ for (const r of heaviest) lines.push(`| ${r.size} | ${r.format} | ${r.url} |`);
2301
+ }
2302
+ return lines.join("\n");
2303
+ }
2304
+
2078
2305
  // src/mcp/mcp-response-formatter.ts
2079
2306
  var reportSavingEnabled = true;
2080
2307
  function sanitizeVendorText(text) {
@@ -2110,7 +2337,7 @@ var BULK_URL_THRESHOLD = 500;
2110
2337
  function reportSavingActive() {
2111
2338
  return reportSavingEnabled && process.env.MCP_SCRAPER_SAVE_REPORTS !== "false";
2112
2339
  }
2113
- function saveBulkSite(siteUrl, pages, seo) {
2340
+ function saveBulkSite(siteUrl, pages, seo, imageAudit) {
2114
2341
  if (!reportSavingActive()) return null;
2115
2342
  try {
2116
2343
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
@@ -2133,15 +2360,35 @@ function saveBulkSite(siteUrl, pages, seo) {
2133
2360
  (0, import_node_fs3.writeFileSync)((0, import_node_path4.join)(pagesDir, fname), content, "utf8");
2134
2361
  return `| ${i + 1} | ${cell(p.title ?? "Untitled")} | ${p.url} | pages/${fname} |`;
2135
2362
  });
2363
+ const dataFilesSection = seo ? [
2364
+ `
2365
+ ## Data files (in this folder)`,
2366
+ `- \`report.md\` \u2014 SEO issues summary (counts + example URLs)`,
2367
+ `- \`issues.json\` \u2014 every finding with the full list of offender URLs`,
2368
+ `- \`pages.jsonl\` \u2014 per-page SEO fields, one JSON object per page`,
2369
+ `- \`links.jsonl\` \u2014 internal/external link edges (anchor, rel, target status)`,
2370
+ `- \`link-metrics.jsonl\` \u2014 inlinks, crawl depth, orphan flags per page`,
2371
+ `- \`link-report.md\` \u2014 link analysis: top internal pages by inlinks, top external domains, distribution`,
2372
+ `- \`links-summary.json\` \u2014 link totals + top-20 internal pages + top-20 external domains`,
2373
+ `- \`external-domains.json\` \u2014 every external domain linked to, ordered by link count`,
2374
+ ...imageAudit ? [
2375
+ `- \`images.jsonl\` \u2014 every image with byte size, format, over-100KB and legacy-format flags`,
2376
+ `- \`images-summary.json\` \u2014 image totals (count, total weight, over-100KB, legacy)`
2377
+ ] : [],
2378
+ ...seo.branding ? [`- \`branding.json\` \u2014 site logo, colors, fonts`] : []
2379
+ ].join("\n") : "";
2136
2380
  const index = [
2137
2381
  `# Site Extract: ${siteUrl}`,
2138
- `**${pages.length} pages** \u2014 each saved as an individual Markdown file under \`pages/\`.`,
2382
+ `**${pages.length} pages** crawled. Everything is in this folder.`,
2383
+ `- Page content: one Markdown file per page under \`pages/\` (listed in the table below).`,
2384
+ seo ? `- SEO crawl data: the data files listed below.` : "",
2385
+ dataFilesSection,
2139
2386
  `
2140
2387
  ## Pages
2141
2388
  | # | Title | URL | File |
2142
2389
  |---|-------|-----|------|
2143
2390
  ${indexRows.join("\n")}`
2144
- ].join("\n");
2391
+ ].filter(Boolean).join("\n");
2145
2392
  const indexFile = (0, import_node_path4.join)(dir, "index.md");
2146
2393
  (0, import_node_fs3.writeFileSync)(indexFile, index, "utf8");
2147
2394
  let seoFiles;
@@ -2156,8 +2403,23 @@ ${indexRows.join("\n")}`
2156
2403
  (0, import_node_fs3.writeFileSync)(linksJsonl, toJsonl(seo.edges), "utf8");
2157
2404
  (0, import_node_fs3.writeFileSync)(metricsJsonl, toJsonl(seo.metrics), "utf8");
2158
2405
  (0, import_node_fs3.writeFileSync)(issuesFile, JSON.stringify(seo.issues, null, 2), "utf8");
2159
- (0, import_node_fs3.writeFileSync)(reportFile, seo.reportMd, "utf8");
2160
- seoFiles = [pagesJsonl, linksJsonl, metricsJsonl, issuesFile, reportFile];
2406
+ (0, import_node_fs3.writeFileSync)(reportFile, seo.reportMd + (imageAudit ? `
2407
+
2408
+ ${renderImageSection(imageAudit)}` : ""), "utf8");
2409
+ const linkReportFile = (0, import_node_path4.join)(dir, "link-report.md");
2410
+ const linksSummaryFile = (0, import_node_path4.join)(dir, "links-summary.json");
2411
+ const externalDomainsFile = (0, import_node_path4.join)(dir, "external-domains.json");
2412
+ (0, import_node_fs3.writeFileSync)(linkReportFile, renderLinkReport(seo.linkReport), "utf8");
2413
+ (0, import_node_fs3.writeFileSync)(linksSummaryFile, JSON.stringify(seo.linkReport.summary, null, 2), "utf8");
2414
+ (0, import_node_fs3.writeFileSync)(externalDomainsFile, JSON.stringify(seo.linkReport.externalDomains, null, 2), "utf8");
2415
+ seoFiles = [pagesJsonl, linksJsonl, metricsJsonl, issuesFile, reportFile, linkReportFile, linksSummaryFile, externalDomainsFile];
2416
+ if (imageAudit) {
2417
+ const imagesJsonl = (0, import_node_path4.join)(dir, "images.jsonl");
2418
+ const imagesSummary = (0, import_node_path4.join)(dir, "images-summary.json");
2419
+ (0, import_node_fs3.writeFileSync)(imagesJsonl, imageAudit.rows.map((r) => JSON.stringify(r)).join("\n"), "utf8");
2420
+ (0, import_node_fs3.writeFileSync)(imagesSummary, JSON.stringify(imageAudit.summary, null, 2), "utf8");
2421
+ seoFiles.push(imagesJsonl, imagesSummary);
2422
+ }
2161
2423
  if (seo.branding) {
2162
2424
  const brandingFile = (0, import_node_path4.join)(dir, "branding.json");
2163
2425
  (0, import_node_fs3.writeFileSync)(brandingFile, JSON.stringify(seo.branding, null, 2), "utf8");
@@ -2602,6 +2864,7 @@ function buildSeoExport(siteUrl, pages, branding) {
2602
2864
  metrics: [...metrics.values()],
2603
2865
  issues,
2604
2866
  reportMd: renderIssueReport(siteUrl, pages, issues, metrics),
2867
+ linkReport: buildLinkReport(edges, [...metrics.values()], siteUrl),
2605
2868
  branding: branding ?? void 0
2606
2869
  };
2607
2870
  }
@@ -2632,24 +2895,23 @@ function formatExtractSite(raw, input) {
2632
2895
  durationMs: d.durationMs ?? 0
2633
2896
  };
2634
2897
  if (pages.length > BULK_PAGE_THRESHOLD) {
2635
- const seo = buildSeoExport(input.url, pages, d.branding);
2636
2898
  const bulk = saveBulkSite(input.url, pages.map((p) => ({
2637
2899
  url: String(p.url ?? ""),
2638
2900
  title: p.title ?? null,
2639
2901
  bodyMarkdown: p.bodyMarkdown ?? null,
2640
2902
  metaDescription: p.metaDescription ?? null,
2641
2903
  schemaTypes: schemaTypesOf(p)
2642
- })), seo);
2904
+ })));
2643
2905
  const preview = pages.slice(0, BULK_PAGE_THRESHOLD).map(pageRow).join("\n");
2644
- const seoLine = bulk?.seoFiles?.length ? `
2645
- - **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)` : "";
2646
2906
  const location = bulk ? `
2647
2907
  ## \u{1F4C1} Bulk scrape saved
2648
- - **Folder:** \`${bulk.dir}\`
2649
- - **Index:** \`${bulk.indexFile}\`
2650
- - **Files:** ${bulk.fileCount} page files under \`pages/\`${seoLine}
2908
+ - **Folder:** \`${bulk.dir}\` \u2190 all scraped page content is here
2909
+ - **Start here:** \`${bulk.indexFile}\` \u2014 lists every page and its file
2910
+ - **Page content:** ${bulk.fileCount} Markdown files under \`pages/\` (one per page)
2651
2911
 
2652
- 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.` : `
2912
+ 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/\`.
2913
+
2914
+ \u{1F4A1} Want the full technical SEO audit (headings, link graph, indexability, image sizes/formats, issues)? Run \`audit_site\` on this URL instead.` : `
2653
2915
  ## \u26A0\uFE0F Bulk scrape not saved
2654
2916
  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.`;
2655
2917
  const full2 = [
@@ -2693,6 +2955,62 @@ ${body}` : "_(no content extracted)_"
2693
2955
  ${pageDetails}${tips}`;
2694
2956
  return { ...oneBlock(full, diskReport), structuredContent };
2695
2957
  }
2958
+ async function formatAuditSite(raw, input) {
2959
+ const parsed = parseData(raw);
2960
+ if ("error" in parsed) return { content: [{ type: "text", text: parsed.error }], isError: true };
2961
+ const d = parsed.data;
2962
+ const pages = d.pages ?? [];
2963
+ const schemaTypesOf = (p) => p.kpo?.type ?? (Array.isArray(p.schema) && p.schema.length ? [`${p.schema.length} block(s)`] : []);
2964
+ const seo = buildSeoExport(input.url, pages, d.branding);
2965
+ const imageAudit = await auditImages(pages, { concurrency: 12 });
2966
+ const bulk = saveBulkSite(input.url, pages.map((p) => ({
2967
+ url: String(p.url ?? ""),
2968
+ title: p.title ?? null,
2969
+ bodyMarkdown: p.bodyMarkdown ?? null,
2970
+ metaDescription: p.metaDescription ?? null,
2971
+ schemaTypes: schemaTypesOf(p)
2972
+ })), seo, imageAudit);
2973
+ 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");
2974
+ const img = imageAudit.summary;
2975
+ const imgLine = `${img.unique} images \xB7 ${img.totalSize} total \xB7 ${img.over100kb} over 100 KB \xB7 ${img.legacyFormat} legacy format`;
2976
+ const lr = seo.linkReport.summary;
2977
+ const topExt = lr.external.topDomains.slice(0, 3).map((dm) => `${dm.domain} (${dm.links})`).join(", ") || "\u2014";
2978
+ 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}`;
2979
+ const durationLine = `**${pages.length} pages** \xB7 ${((d.durationMs ?? 0) / 1e3).toFixed(1)}s`;
2980
+ const structuredContent = {
2981
+ url: input.url,
2982
+ pageCount: pages.length,
2983
+ durationMs: d.durationMs ?? 0,
2984
+ bulkFolder: bulk?.dir ?? null,
2985
+ issues: Object.fromEntries(Object.entries(seo.issues).map(([k, v]) => [k, v.count])),
2986
+ images: { unique: img.unique, totalBytes: img.totalBytes, over100kb: img.over100kb, legacyFormat: img.legacyFormat },
2987
+ links: { internal: lr.internal.totalLinks, external: lr.external.totalLinks, orphans: lr.internal.orphans, brokenInternal: lr.internal.brokenInternal, externalDomains: lr.external.uniqueDomains }
2988
+ };
2989
+ const location = bulk ? `
2990
+ ## \u{1F4C1} Technical audit saved
2991
+ - **Folder:** \`${bulk.dir}\` \u2190 full audit + page content here
2992
+ - **Start here:** \`${bulk.indexFile}\` \u2014 maps every file in the folder
2993
+ - **Data:** \`report.md\` (issues + image audit), \`issues.json\`, \`pages.jsonl\` (per-page fields), \`links.jsonl\`, \`link-metrics.jsonl\`, \`images.jsonl\`
2994
+
2995
+ 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.` : `
2996
+ ## \u26A0\uFE0F Audit not saved
2997
+ Report saving is disabled in this environment. Enable \`MCP_SCRAPER_SAVE_REPORTS\` to capture the technical audit.`;
2998
+ const full = [
2999
+ `# Technical SEO Audit: ${input.url}`,
3000
+ durationLine,
3001
+ location,
3002
+ `
3003
+ ## Top issues
3004
+ ${topIssues || "_none found_"}`,
3005
+ `
3006
+ ## Links
3007
+ ${linkLine}`,
3008
+ `
3009
+ ## Images
3010
+ ${imgLine}`
3011
+ ].join("\n");
3012
+ return { content: [{ type: "text", text: full }], structuredContent };
3013
+ }
2696
3014
  function formatYoutubeHarvest(raw, input) {
2697
3015
  const parsed = parseData(raw);
2698
3016
  if ("error" in parsed) return { content: [{ type: "text", text: parsed.error }], isError: true };
@@ -3932,12 +4250,18 @@ var MapSiteUrlsInputSchema = {
3932
4250
  maxUrls: import_zod3.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.")
3933
4251
  };
3934
4252
  var ExtractSiteInputSchema = {
3935
- url: import_zod3.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."),
4253
+ url: import_zod3.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."),
3936
4254
  maxPages: import_zod3.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."),
3937
4255
  rotateProxies: import_zod3.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."),
3938
4256
  rotateProxyEvery: import_zod3.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."),
3939
4257
  formats: import_zod3.z.array(import_zod3.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.")
3940
4258
  };
4259
+ var AuditSiteInputSchema = {
4260
+ url: import_zod3.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."),
4261
+ maxPages: import_zod3.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."),
4262
+ rotateProxies: import_zod3.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."),
4263
+ rotateProxyEvery: import_zod3.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.")
4264
+ };
3941
4265
  var YoutubeHarvestInputSchema = {
3942
4266
  mode: import_zod3.z.enum(["search", "channel"]).describe("Use search for topic/keyword requests. Use channel when the user provides @handle, channel ID, or channel URL."),
3943
4267
  query: import_zod3.z.string().optional().describe("Required when mode is search. The YouTube search topic in the user\u2019s words."),
@@ -4238,6 +4562,26 @@ var ExtractSiteOutputSchema = {
4238
4562
  })),
4239
4563
  durationMs: import_zod3.z.number().min(0)
4240
4564
  };
4565
+ var AuditSiteOutputSchema = {
4566
+ url: import_zod3.z.string(),
4567
+ pageCount: import_zod3.z.number().int().min(0),
4568
+ durationMs: import_zod3.z.number().min(0),
4569
+ bulkFolder: import_zod3.z.string().nullable(),
4570
+ issues: import_zod3.z.record(import_zod3.z.string(), import_zod3.z.number()),
4571
+ images: import_zod3.z.object({
4572
+ unique: import_zod3.z.number().int().min(0),
4573
+ totalBytes: import_zod3.z.number().min(0),
4574
+ over100kb: import_zod3.z.number().int().min(0),
4575
+ legacyFormat: import_zod3.z.number().int().min(0)
4576
+ }),
4577
+ links: import_zod3.z.object({
4578
+ internal: import_zod3.z.number().int().min(0),
4579
+ external: import_zod3.z.number().int().min(0),
4580
+ orphans: import_zod3.z.number().int().min(0),
4581
+ brokenInternal: import_zod3.z.number().int().min(0),
4582
+ externalDomains: import_zod3.z.number().int().min(0)
4583
+ })
4584
+ };
4241
4585
  var MapsPlaceIntelOutputSchema = {
4242
4586
  name: import_zod3.z.string(),
4243
4587
  rating: NullableString2,
@@ -5074,12 +5418,19 @@ function registerPaaExtractorMcpTools(server2, executor, options = {}) {
5074
5418
  annotations: liveWebToolAnnotations("Site URL Map")
5075
5419
  }, async (input) => formatMapSiteUrls(await executor.mapSiteUrls(input), input));
5076
5420
  server2.registerTool("extract_site", {
5077
- title: "Multi-Page Site Extract",
5078
- 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."),
5421
+ title: "Multi-Page Site Content Crawl",
5422
+ 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."),
5079
5423
  inputSchema: ExtractSiteInputSchema,
5080
5424
  outputSchema: ExtractSiteOutputSchema,
5081
- annotations: liveWebToolAnnotations("Multi-Page Site Extract")
5425
+ annotations: liveWebToolAnnotations("Multi-Page Site Content Crawl")
5082
5426
  }, async (input) => formatExtractSite(await executor.extractSite(input), input));
5427
+ server2.registerTool("audit_site", {
5428
+ title: "Technical SEO Audit",
5429
+ 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."),
5430
+ inputSchema: AuditSiteInputSchema,
5431
+ outputSchema: AuditSiteOutputSchema,
5432
+ annotations: liveWebToolAnnotations("Technical SEO Audit")
5433
+ }, async (input) => formatAuditSite(await executor.auditSite(input), input));
5083
5434
  server2.registerTool("youtube_harvest", {
5084
5435
  title: "YouTube Video Harvest",
5085
5436
  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.'),
@@ -5273,9 +5624,9 @@ function renderInstallTerminal(options) {
5273
5624
  "1/1 install surfaces ready",
5274
5625
  colorize("Newest: saved hosted browser profiles for AI visibility. Share the watch_url, user signs in, then verify with browser_profile_status. Browser sessions are direct/no-proxy by default.", "lime", color),
5275
5626
  "",
5276
- `${colorize("Tools", "cyan", color)} ${colorize("(44 MCP tools)", "muted", color)}`,
5627
+ `${colorize("Tools", "cyan", color)} ${colorize("(45 MCP tools)", "muted", color)}`,
5277
5628
  toolRow("search", ["harvest_paa", "search_serp", "maps_search", "maps_place_intel"], color),
5278
- toolRow("extract", ["extract_url", "map_site_urls", "extract_site", "directory_workflow"], color),
5629
+ toolRow("extract", ["extract_url", "map_site_urls", "extract_site", "audit_site", "directory_workflow"], color),
5279
5630
  toolRow("build", ["rank_tracker_workflow", "cron plan", "database prompt"], color),
5280
5631
  toolRow("media", ["youtube_harvest", "youtube_transcribe", "facebook_ad_search", "facebook_page_intel", "facebook_ad_transcribe", "facebook_video_transcribe", "instagram_profile_content", "instagram_media_download"], color),
5281
5632
  toolRow("browser", ["browser_open", "browser_profile_onboard", "browser_profile_status", "browser_close", "browser_screenshot", "browser_read", "browser_locate", "browser_replay_mark", "browser_replay_annotate"], color),