mcp-scraper 0.3.13 → 0.3.15

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 (51) hide show
  1. package/dist/bin/api-server.cjs +1991 -508
  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 +9 -9
  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 +428 -47
  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 +2 -2
  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 +419 -38
  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-2MX5WOII.js +7 -0
  21. package/dist/chunk-2MX5WOII.js.map +1 -0
  22. package/dist/{chunk-DBQDG7EH.js → chunk-5H22TOXQ.js} +28 -1
  23. package/dist/chunk-5H22TOXQ.js.map +1 -0
  24. package/dist/{chunk-KPXMPAJ3.js → chunk-7Y6JDBML.js} +2 -2
  25. package/dist/chunk-7Y6JDBML.js.map +1 -0
  26. package/dist/{chunk-QAOIH5U2.js → chunk-WJ2LWHPA.js} +10 -10
  27. package/dist/chunk-WJ2LWHPA.js.map +1 -0
  28. package/dist/{chunk-YGV2XZDT.js → chunk-WQAIOY2D.js} +424 -39
  29. package/dist/chunk-WQAIOY2D.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-LX65PB7D.js → server-UOQ2JIUQ.js} +1106 -93
  33. package/dist/server-UOQ2JIUQ.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-craft-lint.generated.md +1 -1
  37. package/docs/mcp-tool-design-guide.md +225 -0
  38. package/docs/mcp-tool-manifest.generated.json +13 -13
  39. package/docs/seo-crawl-report-spec.md +287 -0
  40. package/docs/tool-catalog-spec.md +388 -0
  41. package/package.json +2 -1
  42. package/dist/chunk-BEZJ4G32.js +0 -7
  43. package/dist/chunk-BEZJ4G32.js.map +0 -1
  44. package/dist/chunk-DBQDG7EH.js.map +0 -1
  45. package/dist/chunk-KPXMPAJ3.js.map +0 -1
  46. package/dist/chunk-QAOIH5U2.js.map +0 -1
  47. package/dist/chunk-YGV2XZDT.js.map +0 -1
  48. package/dist/server-LX65PB7D.js.map +0 -1
  49. /package/dist/{chunk-AZ5PKU4F.js.map → chunk-ZAUMSBV3.js.map} +0 -0
  50. /package/dist/{db-BE4JVB3V.js.map → db-P76GVIFN.js.map} +0 -0
  51. /package/dist/{worker-FG7ZWEGA.js.map → worker-2WVKKCC7.js.map} +0 -0
@@ -11,7 +11,7 @@ import {
11
11
  } from "./chunk-LFATOGDF.js";
12
12
  import {
13
13
  PACKAGE_VERSION
14
- } from "./chunk-BEZJ4G32.js";
14
+ } from "./chunk-2MX5WOII.js";
15
15
 
16
16
  // src/harvest-timeout.ts
17
17
  var VERCEL_FUNCTION_MAX_MS = 3e5;
@@ -27,6 +27,33 @@ function harvestTimeoutBudget(maxQuestions, serpOnly = false) {
27
27
  return { serverMs, clientMs };
28
28
  }
29
29
 
30
+ // src/mcp/server-instructions.ts
31
+ var SERVER_INSTRUCTIONS = `
32
+ This server scrapes and analyzes web, social, search, maps, and site data. Pick a tool by NAME using
33
+ this routing map, then load its schema before calling.
34
+
35
+ ROUTING
36
+ - One web page -> extract_url. Whole site (crawl + SEO report) -> extract_site. Just the URL list -> map_site_urls.
37
+ - Web search (organic SERP) -> search_serp. Full SERP + People-Also-Ask / AI-Overview detail -> harvest_paa.
38
+ - Google Maps: find places -> maps_search; one place deep-dive + reviews -> maps_place_intel.
39
+ - YouTube: find or list videos -> youtube_harvest; transcribe one video -> youtube_transcribe.
40
+ - Facebook: find ads -> facebook_ad_search; transcribe an ad -> facebook_ad_transcribe; transcribe a
41
+ video -> facebook_video_transcribe; page profile/intel -> facebook_page_intel.
42
+ - Instagram: profile inventory -> instagram_profile_content; one post or reel -> instagram_media_download.
43
+ - Workflows (multi-step): local directory build -> directory_workflow; rank blueprint -> rank_tracker_workflow;
44
+ AI-answer citation fan-out (AEO) -> query_fanout_workflow; deep research -> deep_research_workflow.
45
+ - Anything without a dedicated tool (e.g. Reddit, arbitrary logged-in sites) -> the browser_* agent
46
+ (browser_open, then navigate/read); save/reuse logins via browser_profile_*.
47
+
48
+ NOTES
49
+ - Bulk / full-site crawls: call extract_site with rotateProxies:true for blocked or rate-limited sites.
50
+ It discovers URLs and returns a saved folder/artifact plus a summary, not the full content inline.
51
+ - Tools that open a browser session or transcribe media cost credits and take longer. Prefer the
52
+ cheapest tool that answers the question (plain search/extract before browser agents).
53
+ - Large results are saved to disk or an artifact and returned as a summary plus a path; read the path
54
+ for full detail rather than expecting the whole payload inline.
55
+ `.trim();
56
+
30
57
  // src/mcp/paa-mcp-server.ts
