mcp-scraper 0.3.14 → 0.3.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/bin/api-server.cjs +2261 -511
  2. package/dist/bin/api-server.cjs.map +1 -1
  3. package/dist/bin/api-server.js +3 -3
  4. package/dist/bin/browser-agent-stdio-server.cjs +6 -6
  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 +663 -48
  11. package/dist/bin/mcp-scraper-combined-stdio-server.cjs.map +1 -1
  12. package/dist/bin/mcp-scraper-combined-stdio-server.js +6 -5
  13. package/dist/bin/mcp-scraper-combined-stdio-server.js.map +1 -1
  14. package/dist/bin/mcp-scraper-install.cjs +4 -4
  15. package/dist/bin/mcp-scraper-install.cjs.map +1 -1
  16. package/dist/bin/mcp-scraper-install.js +2 -2
  17. package/dist/bin/mcp-stdio-server.cjs +655 -40
  18. package/dist/bin/mcp-stdio-server.cjs.map +1 -1
  19. package/dist/bin/mcp-stdio-server.js +2 -2
  20. package/dist/{chunk-DBQDG7EH.js → chunk-5H22TOXQ.js} +28 -1
  21. package/dist/chunk-5H22TOXQ.js.map +1 -0
  22. package/dist/{chunk-HM6FHV5U.js → chunk-7XARJHS6.js} +662 -41
  23. package/dist/chunk-7XARJHS6.js.map +1 -0
  24. package/dist/{chunk-KPXMPAJ3.js → chunk-ACIUCZ27.js} +4 -4
  25. package/dist/chunk-ACIUCZ27.js.map +1 -0
  26. package/dist/{chunk-AX7UBYLG.js → chunk-ISZWGIWL.js} +7 -7
  27. package/dist/chunk-ISZWGIWL.js.map +1 -0
  28. package/dist/chunk-SOMBZK3V.js +7 -0
  29. package/dist/chunk-SOMBZK3V.js.map +1 -0
  30. package/dist/{chunk-AZ5PKU4F.js → chunk-ZAUMSBV3.js} +2 -2
  31. package/dist/{db-BE4JVB3V.js → db-P76GVIFN.js} +2 -2
  32. package/dist/{server-ILIVSPNY.js → server-LPVBSE2E.js} +1133 -93
  33. package/dist/server-LPVBSE2E.js.map +1 -0
  34. package/dist/{worker-FG7ZWEGA.js → worker-2WVKKCC7.js} +3 -3
  35. package/docs/final-tooling-spec.md +206 -0
  36. package/docs/mcp-tool-design-guide.md +225 -0
  37. package/docs/mcp-tool-manifest.generated.json +57 -22
  38. package/docs/seo-crawl-report-spec.md +287 -0
  39. package/docs/tool-catalog-spec.md +388 -0
  40. package/package.json +2 -1
  41. package/dist/chunk-3QHZPR4U.js +0 -7
  42. package/dist/chunk-3QHZPR4U.js.map +0 -1
  43. package/dist/chunk-AX7UBYLG.js.map +0 -1
  44. package/dist/chunk-DBQDG7EH.js.map +0 -1
  45. package/dist/chunk-HM6FHV5U.js.map +0 -1
  46. package/dist/chunk-KPXMPAJ3.js.map +0 -1
  47. package/dist/server-ILIVSPNY.js.map +0 -1
  48. /package/dist/{chunk-AZ5PKU4F.js.map → chunk-ZAUMSBV3.js.map} +0 -0
  49. /package/dist/{db-BE4JVB3V.js.map → db-P76GVIFN.js.map} +0 -0
  50. /package/dist/{worker-FG7ZWEGA.js.map → worker-2WVKKCC7.js.map} +0 -0
@@ -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.14";
459
+ var PACKAGE_VERSION = "0.3.16";
457
460
 
458
461
  // src/mcp/browser-agent-tool-schemas.ts
459
462
  var import_zod = require("zod");
@@ -604,7 +607,7 @@ var FanoutSourceOutput = import_zod.z.object({
604
607
  });
605
608
  var BrowserCaptureFanoutOutputSchema = {
606
609
  ...BrowserBaseOutput,
607
- tool: import_zod.z.literal("browser_capture_fanout"),
610
+ tool: import_zod.z.literal("query_fanout_workflow"),
608
611
  platform: import_zod.z.enum(["ChatGPT", "Claude"]).describe("Which AI-search surface the fan-out was captured from."),
609
612
  captured_at: import_zod.z.string(),
610
613
  prompt: import_zod.z.string().describe("The user prompt that triggered the captured fan-out, when recoverable."),
@@ -1703,7 +1706,7 @@ function registerBrowserAgentMcpTools(server2, opts) {
1703
1706
  }
1704
1707
  );
