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
@@ -3,20 +3,20 @@ import {
3
3
  HttpMcpToolExecutor,
4
4
  SERVER_INSTRUCTIONS,
5
5
  registerPaaExtractorMcpTools
6
- } from "../chunk-WQAIOY2D.js";
6
+ } from "../chunk-N33YEY6W.js";
7
7
  import "../chunk-M2S27J6Z.js";
8
8
  import {
9
9
  registerBrowserAgentMcpTools
10
- } from "../chunk-WJ2LWHPA.js";
10
+ } from "../chunk-UFD7N36R.js";
11
11
  import "../chunk-3YGKXXUG.js";
12
12
  import "../chunk-WN7PBKMV.js";
13
13
  import {
14
14
  renderInstallTerminal
15
- } from "../chunk-7Y6JDBML.js";
15
+ } from "../chunk-ACIUCZ27.js";
16
16
  import "../chunk-LFATOGDF.js";
17
17
  import {
18
18
  PACKAGE_VERSION
19
- } from "../chunk-2MX5WOII.js";
19
+ } from "../chunk-PVWWY5NV.js";
20
20
 
21
21
  // bin/mcp-scraper-combined-stdio-server.ts
22
22
  import { readFileSync } from "fs";
@@ -53,9 +53,9 @@ function renderInstallTerminal(options) {
53
53
  "1/1 install surfaces ready",
54
54
  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),
55
55
  "",
56
- `${colorize("Tools", "cyan", color)} ${colorize("(44 MCP tools)", "muted", color)}`,
56
+ `${colorize("Tools", "cyan", color)} ${colorize("(45 MCP tools)", "muted", color)}`,
57
57
  toolRow("search", ["harvest_paa", "search_serp", "maps_search", "maps_place_intel"], color),
58
- toolRow("extract", ["extract_url", "map_site_urls", "extract_site", "directory_workflow"], color),
58
+ toolRow("extract", ["extract_url", "map_site_urls", "extract_site", "audit_site", "directory_workflow"], color),
59
59
  toolRow("build", ["rank_tracker_workflow", "cron plan", "database prompt"], color),
60
60
  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),
61
61
  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),
@@ -101,7 +101,7 @@ function renderInstallTerminal(options) {
101
101
  }
102
102
 
103
103
  // src/version.ts
104
- var PACKAGE_VERSION = "0.3.15";
104
+ var PACKAGE_VERSION = "0.3.17";
105
105
 
106
106
  // bin/mcp-scraper-install.ts