31
58
  import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
32
59
  import { readdirSync, readFileSync, statSync } from "fs";
@@ -152,6 +179,191 @@ function suggestWorkflowRecipes(goal, limit = 3) {
152
179
  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);
153
180
  }
154
181
 
182
+ // src/api/seo-link-graph.ts
183
+ function normalize2(u) {
184
+ return u.split("#")[0].replace(/\/$/, "");
185
+ }
186
+ function buildLinkGraph(pages, startUrl) {
187
+ const statusByUrl = /* @__PURE__ */ new Map();
188
+ for (const p of pages) statusByUrl.set(normalize2(p.url), p.status);
189
+ const edges = [];
190
+ for (const p of pages) {
191
+ for (const l of p.outlinks ?? []) {
192
+ const nofollow = (l.rel ?? "").split(/\s+/).includes("nofollow");
193
+ edges.push({
194
+ from: p.url,
195
+ to: l.href,
196
+ anchor: l.anchor,
197
+ rel: l.rel,
198
+ internal: l.internal,
199
+ nofollow,
200
+ targetStatus: statusByUrl.get(normalize2(l.href)) ?? null
201
+ });
202
+ }
203
+ }
204
+ const inboundFrom = /* @__PURE__ */ new Map();
205
+ const inboundAnchors = /* @__PURE__ */ new Map();
206
+ const adjacency = /* @__PURE__ */ new Map();
207
+ for (const e of edges) {
208
+ if (!e.internal) continue;
209
+ const to = normalize2(e.to);
210
+ const from = normalize2(e.from);
211
+ if (!inboundFrom.has(to)) inboundFrom.set(to, /* @__PURE__ */ new Set());
212
+ inboundFrom.get(to).add(from);
213
+ if (e.anchor) {
214
+ if (!inboundAnchors.has(to)) inboundAnchors.set(to, []);
215
+ inboundAnchors.get(to).push(e.anchor);
216
+ }
217
+ if (!e.nofollow && (statusByUrl.get(to) === 200 || !statusByUrl.has(to))) {
218
+ if (!adjacency.has(from)) adjacency.set(from, /* @__PURE__ */ new Set());
219
+ adjacency.get(from).add(to);
220
+ }
221
+ }
222
+ const depth = /* @__PURE__ */ new Map();
223
+ const start = normalize2(startUrl);
224
+ const queue = [start];
225
+ depth.set(start, 0);
226
+ while (queue.length > 0) {
227
+ const cur = queue.shift();
228
+ const d = depth.get(cur);
229
+ for (const next of adjacency.get(cur) ?? []) {
230
+ if (!depth.has(next)) {
231
+ depth.set(next, d + 1);
232
+ queue.push(next);
233
+ }
234
+ }
235
+ }
236
+ const topAnchorsFor = (url) => {
237
+ const counts = /* @__PURE__ */ new Map();
238
+ for (const a of inboundAnchors.get(url) ?? []) counts.set(a, (counts.get(a) ?? 0) + 1);
239
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map((e) => e[0]);
240
+ };
241
+ const metrics = /* @__PURE__ */ new Map();
242
+ for (const p of pages) {
243
+ const key = normalize2(p.url);
244
+ const inbound = inboundFrom.get(key) ?? /* @__PURE__ */ new Set();
245
+ metrics.set(p.url, {
246
+ url: p.url,
247
+ inlinks: inbound.size,
248
+ uniqueInlinks: inbound.size,
249
+ outlinksInternal: (p.outlinks ?? []).filter((l) => l.internal).length,
250
+ outlinksExternal: (p.outlinks ?? []).filter((l) => !l.internal).length,
251
+ crawlDepth: depth.has(key) ? depth.get(key) : null,
252
+ orphan: inbound.size === 0 && key !== start,
253
+ topAnchors: topAnchorsFor(key)
254
+ });
255
+ }
256
+ return { edges, metrics };
257
+ }
258
+
259
+ // src/api/seo-issues.ts
260
+ var THIN_WORDS = 200;
261
+ var TITLE_MAX = 60;
262
+ var TITLE_PX_MAX = 561;
263
+ var TITLE_MIN = 30;
264
+ var META_MAX = 155;
265
+ var META_MIN = 70;
266
+ var H1_MAX = 70;
267
+ var URL_MAX = 115;
268
+ function dupes(pages, key) {
269
+ const groups = /* @__PURE__ */ new Map();
270
+ for (const p of pages) {
271
+ const k = key(p);
272
+ if (k === null || k === void 0 || k === "") continue;
273
+ const ks = String(k);
274
+ if (!groups.has(ks)) groups.set(ks, []);
275
+ groups.get(ks).push(p.url);
276
+ }
277
+ return [...groups.values()].filter((g) => g.length > 1).flat();
278
+ }
279
+ function computeIssues(pages, metrics) {
280
+ const group = (urls) => ({ count: urls.length, urls: urls.slice(0, 200) });
281
+ const where = (fn) => pages.filter(fn).map((p) => p.url);
282
+ const ok = (p) => p.status === 200;
283
+ const pathname = (u) => {
284
+ try {
285
+ return new URL(u).pathname;
286
+ } catch {
287
+ return "";
288
+ }
289
+ };
290
+ const report = {
291
+ "title.missing": group(where((p) => ok(p) && !p.title)),
292
+ "title.duplicate": group(dupes(pages.filter(ok), (p) => p.title)),
293
+ "title.tooLong": group(where((p) => (p.titleLength ?? 0) > TITLE_MAX || (p.titlePixels ?? 0) > TITLE_PX_MAX)),
294
+ "title.tooShort": group(where((p) => p.title != null && (p.titleLength ?? 0) < TITLE_MIN)),
295
+ "title.sameAsH1": group(where((p) => !!p.title && !!p.h1 && p.title.trim() === p.h1.trim())),
296
+ "meta.missing": group(where((p) => ok(p) && !p.metaDescription)),
297
+ "meta.duplicate": group(dupes(pages.filter(ok), (p) => p.metaDescription)),
298
+ "meta.tooLong": group(where((p) => (p.metaDescLength ?? 0) > META_MAX)),
299
+ "meta.tooShort": group(where((p) => p.metaDescription != null && (p.metaDescLength ?? 0) < META_MIN)),
300
+ "h1.missing": group(where((p) => ok(p) && !p.h1)),
301
+ "h1.multiple": group(where((p) => !!p.h1_2)),
302
+ "h1.duplicate": group(dupes(pages.filter(ok), (p) => p.h1)),
303
+ "h1.tooLong": group(where((p) => (p.h1?.length ?? 0) > H1_MAX)),
304
+ "h2.missing": group(where((p) => ok(p) && p.h2Count === 0)),
305
+ "indexability.nonIndexable": group(where((p) => !p.indexable)),
306
+ "indexability.noindex": group(where((p) => p.indexabilityReason === "noindex")),
307
+ "canonical.missing": group(where((p) => ok(p) && !p.canonicalUrl)),
308
+ "canonical.canonicalised": group(where((p) => p.indexabilityReason === "canonicalised")),
309
+ "response.broken4xx": group(where((p) => (p.status ?? 0) >= 400 && (p.status ?? 0) < 500)),
310
+ "response.error5xx": group(where((p) => (p.status ?? 0) >= 500)),
311
+ "response.redirect3xx": group(where((p) => (p.status ?? 0) >= 300 && (p.status ?? 0) < 400)),
312
+ "response.noResponse": group(where((p) => p.status === null)),
313
+ "content.thin": group(where((p) => ok(p) && p.wordCount > 0 && p.wordCount < THIN_WORDS)),
314
+ "content.exactDuplicate": group(dupes(pages.filter((p) => ok(p) && p.contentHash), (p) => p.contentHash)),
315
+ "images.missingAlt": group(where((p) => p.imagesMissingAlt > 0)),
316
+ "schema.missing": group(where((p) => ok(p) && p.schemaTypes.length === 0)),
317
+ "url.tooLong": group(where((p) => p.url.length > URL_MAX)),
318
+ "url.uppercase": group(where((p) => /[A-Z]/.test(pathname(p.url)))),
319
+ "url.underscores": group(where((p) => pathname(p.url).includes("_"))),
320
+ "links.orphan": group([...metrics.values()].filter((m) => m.orphan).map((m) => m.url))
321
+ };
322
+ const normUrl = (u) => {
323
+ try {
324
+ const x = new URL(u);
325
+ x.hash = "";
326
+ return (x.origin + x.pathname).replace(/\/+$/, "") + x.search;
327
+ } catch {
328
+ return u.replace(/#.*$/, "").replace(/\/+$/, "");
329
+ }
330
+ };
331
+ const statusByUrl = new Map(pages.map((p) => [normUrl(p.url), p.status]));
332
+ const brokenLinkPages = /* @__PURE__ */ new Set();
333
+ for (const p of pages) {
334
+ for (const l of p.outlinks ?? []) {
335
+ if (!l.internal) continue;
336
+ const st = statusByUrl.get(normUrl(l.href));
337
+ if (st != null && st >= 400) brokenLinkPages.add(p.url);
338
+ }
339
+ }
340
+ report["links.brokenInternal"] = group([...brokenLinkPages]);
341
+ return report;
342
+ }
343
+ function renderIssueReport(siteUrl, pages, report, metrics) {
344
+ const total = pages.length;
345
+ 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}";
346
+ 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" : ""} |`);
347
+ const depths = [...metrics.values()].map((m) => m.crawlDepth).filter((d) => d != null);
348
+ const maxDepth = depths.length ? Math.max(...depths) : 0;
349
+ const orphans = [...metrics.values()].filter((m) => m.orphan).length;
350
+ const topLinked = [...metrics.values()].sort((a, b) => b.inlinks - a.inlinks).slice(0, 5);
351
+ return [
352
+ `# SEO Crawl Report: ${siteUrl}`,
353
+ `**${total} pages crawled** \xB7 max crawl depth ${maxDepth} \xB7 ${orphans} orphan page(s)`,
354
+ `
355
+ ## Issues
356
+ | | Issue | Count | Examples |
357
+ |---|-------|-------|----------|
358
+ ${rows.join("\n") || "| \u2705 | none | 0 | \u2014 |"}`,
359
+ `
360
+ ## Most-linked pages
361
+ ${topLinked.map((m) => `- ${m.inlinks} inlinks \xB7 depth ${m.crawlDepth ?? "\u2014"} \xB7 ${m.url}`).join("\n")}`,
362
+ `
363
+ _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._`
364
+ ].join("\n");
365
+ }
366
+
155
367
  // src/mcp/mcp-response-formatter.ts