1705
1708
  server2.registerTool(
1706
- "browser_capture_fanout",
1709
+ "query_fanout_workflow",
1707
1710
  {
1708
1711
  title: "Capture AI Search Fan-Out",
1709
1712
  description: `Capture the query fan-out behind a ChatGPT or Claude web-search answer for Answer Engine Optimization (AEO): the exact sub-queries the model issued, every researched URL split into cited vs browsed-only (with citation frequency and snippet), each source tagged by category (First-party/vendor, News/media, Reddit, Social/video, Encyclopedia, Review site, Docs, Blog), plus top sourced sites and citation order. Set export=true for durable JSON, CSV, TSV, and HTML artifacts; export paths are returned relative to MCP_SCRAPER_OUTPUT_DIR. This returns the raw structured data; YOU analyze it \u2014 classify each sub-query by funnel stage (Problem-aware, Solution-aware, Decision-aware, Retention) and type (BoFu, Branded, Comparison, How-to, Search operator), name the brands the model researched, and give AEO insights. Sample asks: "capture the fan-out for this ChatGPT answer", "what did Claude search and cite for best CRM", "show the sub-queries and sources behind this AI answer". WRITE NOTE: passing prompt submits a real message in the user's logged-in account (a new conversation turn) \u2014 only send when the user wants that; omit prompt to capture a prompt the user just ran. Setup, if not already connected or if the profile disconnected: call browser_profile_onboard, give the user the watch_url, let them complete login, then call browser_profile_status until status is AUTHENTICATED. After that, browser_open the saved direct no-proxy profile and go to chatgpt.com or claude.ai. Fan-out is captured only as it streams, so the session must be open when the prompt runs. NOT for Google AI Overview citations \u2014 use harvest_paa for those; this tool is ChatGPT and Claude only.`,
@@ -1714,7 +1717,7 @@ function registerBrowserAgentMcpTools(server2, opts) {
1714
1717
  async (input) => {
1715
1718
  const emit = (result, exports3) => structuredResult({
1716
1719
  ok: true,
1717
- tool: "browser_capture_fanout",
1720
+ tool: "query_fanout_workflow",
1718
1721
  session_id: input.session_id,
1719
1722
  platform: result.platform,
1720
1723
  captured_at: result.capturedAt,
@@ -1738,7 +1741,7 @@ function registerBrowserAgentMcpTools(server2, opts) {
1738
1741
  reset: input.reset,
1739
1742
  export: input.export
1740
1743
  }, Math.max(timeoutMs, (input.wait_ms ?? (input.prompt ? 9e4 : 8e3)) + 3e4));
1741
- if (!res.ok) return errorResult("browser_capture_fanout", res.data, input.session_id);
1744
+ if (!res.ok) return errorResult("query_fanout_workflow", res.data, input.session_id);
1742
1745
  const hosted = res.data?.result ?? res.data;
1743
1746
  let exports2 = res.data?.exports ?? null;
1744
1747
  if (input.export && !exports2 && isEnrichedFanout(hosted)) {
@@ -1746,7 +1749,7 @@ function registerBrowserAgentMcpTools(server2, opts) {
1746
1749
  exports2 = exportFanout(hosted);
1747
1750
  } catch (err) {
1748
1751
  return errorResult(
1749
- "browser_capture_fanout",
1752
+ "query_fanout_workflow",
1750
1753
  {
1751
1754
  error: `Fan-out captured but local export failed: ${err instanceof Error ? err.message : String(err)}`,
1752
1755
  result: hosted
@@ -1890,6 +1893,322 @@ function suggestWorkflowRecipes(goal, limit = 3) {
1890
1893
  return scored.sort((a, b) => b.score - a.score || a.recipe.title.localeCompare(b.recipe.title)).slice(0, Math.max(1, limit)).map((item) => item.recipe);
1891
1894
  }
1892
1895
 
1896
+ // src/api/seo-link-graph.ts
1897
+ function normalize2(u) {
1898
+ return u.split("#")[0].replace(/\/$/, "");
1899
+ }
1900
+ function buildLinkGraph(pages, startUrl) {
1901
+ const statusByUrl = /* @__PURE__ */ new Map();
1902
+ for (const p of pages) statusByUrl.set(normalize2(p.url), p.status);
1903
+ const edges = [];
1904
+ for (const p of pages) {
1905
+ for (const l of p.outlinks ?? []) {
1906
+ const nofollow = (l.rel ?? "").split(/\s+/).includes("nofollow");
1907
+ edges.push({
1908
+ from: p.url,
1909
+ to: l.href,
1910
+ anchor: l.anchor,
1911
+ rel: l.rel,
1912
+ internal: l.internal,
1913
+ nofollow,
1914
+ targetStatus: statusByUrl.get(normalize2(l.href)) ?? null
1915
+ });
1916
+ }
1917
+ }
1918
+ const inboundFrom = /* @__PURE__ */ new Map();
1919
+ const inboundAnchors = /* @__PURE__ */ new Map();
1920
+ const adjacency = /* @__PURE__ */ new Map();
1921
+ for (const e of edges) {
1922
+ if (!e.internal) continue;
1923
+ const to = normalize2(e.to);
1924
+ const from = normalize2(e.from);
1925
+ if (!inboundFrom.has(to)) inboundFrom.set(to, /* @__PURE__ */ new Set());
1926
+ inboundFrom.get(to).add(from);
1927
+ if (e.anchor) {
1928
+ if (!inboundAnchors.has(to)) inboundAnchors.set(to, []);
1929
+ inboundAnchors.get(to).push(e.anchor);
1930
+ }
1931
+ if (!e.nofollow && (statusByUrl.get(to) === 200 || !statusByUrl.has(to))) {
1932
+ if (!adjacency.has(from)) adjacency.set(from, /* @__PURE__ */ new Set());
1933
+ adjacency.get(from).add(to);
1934
+ }
1935
+ }
1936
+ const depth = /* @__PURE__ */ new Map();
1937
+ const start = normalize2(startUrl);
1938
+ const queue = [start];
1939
+ depth.set(start, 0);
1940
+ while (queue.length > 0) {
1941
+ const cur = queue.shift();
1942
+ const d = depth.get(cur);
1943
+ for (const next of adjacency.get(cur) ?? []) {
1944
+ if (!depth.has(next)) {
1945
+ depth.set(next, d + 1);
1946
+ queue.push(next);
1947
+ }
1948
+ }
1949
+ }
1950
+ const topAnchorsFor = (url) => {
1951
+ const counts = /* @__PURE__ */ new Map();
1952
+ for (const a of inboundAnchors.get(url) ?? []) counts.set(a, (counts.get(a) ?? 0) + 1);
1953
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map((e) => e[0]);
1954
+ };
1955
+ const metrics = /* @__PURE__ */ new Map();
1956
+ for (const p of pages) {
1957
+ const key = normalize2(p.url);
1958
+ const inbound = inboundFrom.get(key) ?? /* @__PURE__ */ new Set();
1959
+ metrics.set(p.url, {
1960
+ url: p.url,
1961
+ inlinks: inbound.size,
1962
+ uniqueInlinks: inbound.size,
1963
+ outlinksInternal: (p.outlinks ?? []).filter((l) => l.internal).length,
1964
+ outlinksExternal: (p.outlinks ?? []).filter((l) => !l.internal).length,
1965
+ crawlDepth: depth.has(key) ? depth.get(key) : null,
1966
+ orphan: inbound.size === 0 && key !== start,
1967
+ topAnchors: topAnchorsFor(key)
1968
+ });
1969
+ }
1970
+ return { edges, metrics };
1971
+ }
1972
+
1973
+ // src/api/seo-issues.ts
1974
+ var THIN_WORDS = 200;
1975
+ var TITLE_MAX = 60;
1976
+ var TITLE_PX_MAX = 561;
1977
+ var TITLE_MIN = 30;
1978
+ var META_MAX = 155;
1979
+ var META_MIN = 70;
1980
+ var H1_MAX = 70;
1981
+ var URL_MAX = 115;
1982
+ function dupes(pages, key) {
1983
+ const groups = /* @__PURE__ */ new Map();
1984
+ for (const p of pages) {
1985
+ const k = key(p);
1986
+ if (k === null || k === void 0 || k === "") continue;
1987
+ const ks = String(k);
1988
+ if (!groups.has(ks)) groups.set(ks, []);
1989
+ groups.get(ks).push(p.url);
1990
+ }
1991
+ return [...groups.values()].filter((g) => g.length > 1).flat();
1992
+ }
1993
+ function computeIssues(pages, metrics) {
1994
+ const group = (urls) => ({ count: urls.length, urls: urls.slice(0, 200) });
1995
+ const where = (fn) => pages.filter(fn).map((p) => p.url);
1996
+ const ok = (p) => p.status === 200;
1997
+ const pathname = (u) => {
1998
+ try {
1999
+ return new URL(u).pathname;
2000
+ } catch {
2001
+ return "";
2002
+ }
2003
+ };
2004
+ const report = {
2005
+ "title.missing": group(where((p) => ok(p) && !p.title)),
2006
+ "title.duplicate": group(dupes(pages.filter(ok), (p) => p.title)),
2007
+ "title.tooLong": group(where((p) => (p.titleLength ?? 0) > TITLE_MAX || (p.titlePixels ?? 0) > TITLE_PX_MAX)),
2008
+ "title.tooShort": group(where((p) => p.title != null && (p.titleLength ?? 0) < TITLE_MIN)),
2009
+ "title.sameAsH1": group(where((p) => !!p.title && !!p.h1 && p.title.trim() === p.h1.trim())),
2010
+ "meta.missing": group(where((p) => ok(p) && !p.metaDescription)),
2011
+ "meta.duplicate": group(dupes(pages.filter(ok), (p) => p.metaDescription)),
2012
+ "meta.tooLong": group(where((p) => (p.metaDescLength ?? 0) > META_MAX)),
2013
+ "meta.tooShort": group(where((p) => p.metaDescription != null && (p.metaDescLength ?? 0) < META_MIN)),
2014
+ "h1.missing": group(where((p) => ok(p) && !p.h1)),
2015
+ "h1.multiple": group(where((p) => !!p.h1_2)),
2016
+ "h1.duplicate": group(dupes(pages.filter(ok), (p) => p.h1)),
2017
+ "h1.tooLong": group(where((p) => (p.h1?.length ?? 0) > H1_MAX)),
2018
+ "h2.missing": group(where((p) => ok(p) && p.h2Count === 0)),
2019
+ "indexability.nonIndexable": group(where((p) => !p.indexable)),
2020
+ "indexability.noindex": group(where((p) => p.indexabilityReason === "noindex")),
2021
+ "canonical.missing": group(where((p) => ok(p) && !p.canonicalUrl)),
2022
+ "canonical.canonicalised": group(where((p) => p.indexabilityReason === "canonicalised")),
2023
+ "response.broken4xx": group(where((p) => (p.status ?? 0) >= 400 && (p.status ?? 0) < 500)),
2024
+ "response.error5xx": group(where((p) => (p.status ?? 0) >= 500)),
2025
+ "response.redirect3xx": group(where((p) => (p.status ?? 0) >= 300 && (p.status ?? 0) < 400)),
2026
+ "response.noResponse": group(where((p) => p.status === null)),
2027
+ "content.thin": group(where((p) => ok(p) && p.wordCount > 0 && p.wordCount < THIN_WORDS)),
2028
+ "content.exactDuplicate": group(dupes(pages.filter((p) => ok(p) && p.contentHash), (p) => p.contentHash)),
2029
+ "images.missingAlt": group(where((p) => p.imagesMissingAlt > 0)),
2030
+ "schema.missing": group(where((p) => ok(p) && p.schemaTypes.length === 0)),
2031
+ "url.tooLong": group(where((p) => p.url.length > URL_MAX)),
2032
+ "url.uppercase": group(where((p) => /[A-Z]/.test(pathname(p.url)))),
2033
+ "url.underscores": group(where((p) => pathname(p.url).includes("_"))),
2034
+ "links.orphan": group([...metrics.values()].filter((m) => m.orphan).map((m) => m.url))
2035
+ };
2036
+ const normUrl = (u) => {
2037
+ try {
2038
+ const x = new URL(u);
2039
+ x.hash = "";
2040
+ return (x.origin + x.pathname).replace(/\/+$/, "") + x.search;
2041
+ } catch {
2042
+ return u.replace(/#.*$/, "").replace(/\/+$/, "");
2043
+ }
2044
+ };
2045
+ const statusByUrl = new Map(pages.map((p) => [normUrl(p.url), p.status]));
2046
+ const brokenLinkPages = /* @__PURE__ */ new Set();
2047
+ for (const p of pages) {
2048
+ for (const l of p.outlinks ?? []) {
2049
+ if (!l.internal) continue;
2050
+ const st = statusByUrl.get(normUrl(l.href));
2051
+ if (st != null && st >= 400) brokenLinkPages.add(p.url);
2052
+ }
2053
+ }
2054
+ report["links.brokenInternal"] = group([...brokenLinkPages]);
2055
+ return report;
2056
+ }
2057
+ function renderIssueReport(siteUrl, pages, report, metrics) {
2058
+ const total = pages.length;
2059
+ const sev = (key) => key.startsWith("response.broken") || key.startsWith("response.error") || key.startsWith("links.broken") ? "\u{1F534}" : key.startsWith("title.missing") || key.startsWith("h1.missing") || key.startsWith("indexability") || key.startsWith("canonical.missing") ? "\u{1F7E0}" : "\u{1F7E1}";
2060
+ const rows = Object.entries(report).filter(([, g]) => g.count > 0).sort((a, b) => b[1].count - a[1].count).map(([k, g]) => `| ${sev(k)} | \`${k}\` | ${g.count} | ${g.urls.slice(0, 3).join(" \xB7 ")}${g.urls.length > 3 ? " \u2026" : ""} |`);
2061
+ const depths = [...metrics.values()].map((m) => m.crawlDepth).filter((d) => d != null);
2062
+ const maxDepth = depths.length ? Math.max(...depths) : 0;
2063
+ const orphans = [...metrics.values()].filter((m) => m.orphan).length;
2064
+ const topLinked = [...metrics.values()].sort((a, b) => b.inlinks - a.inlinks).slice(0, 5);
2065
+ return [
2066
+ `# SEO Crawl Report: ${siteUrl}`,
2067
+ `**${total} pages crawled** \xB7 max crawl depth ${maxDepth} \xB7 ${orphans} orphan page(s)`,
2068
+ `
2069
+ ## Issues
2070
+ | | Issue | Count | Examples |
2071
+ |---|-------|-------|----------|
2072
+ ${rows.join("\n") || "| \u2705 | none | 0 | \u2014 |"}`,
2073
+ `
2074
+ ## Most-linked pages
2075
+ ${topLinked.map((m) => `- ${m.inlinks} inlinks \xB7 depth ${m.crawlDepth ?? "\u2014"} \xB7 ${m.url}`).join("\n")}`,
2076
+ `
2077
+ _Thresholds: title ${TITLE_MAX}ch/${TITLE_PX_MAX}px, meta ${META_MAX}ch, H1 ${H1_MAX}ch, thin <${THIN_WORDS} words, URL ${URL_MAX}ch. Pixel widths are estimates._`
2078
+ ].join("\n");
2079
+ }
2080
+
2081
+ // src/api/image-audit.ts
2082
+ var OVER_BYTES = 100 * 1024;
2083
+ var MODERN = /* @__PURE__ */ new Set(["webp", "avif", "svg", "svg+xml"]);
2084
+ function formatBytes(n) {
2085
+ if (n == null) return null;
2086
+ if (n < 1024) return `${n} B`;
2087
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
2088
+ return `${(n / (1024 * 1024)).toFixed(2)} MB`;
2089
+ }
2090
+ var decodeEntities = (u) => u.replace(/&amp;/g, "&").replace(/&#0?38;/g, "&").replace(/&#x26;/gi, "&").trim();
2091
+ var dedupKey = (u) => u.replace(/^https?:\/\//i, "//").replace(/\/$/, "");
2092
+ var formatOf = (ct, url) => {
2093
+ if (ct) {
2094
+ const m = ct.split(";")[0].trim().toLowerCase();
2095
+ if (m.startsWith("image/")) return m.replace("image/", "");
2096
+ }
2097
+ return (url.split("?")[0].match(/\.([a-z0-9]+)$/i)?.[1] || "unknown").toLowerCase();
2098
+ };
2099
+ function collectUrls(pages) {
2100
+ const byKey = /* @__PURE__ */ new Map();
2101
+ for (const p of pages) for (const raw of p.imageLinks || []) {
2102
+ const u = decodeEntities(raw);
2103
+ if (!/^https?:\/\//i.test(u)) continue;
2104
+ if (/[{}]|%7[bd]/i.test(u)) continue;
2105
+ const k = dedupKey(u);
2106
+ const prev = byKey.get(k);
2107
+ if (!prev || /^https:/i.test(u) && /^http:/i.test(prev)) byKey.set(k, u);
2108
+ }
2109
+ return [...byKey.values()];
2110
+ }
2111
+ async function sizeAndType(url, timeoutMs) {
2112
+ const ctrl = new AbortController();
2113
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
2114
+ const read = (res) => ({
2115
+ len: res.headers.get("content-length"),
2116
+ cr: res.headers.get("content-range"),
2117
+ ct: res.headers.get("content-type"),
2118
+ status: res.status
2119
+ });
2120
+ try {
2121
+ let bytes = null;
2122
+ let ct = null;
2123
+ let status = null;
2124
+ try {
2125
+ const h = read(await fetch(url, { method: "HEAD", redirect: "follow", signal: ctrl.signal }));
2126
+ status = h.status;
2127
+ ct = h.ct;
2128
+ if (h.len) bytes = Number(h.len);
2129
+ } catch {
2130
+ }
2131
+ if (bytes == null) {
2132
+ const g = await fetch(url, { method: "GET", headers: { Range: "bytes=0-0" }, redirect: "follow", signal: ctrl.signal });
2133
+ const r = read(g);
2134
+ status = status ?? r.status;
2135
+ ct = ct ?? r.ct;
2136
+ if (r.cr && r.cr.includes("/")) {
2137
+ const tail = r.cr.split("/")[1];
2138
+ if (tail && tail !== "*") bytes = Number(tail);
2139
+ } else if (r.len) bytes = Number(r.len);
2140
+ try {
2141
+ await g.body?.cancel();
2142
+ } catch {
2143
+ }
2144
+ }
2145
+ return { url, status, bytes: Number.isFinite(bytes) ? bytes : null, contentType: ct };
2146
+ } catch (e) {
2147
+ return { url, status: null, bytes: null, contentType: null, error: String(e.message || e) };
2148
+ } finally {
2149
+ clearTimeout(t);
2150
+ }
2151
+ }
2152
+ async function pool(items, n, fn) {
2153
+ const out = new Array(items.length);
2154
+ let i = 0;
2155
+ await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => {
2156
+ while (i < items.length) {
2157
+ const idx = i++;
2158
+ out[idx] = await fn(items[idx]);
2159
+ }
2160
+ }));
2161
+ return out;
2162
+ }
2163
+ async function auditImages(pages, opts = {}) {
2164
+ const concurrency = opts.concurrency ?? 12;
2165
+ const timeoutMs = opts.timeoutMs ?? 12e3;
2166
+ const urls = collectUrls(pages).slice(0, opts.max ?? 5e3);
2167
+ const heads = await pool(urls, concurrency, (u) => sizeAndType(u, timeoutMs));
2168
+ const rows = heads.map((r) => {
2169
+ const format = formatOf(r.contentType, r.url);
2170
+ return {
2171
+ ...r,
2172
+ size: formatBytes(r.bytes),
2173
+ format,
2174
+ over100kb: r.bytes != null && r.bytes > OVER_BYTES,
2175
+ legacyFormat: format !== "unknown" && !MODERN.has(format)
2176
+ };
2177
+ });
2178
+ const sized = rows.filter((r) => r.bytes != null);
2179
+ const totalBytes = sized.reduce((a, r) => a + r.bytes, 0);
2180
+ const formatCounts = {};
2181
+ for (const r of rows) formatCounts[r.format] = (formatCounts[r.format] || 0) + 1;
2182
+ return {
2183
+ rows: rows.sort((a, b) => (b.bytes ?? 0) - (a.bytes ?? 0)),
2184
+ summary: {
2185
+ unique: rows.length,
2186
+ sized: sized.length,
2187
+ totalBytes,
2188
+ totalSize: formatBytes(totalBytes) ?? "0 B",
2189
+ avgSize: formatBytes(Math.round(totalBytes / (sized.length || 1))) ?? "0 B",
2190
+ over100kb: rows.filter((r) => r.over100kb).length,
2191
+ legacyFormat: rows.filter((r) => r.legacyFormat).length,
2192
+ formatCounts
2193
+ }
2194
+ };
2195
+ }
2196
+ function renderImageSection(audit) {
2197
+ const s = audit.summary;
2198
+ const fmt = Object.entries(s.formatCounts).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k}:${v}`).join(" ");
2199
+ const heaviest = audit.rows.filter((r) => r.over100kb).slice(0, 15);
2200
+ const lines = [
2201
+ `## Images`,
2202
+ `**${s.unique} unique images** \xB7 ${s.totalSize} total \xB7 ${s.avgSize} avg \xB7 ${s.over100kb} over 100 KB \xB7 ${s.legacyFormat} legacy format`,
2203
+ `Formats: ${fmt}`
2204
+ ];
2205
+ if (heaviest.length) {
2206
+ lines.push("", "| Size | Format | URL |", "|------|--------|-----|");
2207
+ for (const r of heaviest) lines.push(`| ${r.size} | ${r.format} | ${r.url} |`);
2208
+ }
2209
+ return lines.join("\n");
2210
+ }
2211
+
1893
2212
  // src/mcp/mcp-response-formatter.ts
1894
2213
  var reportSavingEnabled = true;
1895
2214
  function sanitizeVendorText(text) {
@@ -1920,6 +2239,111 @@ function saveFullReport(full) {
1920
2239
  return null;
1921
2240
  }
1922
2241
  }
2242
+ var BULK_PAGE_THRESHOLD = 25;
2243
+ var BULK_URL_THRESHOLD = 500;
2244
+ function reportSavingActive() {
2245
+ return reportSavingEnabled && process.env.MCP_SCRAPER_SAVE_REPORTS !== "false";
2246
+ }
2247
+ function saveBulkSite(siteUrl, pages, seo, imageAudit) {
2248
+ if (!reportSavingActive()) return null;
2249
+ try {
2250
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
2251
+ const dir = (0, import_node_path4.join)(outputBaseDir3(), `extract-${slugifyReportName(siteUrl)}-${stamp}`);
2252
+ const pagesDir = (0, import_node_path4.join)(dir, "pages");
2253
+ (0, import_node_fs3.mkdirSync)(pagesDir, { recursive: true });
2254
+ const indexRows = pages.map((p, i) => {
2255
+ const num = String(i + 1).padStart(4, "0");
2256
+ const slug = slugifyReportName(p.url.replace(/^https?:\/\//, "")).slice(0, 60) || "page";
2257
+ const fname = `${num}-${slug}.md`;
2258
+ const body = (p.bodyMarkdown ?? "").trim();
2259
+ const content = [
2260
+ `# ${p.title ?? "Untitled"}`,
2261
+ `- **URL:** ${p.url}`,
2262
+ p.metaDescription ? `- **Description:** ${p.metaDescription}` : "",
2263
+ p.schemaTypes?.length ? `- **Schema:** ${p.schemaTypes.join(", ")}` : "",
2264
+ "",
2265
+ body || "_(no content extracted)_"
2266
+ ].filter(Boolean).join("\n");
2267
+ (0, import_node_fs3.writeFileSync)((0, import_node_path4.join)(pagesDir, fname), content, "utf8");
2268
+ return `| ${i + 1} | ${cell(p.title ?? "Untitled")} | ${p.url} | pages/${fname} |`;
2269
+ });
2270
+ const dataFilesSection = seo ? [
2271
+ `
2272
+ ## Data files (in this folder)`,
2273
+ `- \`report.md\` \u2014 SEO issues summary (counts + example URLs)`,
2274
+ `- \`issues.json\` \u2014 every finding with the full list of offender URLs`,
2275
+ `- \`pages.jsonl\` \u2014 per-page SEO fields, one JSON object per page`,
2276
+ `- \`links.jsonl\` \u2014 internal/external link edges (anchor, rel, target status)`,
2277
+ `- \`link-metrics.jsonl\` \u2014 inlinks, crawl depth, orphan flags per page`,
2278
+ ...imageAudit ? [
2279
+ `- \`images.jsonl\` \u2014 every image with byte size, format, over-100KB and legacy-format flags`,
2280
+ `- \`images-summary.json\` \u2014 image totals (count, total weight, over-100KB, legacy)`
2281
+ ] : [],
2282
+ ...seo.branding ? [`- \`branding.json\` \u2014 site logo, colors, fonts`] : []
2283
+ ].join("\n") : "";
2284
+ const index = [
2285
+ `# Site Extract: ${siteUrl}`,
2286
+ `**${pages.length} pages** crawled. Everything is in this folder.`,
2287
+ `- Page content: one Markdown file per page under \`pages/\` (listed in the table below).`,
2288
+ seo ? `- SEO crawl data: the data files listed below.` : "",
2289
+ dataFilesSection,
2290
+ `
2291
+ ## Pages
2292
+ | # | Title | URL | File |
2293
+ |---|-------|-----|------|
2294
+ ${indexRows.join("\n")}`
2295
+ ].filter(Boolean).join("\n");
2296
+ const indexFile = (0, import_node_path4.join)(dir, "index.md");
2297
+ (0, import_node_fs3.writeFileSync)(indexFile, index, "utf8");
2298
+ let seoFiles;
2299
+ if (seo) {
2300
+ const toJsonl = (rows) => rows.map((r) => JSON.stringify(r)).join("\n");
2301
+ const pagesJsonl = (0, import_node_path4.join)(dir, "pages.jsonl");
2302
+ const linksJsonl = (0, import_node_path4.join)(dir, "links.jsonl");
2303
+ const metricsJsonl = (0, import_node_path4.join)(dir, "link-metrics.jsonl");
2304
+ const issuesFile = (0, import_node_path4.join)(dir, "issues.json");
2305
+ const reportFile = (0, import_node_path4.join)(dir, "report.md");
2306
+ (0, import_node_fs3.writeFileSync)(pagesJsonl, toJsonl(seo.pageRows), "utf8");
2307
+ (0, import_node_fs3.writeFileSync)(linksJsonl, toJsonl(seo.edges), "utf8");
2308
+ (0, import_node_fs3.writeFileSync)(metricsJsonl, toJsonl(seo.metrics), "utf8");
2309
+ (0, import_node_fs3.writeFileSync)(issuesFile, JSON.stringify(seo.issues, null, 2), "utf8");
2310
+ (0, import_node_fs3.writeFileSync)(reportFile, seo.reportMd + (imageAudit ? `
2311
+
2312
+ ${renderImageSection(imageAudit)}` : ""), "utf8");
2313
+ seoFiles = [pagesJsonl, linksJsonl, metricsJsonl, issuesFile, reportFile];
2314
+ if (imageAudit) {
2315
+ const imagesJsonl = (0, import_node_path4.join)(dir, "images.jsonl");
2316
+ const imagesSummary = (0, import_node_path4.join)(dir, "images-summary.json");
2317
+ (0, import_node_fs3.writeFileSync)(imagesJsonl, imageAudit.rows.map((r) => JSON.stringify(r)).join("\n"), "utf8");
2318
+ (0, import_node_fs3.writeFileSync)(imagesSummary, JSON.stringify(imageAudit.summary, null, 2), "utf8");
2319
+ seoFiles.push(imagesJsonl, imagesSummary);
2320
+ }
2321
+ if (seo.branding) {
2322
+ const brandingFile = (0, import_node_path4.join)(dir, "branding.json");
2323
+ (0, import_node_fs3.writeFileSync)(brandingFile, JSON.stringify(seo.branding, null, 2), "utf8");
2324
+ seoFiles.push(brandingFile);
2325
+ }
2326
+ }
2327
+ return { dir, indexFile, fileCount: pages.length, seoFiles };
2328
+ } catch {
2329
+ return null;
2330
+ }
2331
+ }
2332
+ function saveUrlInventory(siteUrl, urls) {
2333
+ if (!reportSavingActive()) return null;
2334
+ try {
2335
+ const outDir = outputBaseDir3();
2336
+ (0, import_node_fs3.mkdirSync)(outDir, { recursive: true });
2337
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
2338
+ const file = (0, import_node_path4.join)(outDir, `${stamp}-urlmap-${slugifyReportName(siteUrl.replace(/^https?:\/\//, ""))}.csv`);
2339
+ const csv = (v) => /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
2340
+ const rows = ["url,status", ...urls.map((u) => `${csv(u.url)},${u.status ?? ""}`)];
2341
+ (0, import_node_fs3.writeFileSync)(file, rows.join("\n"), "utf8");
2342
+ return file;
2343
+ } catch {
2344
+ return null;
2345
+ }
2346
+ }
1923
2347
  function persistScreenshotLocally(base64, url) {
1924
2348
  if (!reportSavingEnabled || process.env.MCP_SCRAPER_SAVE_REPORTS === "false") return null;
1925
2349
  try {
@@ -1934,8 +2358,8 @@ function persistScreenshotLocally(base64, url) {
1934
2358
  return null;
1935
2359
  }
1936
2360
  }
1937
- function oneBlock(content) {
1938
- const filePath = saveFullReport(content);
2361
+ function oneBlock(content, diskContent) {
2362
+ const filePath = saveFullReport(diskContent ?? content);
1939
2363
  const text = filePath ? `${content}
1940
2364
 
1941
2365
  \u{1F4C4} Saved: \`${filePath}\`` : content;
@@ -2204,7 +2628,12 @@ ${[h1Lines, h2Lines].filter(Boolean).join("\n")}` : "";
2204
2628
  ].filter(Boolean).join("\n") : "";
2205
2629
  const bodySection = bodyMd ? `
2206
2630
  ## Page Content
2207
- ${bodyMd.slice(0, 3e3)}${bodyMd.length > 3e3 ? "\n\n*(truncated)*" : ""}` : "";
2631
+ ${bodyMd.slice(0, 3e3)}${bodyMd.length > 3e3 ? `
2632
+
2633
+ *(truncated to 3,000 of ${bodyMd.length.toLocaleString()} chars \u2014 full content in the saved report)*` : ""}` : "";
2634
+ const bodySectionFull = bodyMd ? `
2635
+ ## Page Content
2636
+ ${bodyMd}` : "";
2208
2637
  const screenshotSection = screenshotMeta ? `
2209
2638
  ## Screenshot
2210
2639
  - **File:** ${screenshotPath ?? "(returned inline only \u2014 disk write unavailable in this environment)"}
@@ -2235,7 +2664,10 @@ ${bodyMd.slice(0, 3e3)}${bodyMd.length > 3e3 ? "\n\n*(truncated)*" : ""}` : "";
2235
2664
  const full = `# URL Extract: ${url}
2236
2665
  **${title}**
2237
2666
  ${headingSection}${kpoSection}${brandingSection}${bodySection}${screenshotSection}${mediaSection}${tips}`;
2238
- const textResult = oneBlock(full);
2667
+ const diskReport = `# URL Extract: ${url}
2668
+ **${title}**
2669
+ ${headingSection}${kpoSection}${brandingSection}${bodySectionFull}${screenshotSection}${mediaSection}${tips}`;
2670
+ const textResult = oneBlock(full, diskReport);
2239
2671
  const structuredContent = {
2240
2672
  url,
2241
2673
  title: d.title ?? null,
@@ -2268,7 +2700,16 @@ function formatMapSiteUrls(raw, input) {
2268
2700
  const ok = urls.filter((u) => (u.status ?? 0) >= 200 && (u.status ?? 0) < 300);
2269
2701
  const broken = urls.filter((u) => u.status !== null && u.status >= 400);
2270
2702
  const redirects = urls.filter((u) => u.status !== null && u.status >= 300 && u.status < 400);
2271
- const urlRows = urls.slice(0, 200).map((u, i) => `| ${i + 1} | ${u.url} | ${u.status ?? "\u2014"} |`).join("\n");
2703
+ const isBulk = urls.length > BULK_URL_THRESHOLD;
2704
+ const inventoryFile = isBulk ? saveUrlInventory(input.url, urls.map((u) => ({ url: u.url, status: u.status ?? null }))) : null;
2705
+ const inlineCount = isBulk ? BULK_URL_THRESHOLD : 200;
2706
+ const urlRows = urls.slice(0, inlineCount).map((u, i) => `| ${i + 1} | ${u.url} | ${u.status ?? "\u2014"} |`).join("\n");
2707
+ const inventoryNote = isBulk ? inventoryFile ? `
2708
+ ## \u{1F4C1} Full inventory saved
2709
+ - **File:** \`${inventoryFile}\` (CSV: url, status \u2014 all ${urls.length} URLs)
2710
+ Showing the first ${inlineCount} below; read the file for the complete list.` : `
2711
+ ## \u26A0\uFE0F Full inventory not saved
2712
+ Report saving is disabled, so only the first ${inlineCount} of ${urls.length} URLs are shown. Enable \`MCP_SCRAPER_SAVE_REPORTS\` to capture the full inventory.` : "";
2272
2713
  const full = [
2273
2714
  `# URL Map: ${input.url}`,
2274
2715
  `**${d.totalFound} URLs** \xB7 ${(d.durationMs / 1e3).toFixed(1)}s${d.truncated ? " \xB7 *truncated*" : ""}`,
@@ -2277,12 +2718,13 @@ function formatMapSiteUrls(raw, input) {
2277
2718
  - \u2705 2xx: ${ok.length}
2278
2719
  - \u{1F500} 3xx: ${redirects.length}
2279
2720
  - \u274C 4xx+: ${broken.length}`,
2721
+ inventoryNote,
2280
2722
  `
2281
- ## URL Inventory
2723
+ ## URL Inventory${isBulk ? ` (first ${inlineCount} of ${urls.length})` : ""}
2282
2724
  | # | URL | Status |
2283
2725
  |---|-----|--------|
2284
2726
  ${urlRows}`,
2285
- broken.length ? `
2727
+ !isBulk && broken.length ? `
2286
2728
  ## Broken URLs
2287
2729
  ${broken.map((u) => `- ${u.url} (${u.status})`).join("\n")}` : "",
2288
2730
  `
@@ -2300,47 +2742,164 @@ ${broken.map((u) => `- ${u.url} (${u.status})`).join("\n")}` : "",
2300
2742
  okCount: ok.length,
2301
2743
  redirectCount: redirects.length,
2302
2744
  brokenCount: broken.length,
2745
+ inventoryFile: inventoryFile ?? null,
2303
2746
  urls: urls.map((u) => ({ url: u.url, status: u.status ?? null })),
2304
2747
  durationMs: d.durationMs ?? 0
2305
2748
  }
2306
2749
  };
2307
2750
  }
2751
+ function buildSeoExport(siteUrl, pages, branding) {
2752
+ const { edges, metrics } = buildLinkGraph(pages, siteUrl);
2753
+ const pageRows = pages.map((p) => {
2754
+ const { bodyMarkdown: _b, schema: _s, outlinks: _o, ...rest } = p;
2755
+ const m = metrics.get(p.url);
2756
+ return { ...rest, inlinks: m?.inlinks ?? 0, crawlDepth: m?.crawlDepth ?? null, orphan: m?.orphan ?? false };
2757
+ });
2758
+ const issues = computeIssues(pages, metrics);
2759
+ return {
2760
+ pageRows,
2761
+ edges,
2762
+ metrics: [...metrics.values()],
2763
+ issues,
2764
+ reportMd: renderIssueReport(siteUrl, pages, issues, metrics),
2765
+ branding: branding ?? void 0
2766
+ };
2767
+ }
2308
2768
  function formatExtractSite(raw, input) {
2309
2769
  const parsed = parseData(raw);
2310
2770
  if ("error" in parsed) return { content: [{ type: "text", text: parsed.error }], isError: true };
2311
2771
  const d = parsed.data;
2312
2772
  const pages = d.pages ?? [];
2313
- const pageRows = pages.map((p, i) => {
2314
- const schemaInfo = p.kpo?.type?.join(", ") ?? (Array.isArray(p.schema) && p.schema.length ? `${p.schema.length} block(s)` : "\u2014");
2773
+ const schemaTypesOf = (p) => p.kpo?.type ?? (Array.isArray(p.schema) && p.schema.length ? [`${p.schema.length} block(s)`] : []);
2774
+ const pageRow = (p, i) => {
2775
+ const schemaInfo = schemaTypesOf(p).join(", ") || "\u2014";
2315
2776
  return `| ${i + 1} | ${cell(p.title ?? "Untitled")} | ${p.url} | ${schemaInfo} |`;
2316
- }).join("\n");
2317
- const full = [
2777
+ };
2778
+ const tips = `
2779
+ ---
2780
+ \u{1F4A1} **Tips**
2781
+ - Map URLs first: use \`map_site_urls\`
2782
+ - Inspect a single page: use \`extract_url\``;
2783
+ const durationLine = `**${pages.length} pages** \xB7 ${((d.durationMs ?? 0) / 1e3).toFixed(1)}s`;
2784
+ const structuredContent = {
2785
+ url: input.url,
2786
+ pageCount: pages.length,
2787
+ pages: pages.map((p) => ({
2788
+ url: String(p.url ?? ""),
2789
+ title: p.title ?? null,
2790
+ schemaTypes: schemaTypesOf(p)
2791
+ })),
2792
+ durationMs: d.durationMs ?? 0
2793
+ };
2794
+ if (pages.length > BULK_PAGE_THRESHOLD) {
2795
+ const bulk = saveBulkSite(input.url, pages.map((p) => ({
2796
+ url: String(p.url ?? ""),
2797
+ title: p.title ?? null,
2798
+ bodyMarkdown: p.bodyMarkdown ?? null,
2799
+ metaDescription: p.metaDescription ?? null,
2800
+ schemaTypes: schemaTypesOf(p)
2801
+ })));
2802
+ const preview = pages.slice(0, BULK_PAGE_THRESHOLD).map(pageRow).join("\n");
2803
+ const location = bulk ? `
2804
+ ## \u{1F4C1} Bulk scrape saved
2805
+ - **Folder:** \`${bulk.dir}\` \u2190 all scraped page content is here
2806
+ - **Start here:** \`${bulk.indexFile}\` \u2014 lists every page and its file
2807
+ - **Page content:** ${bulk.fileCount} Markdown files under \`pages/\` (one per page)
2808
+
2809
+ 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/\`.
2810
+
2811
+ \u{1F4A1} Want the full technical SEO audit (headings, link graph, indexability, image sizes/formats, issues)? Run \`audit_site\` on this URL instead.` : `
2812
+ ## \u26A0\uFE0F Bulk scrape not saved
2813
+ 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.`;
2814
+ const full2 = [
2815
+ `# Site Extract: ${input.url}`,
2816
+ durationLine,
2817
+ location,
2818
+ `
2819
+ ## Pages (first ${Math.min(BULK_PAGE_THRESHOLD, pages.length)} of ${pages.length})
2820
+ | # | Title | URL | Schema |
2821
+ |---|-------|-----|--------|
2822
+ ${preview}`,
2823
+ tips
2824
+ ].join("\n");
2825
+ return { content: [{ type: "text", text: full2 }], structuredContent: { ...structuredContent, bulkFolder: bulk?.dir ?? null } };
2826
+ }
2827
+ const header = [
2318
2828
  `# Site Extract: ${input.url}`,
2319
- `**${pages.length} pages** \xB7 ${((d.durationMs ?? 0) / 1e3).toFixed(1)}s`,
2829
+ durationLine,
2320
2830
  `
2321
2831
  ## Pages
2322
2832
  | # | Title | URL | Schema |
2323
2833
  |---|-------|-----|--------|
2324
- ${pageRows}`,
2325
- `
2326
- ---
2327
- \u{1F4A1} **Tips**
2328
- - Map URLs first: use \`map_site_urls\`
2329
- - Inspect a single page: use \`extract_url\``
2834
+ ${pages.map(pageRow).join("\n")}`
2330
2835
  ].join("\n");
2331
- return {
2332
- ...oneBlock(full),
2333
- structuredContent: {
2334
- url: input.url,
2335
- pageCount: pages.length,
2336
- pages: pages.map((p) => ({
2337
- url: String(p.url ?? ""),
2338
- title: p.title ?? null,
2339
- schemaTypes: p.kpo?.type ?? []
2340
- })),
2341
- durationMs: d.durationMs ?? 0
2342
- }
2836
+ const full = `${header}${tips}`;
2837
+ const pageDetails = pages.map((p, i) => {
2838
+ const body = (p.bodyMarkdown ?? "").trim();
2839
+ return [
2840
+ `
2841
+ ## ${i + 1}. ${p.title ?? "Untitled"}`,
2842
+ `- **URL:** ${p.url}`,
2843
+ p.metaDescription ? `- **Description:** ${p.metaDescription}` : "",
2844
+ body ? `
2845
+ ${body}` : "_(no content extracted)_"
2846
+ ].filter(Boolean).join("\n");
2847
+ }).join("\n");
2848
+ const diskReport = `${header}
2849
+
2850
+ ---
2851
+ # Full Page Content
2852
+ ${pageDetails}${tips}`;
2853
+ return { ...oneBlock(full, diskReport), structuredContent };
2854
+ }
2855
+ async function formatAuditSite(raw, input) {
2856
+ const parsed = parseData(raw);
2857
+ if ("error" in parsed) return { content: [{ type: "text", text: parsed.error }], isError: true };
2858
+ const d = parsed.data;
2859
+ const pages = d.pages ?? [];
2860
+ const schemaTypesOf = (p) => p.kpo?.type ?? (Array.isArray(p.schema) && p.schema.length ? [`${p.schema.length} block(s)`] : []);
2861
+ const seo = buildSeoExport(input.url, pages, d.branding);
2862
+ const imageAudit = await auditImages(pages, { concurrency: 12 });
2863
+ const bulk = saveBulkSite(input.url, pages.map((p) => ({
2864
+ url: String(p.url ?? ""),
2865
+ title: p.title ?? null,
2866
+ bodyMarkdown: p.bodyMarkdown ?? null,
2867
+ metaDescription: p.metaDescription ?? null,
2868
+ schemaTypes: schemaTypesOf(p)
2869
+ })), seo, imageAudit);
2870
+ 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");
2871
+ const img = imageAudit.summary;
2872
+ const imgLine = `${img.unique} images \xB7 ${img.totalSize} total \xB7 ${img.over100kb} over 100 KB \xB7 ${img.legacyFormat} legacy format`;
2873
+ const durationLine = `**${pages.length} pages** \xB7 ${((d.durationMs ?? 0) / 1e3).toFixed(1)}s`;
2874
+ const structuredContent = {
2875
+ url: input.url,
2876
+ pageCount: pages.length,
2877
+ durationMs: d.durationMs ?? 0,
2878
+ bulkFolder: bulk?.dir ?? null,
2879
+ issues: Object.fromEntries(Object.entries(seo.issues).map(([k, v]) => [k, v.count])),
2880
+ images: { unique: img.unique, totalBytes: img.totalBytes, over100kb: img.over100kb, legacyFormat: img.legacyFormat }
2343
2881
  };
2882
+ const location = bulk ? `
2883
+ ## \u{1F4C1} Technical audit saved
2884
+ - **Folder:** \`${bulk.dir}\` \u2190 full audit + page content here
2885
+ - **Start here:** \`${bulk.indexFile}\` \u2014 maps every file in the folder
2886
+ - **Data:** \`report.md\` (issues + image audit), \`issues.json\`, \`pages.jsonl\` (per-page fields), \`links.jsonl\`, \`link-metrics.jsonl\`, \`images.jsonl\`
2887
+
2888
+ 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.` : `
2889
+ ## \u26A0\uFE0F Audit not saved
2890
+ Report saving is disabled in this environment. Enable \`MCP_SCRAPER_SAVE_REPORTS\` to capture the technical audit.`;
2891
+ const full = [
2892
+ `# Technical SEO Audit: ${input.url}`,
2893
+ durationLine,
2894
+ location,
2895
+ `
2896
+ ## Top issues
2897
+ ${topIssues || "_none found_"}`,
2898
+ `
2899
+ ## Images
2900
+ ${imgLine}`
2901
+ ].join("\n");
2902
+ return { content: [{ type: "text", text: full }], structuredContent };
2344
2903
  }
2345
2904
  function formatYoutubeHarvest(raw, input) {
2346
2905
  const parsed = parseData(raw);
@@ -3427,6 +3986,33 @@ ${limitations.map((l) => `- ${l}`).join("\n")}` : "",
3427
3986
  };
3428
3987
  }
3429
3988
 
3989
+ // src/mcp/server-instructions.ts
3990
+ var SERVER_INSTRUCTIONS = `
3991
+ This server scrapes and analyzes web, social, search, maps, and site data. Pick a tool by NAME using
3992
+ this routing map, then load its schema before calling.
3993
+
3994
+ ROUTING
3995
+ - One web page -> extract_url. Whole site (crawl + SEO report) -> extract_site. Just the URL list -> map_site_urls.
3996
+ - Web search (organic SERP) -> search_serp. Full SERP + People-Also-Ask / AI-Overview detail -> harvest_paa.
3997
+ - Google Maps: find places -> maps_search; one place deep-dive + reviews -> maps_place_intel.
3998
+ - YouTube: find or list videos -> youtube_harvest; transcribe one video -> youtube_transcribe.
3999
+ - Facebook: find ads -> facebook_ad_search; transcribe an ad -> facebook_ad_transcribe; transcribe a
4000
+ video -> facebook_video_transcribe; page profile/intel -> facebook_page_intel.
4001
+ - Instagram: profile inventory -> instagram_profile_content; one post or reel -> instagram_media_download.
4002
+ - Workflows (multi-step): local directory build -> directory_workflow; rank blueprint -> rank_tracker_workflow;
4003
+ AI-answer citation fan-out (AEO) -> query_fanout_workflow; deep research -> deep_research_workflow.
4004
+ - Anything without a dedicated tool (e.g. Reddit, arbitrary logged-in sites) -> the browser_* agent
4005
+ (browser_open, then navigate/read); save/reuse logins via browser_profile_*.
4006
+
4007
+ NOTES
4008
+ - Bulk / full-site crawls: call extract_site with rotateProxies:true for blocked or rate-limited sites.
4009
+ It discovers URLs and returns a saved folder/artifact plus a summary, not the full content inline.
4010
+ - Tools that open a browser session or transcribe media cost credits and take longer. Prefer the
4011
+ cheapest tool that answers the question (plain search/extract before browser agents).
4012
+ - Large results are saved to disk or an artifact and returned as a summary plus a path; read the path
4013
+ for full detail rather than expecting the whole payload inline.
4014
+ `.trim();
4015
+
3430
4016
  // src/mcp/mcp-tool-schemas.ts
3431
4017
  var import_zod3 = require("zod");
3432
4018
 
@@ -3551,11 +4137,20 @@ var ExtractUrlInputSchema = {
3551
4137
  };
3552
4138
  var MapSiteUrlsInputSchema = {
3553
4139
  url: import_zod3.z.string().url().describe("Public website URL or domain to crawl for internal URLs. Use before extract_site when the user asks to audit/map/crawl a site."),
3554
- maxUrls: import_zod3.z.number().int().min(1).max(500).optional().describe("Maximum URLs to discover. Use 100 for normal maps, higher when the user asks for a full inventory.")
4140
+ 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.")
3555
4141
  };
3556
4142
  var ExtractSiteInputSchema = {
3557
- 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."),
3558
- maxPages: import_zod3.z.number().int().min(1).max(50).optional().describe("Maximum pages to extract. Use 50 when the user asks for full results or a complete crawl within MCP limits.")
4143
+ 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."),
4144
+ 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."),
4145
+ 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."),
4146
+ 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."),
4147
+ 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.")
4148
+ };
4149
+ var AuditSiteInputSchema = {
4150
+ 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."),
4151
+ 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."),
4152
+ 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."),
4153
+ 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.")
3559
4154
  };
3560
4155
  var YoutubeHarvestInputSchema = {
3561
4156
  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."),
@@ -3857,6 +4452,19 @@ var ExtractSiteOutputSchema = {
3857
4452
  })),
3858
4453
  durationMs: import_zod3.z.number().min(0)
3859
4454
  };
4455
+ var AuditSiteOutputSchema = {
4456
+ url: import_zod3.z.string(),
4457
+ pageCount: import_zod3.z.number().int().min(0),
4458
+ durationMs: import_zod3.z.number().min(0),
4459
+ bulkFolder: import_zod3.z.string().nullable(),
4460
+ issues: import_zod3.z.record(import_zod3.z.string(), import_zod3.z.number()),
4461
+ images: import_zod3.z.object({
4462
+ unique: import_zod3.z.number().int().min(0),
4463
+ totalBytes: import_zod3.z.number().min(0),
4464
+ over100kb: import_zod3.z.number().int().min(0),
4465
+ legacyFormat: import_zod3.z.number().int().min(0)
4466
+ })
4467
+ };
3860
4468
  var MapsPlaceIntelOutputSchema = {
3861
4469
  name: import_zod3.z.string(),
3862
4470
  rating: NullableString2,
@@ -4687,18 +5295,25 @@ function registerPaaExtractorMcpTools(server2, executor, options = {}) {
4687
5295
  }, async (input) => formatExtractUrl(await executor.extractUrl(input), input));
4688
5296
  server2.registerTool("map_site_urls", {
4689
5297
  title: "Site URL Map",
4690
- description: withReportNote("Map/crawl a public website when the user asks for a sitemap, URL inventory, broken-link scan, redirect scan, or crawl planning. Returns internal URLs with HTTP status and truncation metadata. Use this before extract_site when choosing pages for an audit; use extract_url for one known page."),
5298
+ description: withReportNote("Map/crawl a public website when the user asks for a sitemap, URL inventory, broken-link scan, redirect scan, or crawl planning. Returns internal URLs with HTTP status and truncation metadata. Supports up to maxUrls=10000; maps over 500 URLs write the complete inventory to a local CSV file and return a summary plus the file path instead of the full list inline. Use this before extract_site when choosing pages for an audit; use extract_url for one known page."),
4691
5299
  inputSchema: MapSiteUrlsInputSchema,
4692
5300
  outputSchema: MapSiteUrlsOutputSchema,
4693
5301
  annotations: liveWebToolAnnotations("Site URL Map")
4694
5302
  }, async (input) => formatMapSiteUrls(await executor.mapSiteUrls(input), input));
4695
5303
  server2.registerTool("extract_site", {
4696
- title: "Multi-Page Site Extract",
4697
- 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. Use map_site_urls first when URL selection matters; use extract_url for one page."),
5304
+ title: "Multi-Page Site Content Crawl",
5305
+ 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."),
4698
5306
  inputSchema: ExtractSiteInputSchema,
4699
5307
  outputSchema: ExtractSiteOutputSchema,
4700
- annotations: liveWebToolAnnotations("Multi-Page Site Extract")
5308
+ annotations: liveWebToolAnnotations("Multi-Page Site Content Crawl")
4701
5309
  }, async (input) => formatExtractSite(await executor.extractSite(input), input));
5310
+ server2.registerTool("audit_site", {
5311
+ title: "Technical SEO Audit",
5312
+ 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."),
5313
+ inputSchema: AuditSiteInputSchema,
5314
+ outputSchema: AuditSiteOutputSchema,
5315
+ annotations: liveWebToolAnnotations("Technical SEO Audit")
5316
+ }, async (input) => formatAuditSite(await executor.auditSite(input), input));
4702
5317
  server2.registerTool("youtube_harvest", {
4703
5318
  title: "YouTube Video Harvest",
4704
5319
  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.'),
@@ -4818,7 +5433,7 @@ function registerPaaExtractorMcpTools(server2, executor, options = {}) {
4818
5433
  outputSchema: WorkflowArtifactReadOutputSchema,
4819
5434
  annotations: liveWebToolAnnotations("Read Workflow Artifact")
4820
5435
  }, async (input) => formatWorkflowArtifactRead(await executor.workflowArtifactRead(input), input));
4821
- server2.registerTool("rank_tracker_blueprint", {
5436
+ server2.registerTool("rank_tracker_workflow", {
4822
5437
  title: "Rank Tracker Blueprint Builder",
4823
5438
  description: "Generate a build-ready database, cron/heartbeat, ingestion, metrics, and AI implementation prompt for a rank tracker powered by MCP Scraper. Supports Maps rankings through directory_workflow/maps_search, organic rankings through search_serp, AI Overview citation tracking, and People Also Ask source presence tracking. This tool is local planning only; it does not call the web or spend credits.",
4824
5439
  inputSchema: RankTrackerBlueprintInputSchema,
@@ -4892,10 +5507,10 @@ function renderInstallTerminal(options) {
4892
5507
  "1/1 install surfaces ready",
4893
5508
  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),
4894
5509
  "",
4895
- `${colorize("Tools", "cyan", color)} ${colorize("(44 MCP tools)", "muted", color)}`,
5510
+ `${colorize("Tools", "cyan", color)} ${colorize("(45 MCP tools)", "muted", color)}`,
4896
5511
  toolRow("search", ["harvest_paa", "search_serp", "maps_search", "maps_place_intel"], color),
4897
- toolRow("extract", ["extract_url", "map_site_urls", "extract_site", "directory_workflow"], color),
4898
- toolRow("build", ["rank_tracker_blueprint", "cron plan", "database prompt"], color),
5512
+ toolRow("extract", ["extract_url", "map_site_urls", "extract_site", "audit_site", "directory_workflow"], color),
5513
+ toolRow("build", ["rank_tracker_workflow", "cron plan", "database prompt"], color),
4899
5514
  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),
4900
5515
  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),
4901
5516
  toolRow("account", ["credits_info", "reports", "MCP resources"], color),
@@ -4971,7 +5586,7 @@ if (!apiKey) {
4971
5586
  }
4972
5587
  var baseUrl = process.env.MCP_SCRAPER_BASE_URL?.trim() || process.env.MCP_BASE_URL?.trim() || "https://mcpscraper.dev";
4973
5588
  var consoleBaseUrl = process.env.BROWSER_AGENT_CONSOLE_URL?.trim() || baseUrl;
4974
- var server = new import_mcp3.McpServer({ name: "mcp-scraper", version: PACKAGE_VERSION });
5589
+ var server = new import_mcp3.McpServer({ name: "mcp-scraper", version: PACKAGE_VERSION }, { instructions: SERVER_INSTRUCTIONS });
4975
5590
  registerPaaExtractorMcpTools(server, new HttpMcpToolExecutor(baseUrl, apiKey));
4976
5591
  registerBrowserAgentMcpTools(server, { baseUrl, apiKey, consoleBaseUrl });
4977
5592
  var transport = new import_stdio.StdioServerTransport();