107
107
  var noColor = process.argv.includes("--no-color") || process.env.NO_COLOR !== void 0 || process.env.FORCE_COLOR === "0" || !process.stdout.isTTY;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/install-terminal.ts","../../src/version.ts","../../bin/mcp-scraper-install.ts"],"sourcesContent":["export interface InstallTerminalOptions {\n color?: boolean\n version: string\n apiKeyConfigured?: boolean\n}\n\ntype Tone = 'cyan' | 'lime' | 'amber' | 'red' | 'muted' | 'bold'\n\nconst CODES: Record<Tone | 'reset', string> = {\n reset: '\\x1b[0m',\n cyan: '\\x1b[36m',\n lime: '\\x1b[32m',\n amber: '\\x1b[33m',\n red: '\\x1b[31m',\n muted: '\\x1b[90m',\n bold: '\\x1b[1m',\n}\n\nfunction colorize(value: string, tone: Tone, enabled: boolean): string {\n if (!enabled) return value\n return `${CODES[tone]}${value}${CODES.reset}`\n}\n\nfunction toolRow(label: string, tools: string[], enabled: boolean): string {\n const padded = label.padEnd(9, ' ')\n return ` ${colorize(padded, 'muted', enabled)} ${tools.join(colorize(' . ', 'muted', enabled))}`\n}\n\nexport function renderInstallTerminal(options: InstallTerminalOptions): string {\n const color = options.color ?? true\n const apiKeyValue = options.apiKeyConfigured ? '$MCP_SCRAPER_API_KEY' : 'sk_live_your_key'\n const ascii = String.raw`\n __ __ ____ ____ \n| \\/ |/ ___| _ \\\n| |\\/| | | | |_) |\n| | | | |___| __/\n|_| |_|\\____|_|\n\n ____ ____ ____ _ ____ _____ ____\n/ ___| / ___| _ \\ / \\ | _ \\| ____| _ \\\n\\___ \\| | | |_) | / _ \\ | |_) | _| | |_) |\n ___) | |___| _ < / ___ \\| __/| |___| _ <\n|____/ \\____|_| \\_\\/_/ \\_\\_| |_____|_| \\_\\\n`\n\n const claudeCommand = [\n `MCP_SCRAPER_API_KEY=${apiKeyValue} npx -y -p mcp-scraper@latest \\\\`,\n ' mcp-scraper-cli agent install claude --apply',\n ].join('\\n')\n\n const codexConfig = [\n '[mcp_servers.mcp-scraper]',\n 'command = \"npx\"',\n 'args = [\"-y\", \"-p\", \"mcp-scraper@latest\", \"mcp-scraper-combined\"]',\n `env = { MCP_SCRAPER_API_KEY = \"${apiKeyValue}\" }`,\n ].join('\\n')\n\n return [\n colorize(`mcp-scraper v${options.version}`, 'bold', color),\n colorize('> mcp-scraper-install', 'muted', color),\n colorize(ascii, 'amber', color),\n `${colorize('MCP Scraper Agent', 'cyan', color)} . v${options.version} . mcpscraper.dev`,\n '1/1 install surfaces ready',\n 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),\n '',\n `${colorize('Tools', 'cyan', color)} ${colorize('(44 MCP tools)', 'muted', color)}`,\n toolRow('search', ['harvest_paa', 'search_serp', 'maps_search', 'maps_place_intel'], color),\n toolRow('extract', ['extract_url', 'map_site_urls', 'extract_site', 'directory_workflow'], color),\n toolRow('build', ['rank_tracker_workflow', 'cron plan', 'database prompt'], color),\n 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),\n 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),\n toolRow('account', ['credits_info', 'reports', 'MCP resources'], color),\n `${colorize('Workflows', 'cyan', color)} ${colorize('(MCP + CLI + API)', 'muted', color)}`,\n toolRow('route', ['workflow_list', 'workflow_suggest', 'workflow_run', 'workflow_step', 'workflow_status', 'workflow_artifact_read'], color),\n toolRow('seo', ['directory', 'agent-packet', 'competitive audit', 'map/serp comparison', 'PAA/AIO briefs', 'scheduled runs'], color),\n '',\n colorize('Usage tips:', 'amber', color),\n 'Run mcp-scraper-install for this visible card. Run mcp-scraper-cli for setup utilities and subcommands.',\n 'Run mcp-scraper-combined in a human terminal to print this card; MCP clients get the same command as a silent stdio server.',\n 'Explicit card command: npx -y -p mcp-scraper@latest mcp-scraper-install',\n 'Customer auth setup: run browser_profile_onboard, send the watch_url, let the user sign in, then call browser_profile_status until AUTHENTICATED.',\n 'Reconnect setup: if a saved profile shows login controls later, run browser_profile_onboard again with the same profile name.',\n 'Start with workflow_suggest for broad jobs like market analysis, ICP research, CRO audits, brand briefs, content gaps, and AI visibility.',\n 'For MCP clients, use mcp-scraper-combined so one install can mix SERP, Maps, browser, reports, and saved MCP resources.',\n 'If you hit the concurrency limit, add an extra slot for $5/month with mcp-scraper-cli billing concurrency checkout.',\n '',\n `${colorize('Ready.', 'lime', color)} Install the combined MCP server with one command:`,\n '',\n colorize('Setup doctor', 'amber', color),\n 'npx -y -p mcp-scraper@latest mcp-scraper-cli doctor',\n '',\n colorize('Hosted profile setup', 'amber', color),\n 'In your MCP client, call browser_profile_onboard with email=\"seo@example.com\" and domain=\"chatgpt.com\".',\n 'Give the returned watch_url to the user. After they sign in, call browser_profile_status, then browser_open with the returned profile.',\n '',\n colorize('Claude Code one-command setup', 'amber', color),\n claudeCommand,\n 'Then fully exit Claude Code and open a new Claude terminal. Check with: claude mcp list',\n '',\n colorize('Codex config', 'amber', color),\n codexConfig,\n '',\n colorize('Claude Desktop Extension', 'amber', color),\n 'Download: https://mcpscraper.dev/downloads/mcp-scraper.mcpb',\n '',\n colorize('Safety note:', 'muted', color),\n 'mcp-scraper-combined prints this card only when stdin/stdout are an interactive TTY. In MCP clients it writes only JSON-RPC to stdout.',\n 'Use --stdio or MCP_SCRAPER_FORCE_STDIO=1 to force server mode from a terminal.',\n '',\n ].join('\\n')\n}\n","export const PACKAGE_VERSION = '0.3.15'\n","#!/usr/bin/env node\nimport { renderInstallTerminal } from '../src/install-terminal.js'\nimport { PACKAGE_VERSION } from '../src/version.js'\n\nconst noColor =\n process.argv.includes('--no-color') ||\n process.env.NO_COLOR !== undefined ||\n process.env.FORCE_COLOR === '0' ||\n !process.stdout.isTTY\n\nconst help = process.argv.includes('--help') || process.argv.includes('-h')\nif (help) {\n process.stdout.write([\n 'Usage: mcp-scraper-install [--no-color]',\n '',\n 'Prints the branded MCP Scraper terminal install card and copyable install commands.',\n 'mcp-scraper-combined prints the same card in a human terminal and runs as the MCP stdio server in clients.',\n '',\n ].join('\\n'))\n process.exit(0)\n}\n\nprocess.stdout.write(renderInstallTerminal({\n version: PACKAGE_VERSION,\n color: !noColor,\n apiKeyConfigured: Boolean(process.env.MCP_SCRAPER_API_KEY?.trim()),\n}))\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/install-terminal.ts","../../src/version.ts","../../bin/mcp-scraper-install.ts"],"sourcesContent":["export interface InstallTerminalOptions {\n color?: boolean\n version: string\n apiKeyConfigured?: boolean\n}\n\ntype Tone = 'cyan' | 'lime' | 'amber' | 'red' | 'muted' | 'bold'\n\nconst CODES: Record<Tone | 'reset', string> = {\n reset: '\\x1b[0m',\n cyan: '\\x1b[36m',\n lime: '\\x1b[32m',\n amber: '\\x1b[33m',\n red: '\\x1b[31m',\n muted: '\\x1b[90m',\n bold: '\\x1b[1m',\n}\n\nfunction colorize(value: string, tone: Tone, enabled: boolean): string {\n if (!enabled) return value\n return `${CODES[tone]}${value}${CODES.reset}`\n}\n\nfunction toolRow(label: string, tools: string[], enabled: boolean): string {\n const padded = label.padEnd(9, ' ')\n return ` ${colorize(padded, 'muted', enabled)} ${tools.join(colorize(' . ', 'muted', enabled))}`\n}\n\nexport function renderInstallTerminal(options: InstallTerminalOptions): string {\n const color = options.color ?? true\n const apiKeyValue = options.apiKeyConfigured ? '$MCP_SCRAPER_API_KEY' : 'sk_live_your_key'\n const ascii = String.raw`\n __ __ ____ ____ \n| \\/ |/ ___| _ \\\n| |\\/| | | | |_) |\n| | | | |___| __/\n|_| |_|\\____|_|\n\n ____ ____ ____ _ ____ _____ ____\n/ ___| / ___| _ \\ / \\ | _ \\| ____| _ \\\n\\___ \\| | | |_) | / _ \\ | |_) | _| | |_) |\n ___) | |___| _ < / ___ \\| __/| |___| _ <\n|____/ \\____|_| \\_\\/_/ \\_\\_| |_____|_| \\_\\\n`\n\n const claudeCommand = [\n `MCP_SCRAPER_API_KEY=${apiKeyValue} npx -y -p mcp-scraper@latest \\\\`,\n ' mcp-scraper-cli agent install claude --apply',\n ].join('\\n')\n\n const codexConfig = [\n '[mcp_servers.mcp-scraper]',\n 'command = \"npx\"',\n 'args = [\"-y\", \"-p\", \"mcp-scraper@latest\", \"mcp-scraper-combined\"]',\n `env = { MCP_SCRAPER_API_KEY = \"${apiKeyValue}\" }`,\n ].join('\\n')\n\n return [\n colorize(`mcp-scraper v${options.version}`, 'bold', color),\n colorize('> mcp-scraper-install', 'muted', color),\n colorize(ascii, 'amber', color),\n `${colorize('MCP Scraper Agent', 'cyan', color)} . v${options.version} . mcpscraper.dev`,\n '1/1 install surfaces ready',\n 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),\n '',\n `${colorize('Tools', 'cyan', color)} ${colorize('(45 MCP tools)', 'muted', color)}`,\n toolRow('search', ['harvest_paa', 'search_serp', 'maps_search', 'maps_place_intel'], color),\n toolRow('extract', ['extract_url', 'map_site_urls', 'extract_site', 'audit_site', 'directory_workflow'], color),\n toolRow('build', ['rank_tracker_workflow', 'cron plan', 'database prompt'], color),\n 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),\n 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),\n toolRow('account', ['credits_info', 'reports', 'MCP resources'], color),\n `${colorize('Workflows', 'cyan', color)} ${colorize('(MCP + CLI + API)', 'muted', color)}`,\n toolRow('route', ['workflow_list', 'workflow_suggest', 'workflow_run', 'workflow_step', 'workflow_status', 'workflow_artifact_read'], color),\n toolRow('seo', ['directory', 'agent-packet', 'competitive audit', 'map/serp comparison', 'PAA/AIO briefs', 'scheduled runs'], color),\n '',\n colorize('Usage tips:', 'amber', color),\n 'Run mcp-scraper-install for this visible card. Run mcp-scraper-cli for setup utilities and subcommands.',\n 'Run mcp-scraper-combined in a human terminal to print this card; MCP clients get the same command as a silent stdio server.',\n 'Explicit card command: npx -y -p mcp-scraper@latest mcp-scraper-install',\n 'Customer auth setup: run browser_profile_onboard, send the watch_url, let the user sign in, then call browser_profile_status until AUTHENTICATED.',\n 'Reconnect setup: if a saved profile shows login controls later, run browser_profile_onboard again with the same profile name.',\n 'Start with workflow_suggest for broad jobs like market analysis, ICP research, CRO audits, brand briefs, content gaps, and AI visibility.',\n 'For MCP clients, use mcp-scraper-combined so one install can mix SERP, Maps, browser, reports, and saved MCP resources.',\n 'If you hit the concurrency limit, add an extra slot for $5/month with mcp-scraper-cli billing concurrency checkout.',\n '',\n `${colorize('Ready.', 'lime', color)} Install the combined MCP server with one command:`,\n '',\n colorize('Setup doctor', 'amber', color),\n 'npx -y -p mcp-scraper@latest mcp-scraper-cli doctor',\n '',\n colorize('Hosted profile setup', 'amber', color),\n 'In your MCP client, call browser_profile_onboard with email=\"seo@example.com\" and domain=\"chatgpt.com\".',\n 'Give the returned watch_url to the user. After they sign in, call browser_profile_status, then browser_open with the returned profile.',\n '',\n colorize('Claude Code one-command setup', 'amber', color),\n claudeCommand,\n 'Then fully exit Claude Code and open a new Claude terminal. Check with: claude mcp list',\n '',\n colorize('Codex config', 'amber', color),\n codexConfig,\n '',\n colorize('Claude Desktop Extension', 'amber', color),\n 'Download: https://mcpscraper.dev/downloads/mcp-scraper.mcpb',\n '',\n colorize('Safety note:', 'muted', color),\n 'mcp-scraper-combined prints this card only when stdin/stdout are an interactive TTY. In MCP clients it writes only JSON-RPC to stdout.',\n 'Use --stdio or MCP_SCRAPER_FORCE_STDIO=1 to force server mode from a terminal.',\n '',\n ].join('\\n')\n}\n","export const PACKAGE_VERSION = '0.3.17'\n","#!/usr/bin/env node\nimport { renderInstallTerminal } from '../src/install-terminal.js'\nimport { PACKAGE_VERSION } from '../src/version.js'\n\nconst noColor =\n process.argv.includes('--no-color') ||\n process.env.NO_COLOR !== undefined ||\n process.env.FORCE_COLOR === '0' ||\n !process.stdout.isTTY\n\nconst help = process.argv.includes('--help') || process.argv.includes('-h')\nif (help) {\n process.stdout.write([\n 'Usage: mcp-scraper-install [--no-color]',\n '',\n 'Prints the branded MCP Scraper terminal install card and copyable install commands.',\n 'mcp-scraper-combined prints the same card in a human terminal and runs as the MCP stdio server in clients.',\n '',\n ].join('\\n'))\n process.exit(0)\n}\n\nprocess.stdout.write(renderInstallTerminal({\n version: PACKAGE_VERSION,\n color: !noColor,\n apiKeyConfigured: Boolean(process.env.MCP_SCRAPER_API_KEY?.trim()),\n}))\n"],"mappings":"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","names":[]}
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  renderInstallTerminal
4
- } from "../chunk-7Y6JDBML.js";
4
+ } from "../chunk-ACIUCZ27.js";
5
5
  import {
6
6
  PACKAGE_VERSION
7
- } from "../chunk-2MX5WOII.js";
7
+ } from "../chunk-PVWWY5NV.js";
8
8
 