156
368
  var reportSavingEnabled = true;
157
369
  function configureReportSaving(enabled) {
@@ -185,6 +397,85 @@ function saveFullReport(full) {
185
397
  return null;
186
398
  }
187
399
  }
400
+ var BULK_PAGE_THRESHOLD = 25;
401
+ var BULK_URL_THRESHOLD = 500;
402
+ function reportSavingActive() {
403
+ return reportSavingEnabled && process.env.MCP_SCRAPER_SAVE_REPORTS !== "false";
404
+ }
405
+ function saveBulkSite(siteUrl, pages, seo) {
406
+ if (!reportSavingActive()) return null;
407
+ try {
408
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
409
+ const dir = join(outputBaseDir(), `extract-${slugifyReportName(siteUrl)}-${stamp}`);
410
+ const pagesDir = join(dir, "pages");
411
+ mkdirSync(pagesDir, { recursive: true });
412
+ const indexRows = pages.map((p, i) => {
413
+ const num = String(i + 1).padStart(4, "0");
414
+ const slug = slugifyReportName(p.url.replace(/^https?:\/\//, "")).slice(0, 60) || "page";
415
+ const fname = `${num}-${slug}.md`;
416
+ const body = (p.bodyMarkdown ?? "").trim();
417
+ const content = [
418
+ `# ${p.title ?? "Untitled"}`,
419
+ `- **URL:** ${p.url}`,
420
+ p.metaDescription ? `- **Description:** ${p.metaDescription}` : "",
421
+ p.schemaTypes?.length ? `- **Schema:** ${p.schemaTypes.join(", ")}` : "",
422
+ "",
423
+ body || "_(no content extracted)_"
424
+ ].filter(Boolean).join("\n");
425
+ writeFileSync(join(pagesDir, fname), content, "utf8");
426
+ return `| ${i + 1} | ${cell(p.title ?? "Untitled")} | ${p.url} | pages/${fname} |`;
427
+ });
428
+ const index = [
429
+ `# Site Extract: ${siteUrl}`,
430
+ `**${pages.length} pages** \u2014 each saved as an individual Markdown file under \`pages/\`.`,
431
+ `
432
+ ## Pages
433
+ | # | Title | URL | File |
434
+ |---|-------|-----|------|
435
+ ${indexRows.join("\n")}`
436
+ ].join("\n");
437
+ const indexFile = join(dir, "index.md");
438
+ writeFileSync(indexFile, index, "utf8");
439
+ let seoFiles;
440
+ if (seo) {
441
+ const toJsonl = (rows) => rows.map((r) => JSON.stringify(r)).join("\n");
442
+ const pagesJsonl = join(dir, "pages.jsonl");
443
+ const linksJsonl = join(dir, "links.jsonl");
444
+ const metricsJsonl = join(dir, "link-metrics.jsonl");
445
+ const issuesFile = join(dir, "issues.json");
446
+ const reportFile = join(dir, "report.md");
447
+ writeFileSync(pagesJsonl, toJsonl(seo.pageRows), "utf8");
448
+ writeFileSync(linksJsonl, toJsonl(seo.edges), "utf8");
449
+ writeFileSync(metricsJsonl, toJsonl(seo.metrics), "utf8");
450
+ writeFileSync(issuesFile, JSON.stringify(seo.issues, null, 2), "utf8");
451
+ writeFileSync(reportFile, seo.reportMd, "utf8");
452
+ seoFiles = [pagesJsonl, linksJsonl, metricsJsonl, issuesFile, reportFile];
453
+ if (seo.branding) {
454
+ const brandingFile = join(dir, "branding.json");
455
+ writeFileSync(brandingFile, JSON.stringify(seo.branding, null, 2), "utf8");
456
+ seoFiles.push(brandingFile);
457
+ }
458
+ }
459
+ return { dir, indexFile, fileCount: pages.length, seoFiles };
460
+ } catch {
461
+ return null;
462
+ }
463
+ }
464
+ function saveUrlInventory(siteUrl, urls) {
465
+ if (!reportSavingActive()) return null;
466
+ try {
467
+ const outDir = outputBaseDir();
468
+ mkdirSync(outDir, { recursive: true });
469
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
470
+ const file = join(outDir, `${stamp}-urlmap-${slugifyReportName(siteUrl.replace(/^https?:\/\//, ""))}.csv`);
471
+ const csv = (v) => /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
472
+ const rows = ["url,status", ...urls.map((u) => `${csv(u.url)},${u.status ?? ""}`)];
473
+ writeFileSync(file, rows.join("\n"), "utf8");
474
+ return file;
475
+ } catch {
476
+ return null;
477
+ }
478
+ }
188
479
  function persistScreenshotLocally(base64, url) {
189
480
  if (!reportSavingEnabled || process.env.MCP_SCRAPER_SAVE_REPORTS === "false") return null;
190
481
  try {
@@ -199,8 +490,8 @@ function persistScreenshotLocally(base64, url) {
199
490
  return null;
200
491
  }
201
492
  }
202
- function oneBlock(content) {
203
- const filePath = saveFullReport(content);
493
+ function oneBlock(content, diskContent) {
494
+ const filePath = saveFullReport(diskContent ?? content);
204
495
  const text = filePath ? `${content}
205
496
 
206
497
  \u{1F4C4} Saved: \`${filePath}\`` : content;
@@ -469,7 +760,12 @@ ${[h1Lines, h2Lines].filter(Boolean).join("\n")}` : "";
469
760
  ].filter(Boolean).join("\n") : "";
470
761
  const bodySection = bodyMd ? `
471
762
  ## Page Content
472
- ${bodyMd.slice(0, 3e3)}${bodyMd.length > 3e3 ? "\n\n*(truncated)*" : ""}` : "";
763
+ ${bodyMd.slice(0, 3e3)}${bodyMd.length > 3e3 ? `
764
+
765
+ *(truncated to 3,000 of ${bodyMd.length.toLocaleString()} chars \u2014 full content in the saved report)*` : ""}` : "";
766
+ const bodySectionFull = bodyMd ? `
767
+ ## Page Content
768
+ ${bodyMd}` : "";
473
769
  const screenshotSection = screenshotMeta ? `
474
770
  ## Screenshot
475
771
  - **File:** ${screenshotPath ?? "(returned inline only \u2014 disk write unavailable in this environment)"}
@@ -500,7 +796,10 @@ ${bodyMd.slice(0, 3e3)}${bodyMd.length > 3e3 ? "\n\n*(truncated)*" : ""}` : "";
500
796
  const full = `# URL Extract: ${url}
501
797
  **${title}**
502
798
  ${headingSection}${kpoSection}${brandingSection}${bodySection}${screenshotSection}${mediaSection}${tips}`;
503
- const textResult = oneBlock(full);
799
+ const diskReport = `# URL Extract: ${url}
800
+ **${title}**
801
+ ${headingSection}${kpoSection}${brandingSection}${bodySectionFull}${screenshotSection}${mediaSection}${tips}`;
802
+ const textResult = oneBlock(full, diskReport);
504
803
  const structuredContent = {
505
804
  url,
506
805
  title: d.title ?? null,
@@ -533,7 +832,16 @@ function formatMapSiteUrls(raw, input) {
533
832
  const ok = urls.filter((u) => (u.status ?? 0) >= 200 && (u.status ?? 0) < 300);
534
833
  const broken = urls.filter((u) => u.status !== null && u.status >= 400);
535
834
  const redirects = urls.filter((u) => u.status !== null && u.status >= 300 && u.status < 400);
536
- const urlRows = urls.slice(0, 200).map((u, i) => `| ${i + 1} | ${u.url} | ${u.status ?? "\u2014"} |`).join("\n");
835
+ const isBulk = urls.length > BULK_URL_THRESHOLD;
836
+ const inventoryFile = isBulk ? saveUrlInventory(input.url, urls.map((u) => ({ url: u.url, status: u.status ?? null }))) : null;
837
+ const inlineCount = isBulk ? BULK_URL_THRESHOLD : 200;
838
+ const urlRows = urls.slice(0, inlineCount).map((u, i) => `| ${i + 1} | ${u.url} | ${u.status ?? "\u2014"} |`).join("\n");
839
+ const inventoryNote = isBulk ? inventoryFile ? `
840
+ ## \u{1F4C1} Full inventory saved
841
+ - **File:** \`${inventoryFile}\` (CSV: url, status \u2014 all ${urls.length} URLs)
842
+ Showing the first ${inlineCount} below; read the file for the complete list.` : `
843
+ ## \u26A0\uFE0F Full inventory not saved
844
+ 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.` : "";
537
845
  const full = [
538
846
  `# URL Map: ${input.url}`,
539
847
  `**${d.totalFound} URLs** \xB7 ${(d.durationMs / 1e3).toFixed(1)}s${d.truncated ? " \xB7 *truncated*" : ""}`,
@@ -542,12 +850,13 @@ function formatMapSiteUrls(raw, input) {
542
850
  - \u2705 2xx: ${ok.length}
543
851
  - \u{1F500} 3xx: ${redirects.length}
544
852
  - \u274C 4xx+: ${broken.length}`,
853
+ inventoryNote,
545
854
  `
546
- ## URL Inventory
855
+ ## URL Inventory${isBulk ? ` (first ${inlineCount} of ${urls.length})` : ""}
547
856
  | # | URL | Status |
548
857
  |---|-----|--------|
549
858
  ${urlRows}`,
550
- broken.length ? `
859
+ !isBulk && broken.length ? `
551
860
  ## Broken URLs
552
861
  ${broken.map((u) => `- ${u.url} (${u.status})`).join("\n")}` : "",
553
862
  `
@@ -565,47 +874,116 @@ ${broken.map((u) => `- ${u.url} (${u.status})`).join("\n")}` : "",
565
874
  okCount: ok.length,
566
875
  redirectCount: redirects.length,
567
876
  brokenCount: broken.length,
877
+ inventoryFile: inventoryFile ?? null,
568
878
  urls: urls.map((u) => ({ url: u.url, status: u.status ?? null })),
569
879
  durationMs: d.durationMs ?? 0
570
880
  }
571
881
  };
572
882
  }
883
+ function buildSeoExport(siteUrl, pages, branding) {
884
+ const { edges, metrics } = buildLinkGraph(pages, siteUrl);
885
+ const pageRows = pages.map((p) => {
886
+ const { bodyMarkdown: _b, schema: _s, outlinks: _o, ...rest } = p;
887
+ const m = metrics.get(p.url);
888
+ return { ...rest, inlinks: m?.inlinks ?? 0, crawlDepth: m?.crawlDepth ?? null, orphan: m?.orphan ?? false };
889
+ });
890
+ const issues = computeIssues(pages, metrics);
891
+ return {
892
+ pageRows,
893
+ edges,
894
+ metrics: [...metrics.values()],
895
+ issues,
896
+ reportMd: renderIssueReport(siteUrl, pages, issues, metrics),
897
+ branding: branding ?? void 0
898
+ };
899
+ }
573
900
  function formatExtractSite(raw, input) {
574
901
  const parsed = parseData(raw);
575
902
  if ("error" in parsed) return { content: [{ type: "text", text: parsed.error }], isError: true };
576
903
  const d = parsed.data;
577
904
  const pages = d.pages ?? [];
578
- const pageRows = pages.map((p, i) => {
579
- const schemaInfo = p.kpo?.type?.join(", ") ?? (Array.isArray(p.schema) && p.schema.length ? `${p.schema.length} block(s)` : "\u2014");
905
+ const schemaTypesOf = (p) => p.kpo?.type ?? (Array.isArray(p.schema) && p.schema.length ? [`${p.schema.length} block(s)`] : []);
906
+ const pageRow = (p, i) => {
907
+ const schemaInfo = schemaTypesOf(p).join(", ") || "\u2014";
580
908
  return `| ${i + 1} | ${cell(p.title ?? "Untitled")} | ${p.url} | ${schemaInfo} |`;
581
- }).join("\n");
582
- const full = [
909
+ };
910
+ const tips = `
911
+ ---
912
+ \u{1F4A1} **Tips**
913
+ - Map URLs first: use \`map_site_urls\`
914
+ - Inspect a single page: use \`extract_url\``;
915
+ const durationLine = `**${pages.length} pages** \xB7 ${((d.durationMs ?? 0) / 1e3).toFixed(1)}s`;
916
+ const structuredContent = {
917
+ url: input.url,
918
+ pageCount: pages.length,
919
+ pages: pages.map((p) => ({
920
+ url: String(p.url ?? ""),
921
+ title: p.title ?? null,
922
+ schemaTypes: schemaTypesOf(p)
923
+ })),
924
+ durationMs: d.durationMs ?? 0
925
+ };
926
+ if (pages.length > BULK_PAGE_THRESHOLD) {
927
+ const seo = buildSeoExport(input.url, pages, d.branding);
928
+ const bulk = saveBulkSite(input.url, pages.map((p) => ({
929
+ url: String(p.url ?? ""),
930
+ title: p.title ?? null,
931
+ bodyMarkdown: p.bodyMarkdown ?? null,
932
+ metaDescription: p.metaDescription ?? null,
933
+ schemaTypes: schemaTypesOf(p)
934
+ })), seo);
935
+ const preview = pages.slice(0, BULK_PAGE_THRESHOLD).map(pageRow).join("\n");
936
+ const seoLine = bulk?.seoFiles?.length ? `
937
+ - **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)` : "";
938
+ const location = bulk ? `
939
+ ## \u{1F4C1} Bulk scrape saved
940
+ - **Folder:** \`${bulk.dir}\`
941
+ - **Index:** \`${bulk.indexFile}\`
942
+ - **Files:** ${bulk.fileCount} page files under \`pages/\`${seoLine}
943
+
944
+ 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.` : `
945
+ ## \u26A0\uFE0F Bulk scrape not saved
946
+ 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.`;
947
+ const full2 = [
948
+ `# Site Extract: ${input.url}`,
949
+ durationLine,
950
+ location,
951
+ `
952
+ ## Pages (first ${Math.min(BULK_PAGE_THRESHOLD, pages.length)} of ${pages.length})
953
+ | # | Title | URL | Schema |
954
+ |---|-------|-----|--------|
955
+ ${preview}`,
956
+ tips
957
+ ].join("\n");
958
+ return { content: [{ type: "text", text: full2 }], structuredContent: { ...structuredContent, bulkFolder: bulk?.dir ?? null } };
959
+ }
960
+ const header = [
583
961
  `# Site Extract: ${input.url}`,
584
- `**${pages.length} pages** \xB7 ${((d.durationMs ?? 0) / 1e3).toFixed(1)}s`,
962
+ durationLine,
585
963
  `
586
964
  ## Pages
587
965
  | # | Title | URL | Schema |
588
966
  |---|-------|-----|--------|
589
- ${pageRows}`,
590
- `
591
- ---
592
- \u{1F4A1} **Tips**
593
- - Map URLs first: use \`map_site_urls\`
594
- - Inspect a single page: use \`extract_url\``
967
+ ${pages.map(pageRow).join("\n")}`
595
968
  ].join("\n");
596
- return {
597
- ...oneBlock(full),
598
- structuredContent: {
599
- url: input.url,
600
- pageCount: pages.length,
601
- pages: pages.map((p) => ({
602
- url: String(p.url ?? ""),
603
- title: p.title ?? null,
604
- schemaTypes: p.kpo?.type ?? []
605
- })),
606
- durationMs: d.durationMs ?? 0
607
- }
608
- };
969
+ const full = `${header}${tips}`;
970
+ const pageDetails = pages.map((p, i) => {
971
+ const body = (p.bodyMarkdown ?? "").trim();
972
+ return [
973
+ `
974
+ ## ${i + 1}. ${p.title ?? "Untitled"}`,
975
+ `- **URL:** ${p.url}`,
976
+ p.metaDescription ? `- **Description:** ${p.metaDescription}` : "",
977
+ body ? `
978
+ ${body}` : "_(no content extracted)_"
979
+ ].filter(Boolean).join("\n");
980
+ }).join("\n");
981
+ const diskReport = `${header}
982
+
983
+ ---
984
+ # Full Page Content
985
+ ${pageDetails}${tips}`;
986
+ return { ...oneBlock(full, diskReport), structuredContent };
609
987
  }
610
988
  function formatYoutubeHarvest(raw, input) {
611
989
  const parsed = parseData(raw);
@@ -1806,11 +2184,14 @@ var ExtractUrlInputSchema = {
1806
2184
  };
1807
2185
  var MapSiteUrlsInputSchema = {
1808
2186
  url: 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."),
1809
- maxUrls: 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.")
2187
+ maxUrls: 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.")
1810
2188
  };
1811
2189
  var ExtractSiteInputSchema = {
1812
2190
  url: 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."),
1813
- maxPages: 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.")
2191
+ maxPages: 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."),
2192
+ rotateProxies: 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."),
2193
+ rotateProxyEvery: 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."),
2194
+ formats: z.array(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.")
1814
2195
  };
1815
2196
  var YoutubeHarvestInputSchema = {
1816
2197
  mode: z.enum(["search", "channel"]).describe("Use search for topic/keyword requests. Use channel when the user provides @handle, channel ID, or channel URL."),
@@ -2915,7 +3296,7 @@ function registerSavedReportResources(server) {
2915
3296
  );
2916
3297
  }
2917
3298
  function buildPaaExtractorMcpServer(executor, options = {}) {
2918
- const server = new McpServer({ name: "mcp-scraper", version: PACKAGE_VERSION });
3299
+ const server = new McpServer({ name: "mcp-scraper", version: PACKAGE_VERSION }, { instructions: SERVER_INSTRUCTIONS });
2919
3300
  registerPaaExtractorMcpTools(server, executor, options);
2920
3301
  return server;
2921
3302
  }
@@ -2947,14 +3328,14 @@ function registerPaaExtractorMcpTools(server, executor, options = {}) {
2947
3328
  }, async (input) => formatExtractUrl(await executor.extractUrl(input), input));
2948
3329
  server.registerTool("map_site_urls", {
2949
3330
  title: "Site URL Map",
2950
- 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."),
3331
+ 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."),
2951
3332
  inputSchema: MapSiteUrlsInputSchema,
2952
3333
  outputSchema: MapSiteUrlsOutputSchema,
2953
3334
  annotations: liveWebToolAnnotations("Site URL Map")
2954
3335
  }, async (input) => formatMapSiteUrls(await executor.mapSiteUrls(input), input));
2955
3336
  server.registerTool("extract_site", {
2956
3337
  title: "Multi-Page Site Extract",
2957
- 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."),
3338
+ 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."),
2958
3339
  inputSchema: ExtractSiteInputSchema,
2959
3340
  outputSchema: ExtractSiteOutputSchema,
2960
3341
  annotations: liveWebToolAnnotations("Multi-Page Site Extract")
@@ -3078,7 +3459,7 @@ function registerPaaExtractorMcpTools(server, executor, options = {}) {
3078
3459
  outputSchema: WorkflowArtifactReadOutputSchema,
3079
3460
  annotations: liveWebToolAnnotations("Read Workflow Artifact")
3080
3461
  }, async (input) => formatWorkflowArtifactRead(await executor.workflowArtifactRead(input), input));
3081
- server.registerTool("rank_tracker_blueprint", {
3462
+ server.registerTool("rank_tracker_workflow", {
3082
3463
  title: "Rank Tracker Blueprint Builder",
3083
3464
  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.",
3084
3465
  inputSchema: RankTrackerBlueprintInputSchema,
@@ -3362,10 +3743,14 @@ var HttpMcpToolExecutor = class {
3362
3743
 
3363
3744
  export {
3364
3745
  harvestTimeoutBudget,
3746
+ buildLinkGraph,
3747
+ computeIssues,
3748
+ renderIssueReport,
3365
3749
  configureReportSaving,
3366
3750
  outputBaseDir,
3367
3751
  formatCaptureSerpSnapshot,
3368
3752
  formatCaptureSerpPageSnapshots,
3753
+ SERVER_INSTRUCTIONS,
3369
3754
  CaptureSerpSnapshotOutputSchema,
3370
3755
  CaptureSerpPageSnapshotsOutputSchema,
3371
3756
  CaptureSerpSnapshotInputSchema,
@@ -3375,4 +3760,4 @@ export {
3375
3760
  registerPaaExtractorMcpTools,
3376
3761
  HttpMcpToolExecutor
3377
3762
  };
3378
- //# sourceMappingURL=chunk-YGV2XZDT.js.map
3763
+ //# sourceMappingURL=chunk-WQAIOY2D.js.map