9
9
  // bin/mcp-scraper-install.ts
10
10
  var noColor = process.argv.includes("--no-color") || process.env.NO_COLOR !== void 0 || process.env.FORCE_COLOR === "0" || !process.stdout.isTTY;
@@ -209,6 +209,9 @@ var HttpMcpToolExecutor = class {
209
209
  extractSite(input) {
210
210
  return this.call("/extract-site", input);
211
211
  }
212
+ auditSite(input) {
213
+ return this.call("/extract-site", input);
214
+ }
212
215
  youtubeHarvest(input) {
213
216
  return this.call("/youtube/harvest", input);
214
217
  }
@@ -300,7 +303,7 @@ var import_node_fs2 = require("fs");
300
303
  var import_node_path2 = require("path");
301
304
 
302
305
  // src/version.ts
303
- var PACKAGE_VERSION = "0.3.15";
306
+ var PACKAGE_VERSION = "0.3.17";
304
307
 
305
308
  // src/mcp/mcp-response-formatter.ts
306
309
  var import_node_fs = require("fs");
@@ -504,6 +507,99 @@ function buildLinkGraph(pages, startUrl) {
504
507
  return { edges, metrics };
505
508
  }
506
509
 
510
+ // src/api/seo-link-report.ts
511
+ function registrableDomain(host) {
512
+ const h = host.replace(/^www\./, "").toLowerCase();
513
+ const parts = h.split(".");
514
+ return parts.length <= 2 ? h : parts.slice(-2).join(".");
515
+ }
516
+ function buildLinkReport(edges, metrics, siteUrl) {
517
+ let siteReg = "";
518
+ try {
519
+ siteReg = registrableDomain(new URL(siteUrl).hostname);
520
+ } catch {
521
+ siteReg = "";
522
+ }
523
+ const internalEdges = edges.filter((e) => e.internal);
524
+ const pages = metrics.length;
525
+ const orphans = metrics.filter((m) => m.orphan).length;
526
+ const brokenInternal = internalEdges.filter((e) => e.targetStatus != null && e.targetStatus >= 400).length;
527
+ const sumInlinks = metrics.reduce((a, m) => a + m.inlinks, 0);
528
+ const sumOutlinks = metrics.reduce((a, m) => a + m.outlinksInternal + m.outlinksExternal, 0);
529
+ const distribution = { zero: 0, oneToTwo: 0, threeToTen: 0, elevenPlus: 0 };
530
+ for (const m of metrics) {
531
+ if (m.inlinks === 0) distribution.zero++;
532
+ else if (m.inlinks <= 2) distribution.oneToTwo++;
533
+ else if (m.inlinks <= 10) distribution.threeToTen++;
534
+ else distribution.elevenPlus++;
535
+ }
536
+ 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 }));
537
+ const domMap = /* @__PURE__ */ new Map();
538
+ let externalTotal = 0;
539
+ for (const e of edges) {
540
+ let domain;
541
+ try {
542
+ domain = registrableDomain(new URL(e.to).hostname);
543
+ } catch {
544
+ continue;
545
+ }
546
+ if (!domain || domain === siteReg) continue;
547
+ externalTotal++;
548
+ const d = domMap.get(domain) ?? { links: 0, nofollow: 0, pages: /* @__PURE__ */ new Set() };
549
+ d.links++;
550
+ if (e.nofollow) d.nofollow++;
551
+ d.pages.add(e.from);
552
+ domMap.set(domain, d);
553
+ }
554
+ 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);
555
+ const round = (n) => Math.round(n * 10) / 10;
556
+ return {
557
+ summary: {
558
+ internal: {
559
+ totalLinks: internalEdges.length,
560
+ pages,
561
+ orphans,
562
+ brokenInternal,
563
+ avgInlinks: pages ? round(sumInlinks / pages) : 0,
564
+ avgOutlinks: pages ? round(sumOutlinks / pages) : 0,
565
+ distribution,
566
+ topByInlinks
567
+ },
568
+ external: {
569
+ totalLinks: externalTotal,
570
+ uniqueDomains: externalDomains.length,
571
+ topDomains: externalDomains.slice(0, 20)
572
+ }
573
+ },
574
+ externalDomains
575
+ };
576
+ }
577
+ function renderLinkReport(r) {
578
+ const s = r.summary;
579
+ const lines = [
580
+ `## Link analysis`,
581
+ `**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`,
582
+ `**External:** ${s.external.totalLinks} links to ${s.external.uniqueDomains} domains`,
583
+ ``,
584
+ `### Inlink distribution`,
585
+ `- 0 inlinks (orphans): ${s.internal.distribution.zero}`,
586
+ `- 1\u20132: ${s.internal.distribution.oneToTwo}`,
587
+ `- 3\u201310: ${s.internal.distribution.threeToTen}`,
588
+ `- 11+: ${s.internal.distribution.elevenPlus}`,
589
+ ``,
590
+ `### Top 20 internal pages by inlinks`,
591
+ `| inlinks | out (int/ext) | URL |`,
592
+ `|---|---|---|`,
593
+ ...s.internal.topByInlinks.map((p) => `| ${p.inlinks} | ${p.outlinksInternal}/${p.outlinksExternal} | ${p.url} |`),
594
+ ``,
595
+ `### Top 20 external domains by links`,
596
+ `| links | nofollow | from pages | domain |`,
597
+ `|---|---|---|---|`,
598
+ ...s.external.topDomains.map((d) => `| ${d.links} | ${d.nofollow} | ${d.pages} | ${d.domain} |`)
599
+ ];
600
+ return lines.join("\n");
601
+ }
602
+
507
603
  // src/api/seo-issues.ts
508
604
  var THIN_WORDS = 200;
509
605
  var TITLE_MAX = 60;
@@ -612,6 +708,137 @@ _Thresholds: title ${TITLE_MAX}ch/${TITLE_PX_MAX}px, meta ${META_MAX}ch, H1 ${H1
612
708
  ].join("\n");
613
709
  }
614
710
 
711
+ // src/api/image-audit.ts
712
+ var OVER_BYTES = 100 * 1024;
713
+ var MODERN = /* @__PURE__ */ new Set(["webp", "avif", "svg", "svg+xml"]);
714
+ function formatBytes(n) {
715
+ if (n == null) return null;
716
+ if (n < 1024) return `${n} B`;
717
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
718
+ return `${(n / (1024 * 1024)).toFixed(2)} MB`;
719
+ }
720
+ var decodeEntities = (u) => u.replace(/&amp;/g, "&").replace(/&#0?38;/g, "&").replace(/&#x26;/gi, "&").trim();
721
+ var dedupKey = (u) => u.replace(/^https?:\/\//i, "//").replace(/\/$/, "");
722
+ var formatOf = (ct, url) => {
723
+ if (ct) {
724
+ const m = ct.split(";")[0].trim().toLowerCase();
725
+ if (m.startsWith("image/")) return m.replace("image/", "");
726
+ }
727
+ return (url.split("?")[0].match(/\.([a-z0-9]+)$/i)?.[1] || "unknown").toLowerCase();
728
+ };
729
+ function collectUrls(pages) {
730
+ const byKey = /* @__PURE__ */ new Map();
731
+ for (const p of pages) for (const raw of p.imageLinks || []) {
732
+ const u = decodeEntities(raw);
733
+ if (!/^https?:\/\//i.test(u)) continue;
734
+ if (/[{}]|%7[bd]/i.test(u)) continue;
735
+ const k = dedupKey(u);
736
+ const prev = byKey.get(k);
737
+ if (!prev || /^https:/i.test(u) && /^http:/i.test(prev)) byKey.set(k, u);
738
+ }
739
+ return [...byKey.values()];
740
+ }
741
+ async function sizeAndType(url, timeoutMs) {
742
+ const ctrl = new AbortController();
743
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
744
+ const read = (res) => ({
745
+ len: res.headers.get("content-length"),
746
+ cr: res.headers.get("content-range"),
747
+ ct: res.headers.get("content-type"),
748
+ status: res.status
749
+ });
750
+ try {
751
+ let bytes = null;
752
+ let ct = null;
753
+ let status = null;
754
+ try {
755
+ const h = read(await fetch(url, { method: "HEAD", redirect: "follow", signal: ctrl.signal }));
756
+ status = h.status;
757
+ ct = h.ct;
758
+ if (h.len) bytes = Number(h.len);
759
+ } catch {
760
+ }
761
+ if (bytes == null) {
762
+ const g = await fetch(url, { method: "GET", headers: { Range: "bytes=0-0" }, redirect: "follow", signal: ctrl.signal });
763
+ const r = read(g);
764
+ status = status ?? r.status;
765
+ ct = ct ?? r.ct;
766
+ if (r.cr && r.cr.includes("/")) {
767
+ const tail = r.cr.split("/")[1];
768
+ if (tail && tail !== "*") bytes = Number(tail);
769
+ } else if (r.len) bytes = Number(r.len);
770
+ try {
771
+ await g.body?.cancel();
772
+ } catch {
773
+ }
774
+ }
775
+ return { url, status, bytes: Number.isFinite(bytes) ? bytes : null, contentType: ct };
776
+ } catch (e) {
777
+ return { url, status: null, bytes: null, contentType: null, error: String(e.message || e) };
778
+ } finally {
779
+ clearTimeout(t);
780
+ }
781
+ }
782
+ async function pool(items, n, fn) {
783
+ const out = new Array(items.length);
784
+ let i = 0;
785
+ await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => {
786
+ while (i < items.length) {
787
+ const idx = i++;
788
+ out[idx] = await fn(items[idx]);
789
+ }
790
+ }));
791
+ return out;
792
+ }
793
+ async function auditImages(pages, opts = {}) {
794
+ const concurrency = opts.concurrency ?? 12;
795
+ const timeoutMs = opts.timeoutMs ?? 12e3;
796
+ const urls = collectUrls(pages).slice(0, opts.max ?? 5e3);
797
+ const heads = await pool(urls, concurrency, (u) => sizeAndType(u, timeoutMs));
798
+ const rows = heads.map((r) => {
799
+ const format = formatOf(r.contentType, r.url);
800
+ return {
801
+ ...r,
802
+ size: formatBytes(r.bytes),
803
+ format,
804
+ over100kb: r.bytes != null && r.bytes > OVER_BYTES,
805
+ legacyFormat: format !== "unknown" && !MODERN.has(format)
806
+ };
807
+ });
808
+ const sized = rows.filter((r) => r.bytes != null);
809
+ const totalBytes = sized.reduce((a, r) => a + r.bytes, 0);
810
+ const formatCounts = {};
811
+ for (const r of rows) formatCounts[r.format] = (formatCounts[r.format] || 0) + 1;
812
+ return {
813
+ rows: rows.sort((a, b) => (b.bytes ?? 0) - (a.bytes ?? 0)),
814
+ summary: {
815
+ unique: rows.length,
816
+ sized: sized.length,
817
+ totalBytes,
818
+ totalSize: formatBytes(totalBytes) ?? "0 B",
819
+ avgSize: formatBytes(Math.round(totalBytes / (sized.length || 1))) ?? "0 B",
820
+ over100kb: rows.filter((r) => r.over100kb).length,
821
+ legacyFormat: rows.filter((r) => r.legacyFormat).length,
822
+ formatCounts
823
+ }
824
+ };
825
+ }
826
+ function renderImageSection(audit) {
827
+ const s = audit.summary;
828
+ const fmt = Object.entries(s.formatCounts).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k}:${v}`).join(" ");
829
+ const heaviest = audit.rows.filter((r) => r.over100kb).slice(0, 15);
830
+ const lines = [
831
+ `## Images`,
832
+ `**${s.unique} unique images** \xB7 ${s.totalSize} total \xB7 ${s.avgSize} avg \xB7 ${s.over100kb} over 100 KB \xB7 ${s.legacyFormat} legacy format`,
833
+ `Formats: ${fmt}`
834
+ ];
835
+ if (heaviest.length) {
836
+ lines.push("", "| Size | Format | URL |", "|------|--------|-----|");
837
+ for (const r of heaviest) lines.push(`| ${r.size} | ${r.format} | ${r.url} |`);
838
+ }
839
+ return lines.join("\n");
840
+ }
841
+
615
842
  // src/mcp/mcp-response-formatter.ts
616
843
  var reportSavingEnabled = true;
617
844
  function sanitizeVendorText(text) {
@@ -647,7 +874,7 @@ var BULK_URL_THRESHOLD = 500;
647
874
  function reportSavingActive() {
648
875
  return reportSavingEnabled && process.env.MCP_SCRAPER_SAVE_REPORTS !== "false";
649
876
  }
650
- function saveBulkSite(siteUrl, pages, seo) {
877
+ function saveBulkSite(siteUrl, pages, seo, imageAudit) {
651
878
  if (!reportSavingActive()) return null;
652
879
  try {
653
880
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
@@ -670,15 +897,35 @@ function saveBulkSite(siteUrl, pages, seo) {
670
897
  (0, import_node_fs.writeFileSync)((0, import_node_path.join)(pagesDir, fname), content, "utf8");
671
898
  return `| ${i + 1} | ${cell(p.title ?? "Untitled")} | ${p.url} | pages/${fname} |`;
672
899
  });
900
+ const dataFilesSection = seo ? [
901
+ `
902
+ ## Data files (in this folder)`,
903
+ `- \`report.md\` \u2014 SEO issues summary (counts + example URLs)`,
904
+ `- \`issues.json\` \u2014 every finding with the full list of offender URLs`,
905
+ `- \`pages.jsonl\` \u2014 per-page SEO fields, one JSON object per page`,
906
+ `- \`links.jsonl\` \u2014 internal/external link edges (anchor, rel, target status)`,
907
+ `- \`link-metrics.jsonl\` \u2014 inlinks, crawl depth, orphan flags per page`,
908
+ `- \`link-report.md\` \u2014 link analysis: top internal pages by inlinks, top external domains, distribution`,
909
+ `- \`links-summary.json\` \u2014 link totals + top-20 internal pages + top-20 external domains`,
910
+ `- \`external-domains.json\` \u2014 every external domain linked to, ordered by link count`,
911
+ ...imageAudit ? [
912
+ `- \`images.jsonl\` \u2014 every image with byte size, format, over-100KB and legacy-format flags`,
913
+ `- \`images-summary.json\` \u2014 image totals (count, total weight, over-100KB, legacy)`
914
+ ] : [],
915
+ ...seo.branding ? [`- \`branding.json\` \u2014 site logo, colors, fonts`] : []
916
+ ].join("\n") : "";
673
917
  const index = [
674
918
  `# Site Extract: ${siteUrl}`,
675
- `**${pages.length} pages** \u2014 each saved as an individual Markdown file under \`pages/\`.`,
919
+ `**${pages.length} pages** crawled. Everything is in this folder.`,
920
+ `- Page content: one Markdown file per page under \`pages/\` (listed in the table below).`,
921
+ seo ? `- SEO crawl data: the data files listed below.` : "",
922
+ dataFilesSection,
676
923
  `
677
924
  ## Pages
678
925
  | # | Title | URL | File |
679
926
  |---|-------|-----|------|
680
927
  ${indexRows.join("\n")}`
681
- ].join("\n");
928
+ ].filter(Boolean).join("\n");
682
929
  const indexFile = (0, import_node_path.join)(dir, "index.md");
683
930
  (0, import_node_fs.writeFileSync)(indexFile, index, "utf8");
684
931
  let seoFiles;
@@ -693,8 +940,23 @@ ${indexRows.join("\n")}`
693
940
  (0, import_node_fs.writeFileSync)(linksJsonl, toJsonl(seo.edges), "utf8");
694
941
  (0, import_node_fs.writeFileSync)(metricsJsonl, toJsonl(seo.metrics), "utf8");
695
942
  (0, import_node_fs.writeFileSync)(issuesFile, JSON.stringify(seo.issues, null, 2), "utf8");
696
- (0, import_node_fs.writeFileSync)(reportFile, seo.reportMd, "utf8");
697
- seoFiles = [pagesJsonl, linksJsonl, metricsJsonl, issuesFile, reportFile];
943
+ (0, import_node_fs.writeFileSync)(reportFile, seo.reportMd + (imageAudit ? `
944
+
945
+ ${renderImageSection(imageAudit)}` : ""), "utf8");
946
+ const linkReportFile = (0, import_node_path.join)(dir, "link-report.md");
947
+ const linksSummaryFile = (0, import_node_path.join)(dir, "links-summary.json");
948
+ const externalDomainsFile = (0, import_node_path.join)(dir, "external-domains.json");
949
+ (0, import_node_fs.writeFileSync)(linkReportFile, renderLinkReport(seo.linkReport), "utf8");
950
+ (0, import_node_fs.writeFileSync)(linksSummaryFile, JSON.stringify(seo.linkReport.summary, null, 2), "utf8");
951
+ (0, import_node_fs.writeFileSync)(externalDomainsFile, JSON.stringify(seo.linkReport.externalDomains, null, 2), "utf8");
952
+ seoFiles = [pagesJsonl, linksJsonl, metricsJsonl, issuesFile, reportFile, linkReportFile, linksSummaryFile, externalDomainsFile];
953
+ if (imageAudit) {
954
+ const imagesJsonl = (0, import_node_path.join)(dir, "images.jsonl");
955
+ const imagesSummary = (0, import_node_path.join)(dir, "images-summary.json");
956
+ (0, import_node_fs.writeFileSync)(imagesJsonl, imageAudit.rows.map((r) => JSON.stringify(r)).join("\n"), "utf8");
957
+ (0, import_node_fs.writeFileSync)(imagesSummary, JSON.stringify(imageAudit.summary, null, 2), "utf8");
958
+ seoFiles.push(imagesJsonl, imagesSummary);
959
+ }
698
960
  if (seo.branding) {
699
961
  const brandingFile = (0, import_node_path.join)(dir, "branding.json");
700
962
  (0, import_node_fs.writeFileSync)(brandingFile, JSON.stringify(seo.branding, null, 2), "utf8");
@@ -1139,6 +1401,7 @@ function buildSeoExport(siteUrl, pages, branding) {
1139
1401
  metrics: [...metrics.values()],
1140
1402
  issues,
1141
1403
  reportMd: renderIssueReport(siteUrl, pages, issues, metrics),
1404
+ linkReport: buildLinkReport(edges, [...metrics.values()], siteUrl),
1142
1405
  branding: branding ?? void 0
1143
1406
  };
1144
1407
  }
@@ -1169,24 +1432,23 @@ function formatExtractSite(raw, input) {
1169
1432
  durationMs: d.durationMs ?? 0
1170
1433
  };
1171
1434
  if (pages.length > BULK_PAGE_THRESHOLD) {
1172
- const seo = buildSeoExport(input.url, pages, d.branding);
1173
1435
  const bulk = saveBulkSite(input.url, pages.map((p) => ({
1174
1436
  url: String(p.url ?? ""),
1175
1437
  title: p.title ?? null,
1176
1438
  bodyMarkdown: p.bodyMarkdown ?? null,
1177
1439
  metaDescription: p.metaDescription ?? null,
1178
1440
  schemaTypes: schemaTypesOf(p)
1179
- })), seo);
1441
+ })));
1180
1442
  const preview = pages.slice(0, BULK_PAGE_THRESHOLD).map(pageRow).join("\n");
1181
- const seoLine = bulk?.seoFiles?.length ? `
1182
- - **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)` : "";
1183
1443
  const location = bulk ? `
1184
1444
  ## \u{1F4C1} Bulk scrape saved
1185
- - **Folder:** \`${bulk.dir}\`
1186
- - **Index:** \`${bulk.indexFile}\`
1187
- - **Files:** ${bulk.fileCount} page files under \`pages/\`${seoLine}
1445
+ - **Folder:** \`${bulk.dir}\` \u2190 all scraped page content is here
1446
+ - **Start here:** \`${bulk.indexFile}\` \u2014 lists every page and its file
1447
+ - **Page content:** ${bulk.fileCount} Markdown files under \`pages/\` (one per page)
1188
1448
 
1189
- 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.` : `
1449
+ 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/\`.
1450
+
1451
+ \u{1F4A1} Want the full technical SEO audit (headings, link graph, indexability, image sizes/formats, issues)? Run \`audit_site\` on this URL instead.` : `
1190
1452
  ## \u26A0\uFE0F Bulk scrape not saved
1191
1453
  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.`;
1192
1454
  const full2 = [
@@ -1230,6 +1492,62 @@ ${body}` : "_(no content extracted)_"
1230
1492
  ${pageDetails}${tips}`;
1231
1493
  return { ...oneBlock(full, diskReport), structuredContent };
1232
1494
  }
1495
+ async function formatAuditSite(raw, input) {
1496
+ const parsed = parseData(raw);
1497
+ if ("error" in parsed) return { content: [{ type: "text", text: parsed.error }], isError: true };
1498
+ const d = parsed.data;
1499
+ const pages = d.pages ?? [];
1500
+ const schemaTypesOf = (p) => p.kpo?.type ?? (Array.isArray(p.schema) && p.schema.length ? [`${p.schema.length} block(s)`] : []);
1501
+ const seo = buildSeoExport(input.url, pages, d.branding);
1502
+ const imageAudit = await auditImages(pages, { concurrency: 12 });
1503
+ const bulk = saveBulkSite(input.url, pages.map((p) => ({
1504
+ url: String(p.url ?? ""),
1505
+ title: p.title ?? null,
1506
+ bodyMarkdown: p.bodyMarkdown ?? null,
1507
+ metaDescription: p.metaDescription ?? null,
1508
+ schemaTypes: schemaTypesOf(p)
1509
+ })), seo, imageAudit);
1510
+ 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");
1511
+ const img = imageAudit.summary;
1512
+ const imgLine = `${img.unique} images \xB7 ${img.totalSize} total \xB7 ${img.over100kb} over 100 KB \xB7 ${img.legacyFormat} legacy format`;
1513
+ const lr = seo.linkReport.summary;
1514
+ const topExt = lr.external.topDomains.slice(0, 3).map((dm) => `${dm.domain} (${dm.links})`).join(", ") || "\u2014";
1515
+ 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}`;
1516
+ const durationLine = `**${pages.length} pages** \xB7 ${((d.durationMs ?? 0) / 1e3).toFixed(1)}s`;
1517
+ const structuredContent = {
1518
+ url: input.url,
1519
+ pageCount: pages.length,
1520
+ durationMs: d.durationMs ?? 0,
1521
+ bulkFolder: bulk?.dir ?? null,
1522
+ issues: Object.fromEntries(Object.entries(seo.issues).map(([k, v]) => [k, v.count])),
1523
+ images: { unique: img.unique, totalBytes: img.totalBytes, over100kb: img.over100kb, legacyFormat: img.legacyFormat },
1524
+ links: { internal: lr.internal.totalLinks, external: lr.external.totalLinks, orphans: lr.internal.orphans, brokenInternal: lr.internal.brokenInternal, externalDomains: lr.external.uniqueDomains }
1525
+ };
1526
+ const location = bulk ? `
1527
+ ## \u{1F4C1} Technical audit saved
1528
+ - **Folder:** \`${bulk.dir}\` \u2190 full audit + page content here
1529
+ - **Start here:** \`${bulk.indexFile}\` \u2014 maps every file in the folder
1530
+ - **Data:** \`report.md\` (issues + image audit), \`issues.json\`, \`pages.jsonl\` (per-page fields), \`links.jsonl\`, \`link-metrics.jsonl\`, \`images.jsonl\`
1531
+
1532
+ 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.` : `
1533
+ ## \u26A0\uFE0F Audit not saved
1534
+ Report saving is disabled in this environment. Enable \`MCP_SCRAPER_SAVE_REPORTS\` to capture the technical audit.`;
1535
+ const full = [
1536
+ `# Technical SEO Audit: ${input.url}`,
1537
+ durationLine,
1538
+ location,
1539
+ `
1540
+ ## Top issues
1541
+ ${topIssues || "_none found_"}`,
1542
+ `
1543
+ ## Links
1544
+ ${linkLine}`,
1545
+ `
1546
+ ## Images
1547
+ ${imgLine}`
1548
+ ].join("\n");
1549
+ return { content: [{ type: "text", text: full }], structuredContent };
1550
+ }
1233
1551
  function formatYoutubeHarvest(raw, input) {
1234
1552
  const parsed = parseData(raw);
1235
1553
  if ("error" in parsed) return { content: [{ type: "text", text: parsed.error }], isError: true };
@@ -2469,12 +2787,18 @@ var MapSiteUrlsInputSchema = {
2469
2787
  maxUrls: import_zod2.z.number().int().min(1).max(1e4).optional().describe("Maximum URLs to discover. Use 100 for normal maps, up to 10000 for a full inventory. Large maps (over 500 URLs) write the complete inventory to a local file and return only a summary plus the file path instead of the full list inline.")
2470
2788
  };
2471
2789
  var ExtractSiteInputSchema = {
2472
- url: import_zod2.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."),
2790
+ url: import_zod2.z.string().url().describe("Public website URL or domain to crawl for page CONTENT across multiple pages (map + scrape). Use when the user wants the content/text/markdown of a site's pages. For a technical SEO audit (issues, link graph, indexability, headings, image weights) use audit_site instead \u2014 extract_site returns content only, not analysis."),
2473
2791
  maxPages: import_zod2.z.number().int().min(1).max(1e4).optional().describe("Maximum pages to extract. Use 50 for a normal crawl, up to 10000 for a full-site bulk scrape. Bulk crawls (over 25 pages) switch to folder mode: every page is saved as its own Markdown file in a local folder and the response returns only a summary plus the folder path, so the full content never floods the context window."),
2474
2792
  rotateProxies: import_zod2.z.boolean().optional().describe("Route page fetches through rotating residential proxies in headful browsers to defeat rate-limiting and bot blocks (403/429). Discovers URLs from the sitemap, fetches in batches with a fresh proxy per batch, retries failures on a new proxy, and automatically parallelizes across the account's concurrency slots. Slower and pricier than the default fetch path, so use only when a site blocks normal crawling."),
2475
2793
  rotateProxyEvery: import_zod2.z.number().int().min(1).max(100).optional().describe("When rotateProxies is on, how many pages to fetch per browser/proxy before discarding it and minting a fresh one. Default 30. Lower values rotate IPs more aggressively against strict rate limits."),
2476
2794
  formats: import_zod2.z.array(import_zod2.z.enum(["markdown", "links", "json", "images", "branding"])).optional().describe("Which data formats to include in the per-page output. markdown=page content as Markdown, links=internal/external link graph, json=structured data/schema, images=per-page image URLs (links only, no downloads), branding=site-level logo/colors/fonts captured once from the homepage (requires a browser, adds time). markdown, links, json, and images are always captured cheaply from the HTML; branding is the only opt-in capture. Defaults to markdown+links when omitted.")
2477
2795
  };
2796
+ var AuditSiteInputSchema = {
2797
+ url: import_zod2.z.string().url().describe("Public website URL or domain to run a full technical SEO audit on. Use when the user asks for a technical audit, SEO audit, site health check, or a Screaming-Frog-style crawl \u2014 i.e. they want ANALYSIS (issues, internal link graph, indexability, heading breakdown, image sizes/formats), not just page content. For plain content scraping use extract_site instead."),
2798
+ maxPages: import_zod2.z.number().int().min(1).max(1e4).optional().describe("Maximum pages to crawl and audit. Use 50 for a normal audit, up to 10000 for a full-site audit. The audit always writes a folder of analysis files (report.md, issues.json, pages.jsonl, links.jsonl, link-metrics.jsonl, images.jsonl) plus per-page content, and returns a headline summary plus the folder path."),
2799
+ rotateProxies: import_zod2.z.boolean().optional().describe("Route page fetches through rotating residential proxies to defeat rate-limiting and bot blocks (403/429). Slower and pricier than the default fetch path, so use only when a site blocks normal crawling."),
2800
+ rotateProxyEvery: import_zod2.z.number().int().min(1).max(100).optional().describe("When rotateProxies is on, how many pages to fetch per browser/proxy before discarding it and minting a fresh one. Default 30.")
2801
+ };
2478
2802
  var YoutubeHarvestInputSchema = {
2479
2803
  mode: import_zod2.z.enum(["search", "channel"]).describe("Use search for topic/keyword requests. Use channel when the user provides @handle, channel ID, or channel URL."),
2480
2804
  query: import_zod2.z.string().optional().describe("Required when mode is search. The YouTube search topic in the user\u2019s words."),
@@ -2775,6 +3099,26 @@ var ExtractSiteOutputSchema = {
2775
3099
  })),
2776
3100
  durationMs: import_zod2.z.number().min(0)
2777
3101
  };
3102
+ var AuditSiteOutputSchema = {
3103
+ url: import_zod2.z.string(),
3104
+ pageCount: import_zod2.z.number().int().min(0),
3105
+ durationMs: import_zod2.z.number().min(0),
3106
+ bulkFolder: import_zod2.z.string().nullable(),
3107
+ issues: import_zod2.z.record(import_zod2.z.string(), import_zod2.z.number()),
3108
+ images: import_zod2.z.object({
3109
+ unique: import_zod2.z.number().int().min(0),
3110
+ totalBytes: import_zod2.z.number().min(0),
3111
+ over100kb: import_zod2.z.number().int().min(0),
3112
+ legacyFormat: import_zod2.z.number().int().min(0)
3113
+ }),
3114
+ links: import_zod2.z.object({
3115
+ internal: import_zod2.z.number().int().min(0),
3116
+ external: import_zod2.z.number().int().min(0),
3117
+ orphans: import_zod2.z.number().int().min(0),
3118
+ brokenInternal: import_zod2.z.number().int().min(0),
3119
+ externalDomains: import_zod2.z.number().int().min(0)
3120
+ })
3121
+ };
2778
3122
  var MapsPlaceIntelOutputSchema = {
2779
3123
  name: import_zod2.z.string(),
2780
3124
  rating: NullableString,
@@ -3616,12 +3960,19 @@ function registerPaaExtractorMcpTools(server2, executor2, options = {}) {
3616
3960
  annotations: liveWebToolAnnotations("Site URL Map")
3617
3961
  }, async (input) => formatMapSiteUrls(await executor2.mapSiteUrls(input), input));
3618
3962
  server2.registerTool("extract_site", {
3619
- title: "Multi-Page Site Extract",
3620
- 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."),
3963
+ title: "Multi-Page Site Content Crawl",
3964
+ 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."),
3621
3965
  inputSchema: ExtractSiteInputSchema,
3622
3966
  outputSchema: ExtractSiteOutputSchema,
3623
- annotations: liveWebToolAnnotations("Multi-Page Site Extract")
3967
+ annotations: liveWebToolAnnotations("Multi-Page Site Content Crawl")
3624
3968
  }, async (input) => formatExtractSite(await executor2.extractSite(input), input));
3969
+ server2.registerTool("audit_site", {
3970
+ title: "Technical SEO Audit",
3971
+ 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."),
3972
+ inputSchema: AuditSiteInputSchema,
3973
+ outputSchema: AuditSiteOutputSchema,
3974
+ annotations: liveWebToolAnnotations("Technical SEO Audit")
3975
+ }, async (input) => formatAuditSite(await executor2.auditSite(input), input));
3625
3976
  server2.registerTool("youtube_harvest", {
3626
3977
  title: "YouTube Video Harvest",
3627
3978
  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.'),