mcp-scraper 0.3.16 → 0.3.18

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 (62) hide show
  1. package/README.md +26 -58
  2. package/dist/bin/api-server.cjs +418 -53
  3. package/dist/bin/api-server.cjs.map +1 -1
  4. package/dist/bin/api-server.js +2 -2
  5. package/dist/bin/mcp-scraper-cli.cjs +4 -4
  6. package/dist/bin/mcp-scraper-cli.cjs.map +1 -1
  7. package/dist/bin/mcp-scraper-cli.js +5 -5
  8. package/dist/bin/mcp-scraper-cli.js.map +1 -1
  9. package/dist/bin/mcp-scraper-install.cjs +12 -12
  10. package/dist/bin/mcp-scraper-install.cjs.map +1 -1
  11. package/dist/bin/mcp-scraper-install.js +3 -3
  12. package/dist/bin/mcp-scraper-install.js.map +1 -1
  13. package/dist/bin/mcp-stdio-server.cjs +2554 -781
  14. package/dist/bin/mcp-stdio-server.cjs.map +1 -1
  15. package/dist/bin/mcp-stdio-server.js +1377 -9
  16. package/dist/bin/mcp-stdio-server.js.map +1 -1
  17. package/dist/bin/paa-harvest.js +2 -3
  18. package/dist/bin/paa-harvest.js.map +1 -1
  19. package/dist/{chunk-7XARJHS6.js → chunk-2XCNU22L.js} +332 -13
  20. package/dist/chunk-2XCNU22L.js.map +1 -0
  21. package/dist/chunk-C7ZNIXOA.js +7 -0
  22. package/dist/chunk-C7ZNIXOA.js.map +1 -0
  23. package/dist/{chunk-ACIUCZ27.js → chunk-DE7WP4UU.js} +11 -11
  24. package/dist/chunk-DE7WP4UU.js.map +1 -0
  25. package/dist/{chunk-ZAUMSBV3.js → chunk-HDPWLYK7.js} +6 -6
  26. package/dist/{chunk-4OPKIDON.js → chunk-IGOEAKC7.js} +4 -6
  27. package/dist/{chunk-4OPKIDON.js.map → chunk-IGOEAKC7.js.map} +1 -1
  28. package/dist/{chunk-M2S27J6Z.js → chunk-ZAP7MYVO.js} +26 -1
  29. package/dist/chunk-ZAP7MYVO.js.map +1 -0
  30. package/dist/index.js +2 -3
  31. package/dist/index.js.map +1 -1
  32. package/dist/{server-LPVBSE2E.js → server-3M3XCVOT.js} +328 -157
  33. package/dist/server-3M3XCVOT.js.map +1 -0
  34. package/dist/{worker-2WVKKCC7.js → worker-J6I5XP2F.js} +9 -10
  35. package/dist/{worker-2WVKKCC7.js.map → worker-J6I5XP2F.js.map} +1 -1
  36. package/docs/mcp-tool-manifest.generated.json +28 -27
  37. package/package.json +3 -3
  38. package/dist/bin/browser-agent-stdio-server.cjs +0 -1523
  39. package/dist/bin/browser-agent-stdio-server.cjs.map +0 -1
  40. package/dist/bin/browser-agent-stdio-server.d.cts +0 -1
  41. package/dist/bin/browser-agent-stdio-server.d.ts +0 -1
  42. package/dist/bin/browser-agent-stdio-server.js +0 -43
  43. package/dist/bin/browser-agent-stdio-server.js.map +0 -1
  44. package/dist/bin/mcp-scraper-combined-stdio-server.cjs +0 -5601
  45. package/dist/bin/mcp-scraper-combined-stdio-server.cjs.map +0 -1
  46. package/dist/bin/mcp-scraper-combined-stdio-server.d.cts +0 -1
  47. package/dist/bin/mcp-scraper-combined-stdio-server.d.ts +0 -1
  48. package/dist/bin/mcp-scraper-combined-stdio-server.js +0 -70
  49. package/dist/bin/mcp-scraper-combined-stdio-server.js.map +0 -1
  50. package/dist/chunk-3YGKXXUG.js +0 -133
  51. package/dist/chunk-3YGKXXUG.js.map +0 -1
  52. package/dist/chunk-7XARJHS6.js.map +0 -1
  53. package/dist/chunk-ACIUCZ27.js.map +0 -1
  54. package/dist/chunk-ISZWGIWL.js +0 -1332
  55. package/dist/chunk-ISZWGIWL.js.map +0 -1
  56. package/dist/chunk-M2S27J6Z.js.map +0 -1
  57. package/dist/chunk-SOMBZK3V.js +0 -7
  58. package/dist/chunk-SOMBZK3V.js.map +0 -1
  59. package/dist/chunk-WN7PBKMV.js +0 -28
  60. package/dist/chunk-WN7PBKMV.js.map +0 -1
  61. package/dist/server-LPVBSE2E.js.map +0 -1
  62. /package/dist/{chunk-ZAUMSBV3.js.map → chunk-HDPWLYK7.js.map} +0 -0
@@ -1,17 +1,15 @@
1
1
  import {
2
- sanitizeVendorName
3
- } from "./chunk-M2S27J6Z.js";
2
+ PACKAGE_VERSION
3
+ } from "./chunk-C7ZNIXOA.js";
4
4
  import {
5
5
  browserServiceProfileName,
6
- browserServiceProfileSaveChanges
7
- } from "./chunk-WN7PBKMV.js";
6
+ browserServiceProfileSaveChanges,
7
+ sanitizeVendorName
8
+ } from "./chunk-ZAP7MYVO.js";
8
9
  import {
9
10
  DEFAULT_MAPS_PROXY_MODE,
10
11
  DEFAULT_PROXY_MODE
11
12
  } from "./chunk-LFATOGDF.js";
12
- import {
13
- PACKAGE_VERSION
14
- } from "./chunk-SOMBZK3V.js";
15
13
 
16
14
  // src/harvest-timeout.ts
17
15
  var VERCEL_FUNCTION_MAX_MS = 3e5;
@@ -27,6 +25,71 @@ function harvestTimeoutBudget(maxQuestions, serpOnly = false) {
27
25
  return { serverMs, clientMs };
28
26
  }
29
27
 
28
+ // src/api/outbound-sanitize.ts
29
+ var KEY_RENAMES = {
30
+ kernel: "browserRuntime",
31
+ kernel_session_id: "browser_session_id",
32
+ kernel_delete_started: "session_cleanup_started",
33
+ kernel_delete_succeeded: "session_cleanup_succeeded",
34
+ kernel_delete_error: "session_cleanup_error",
35
+ kernelSessionId: "browserSessionId",
36
+ kernelDeleteStarted: "sessionCleanupStarted",
37
+ kernelDeleteSucceeded: "sessionCleanupSucceeded",
38
+ kernelDeleteError: "sessionCleanupError",
39
+ kernelProxyId: "proxyId"
40
+ };
41
+ var SANITIZED_VALUE_KEYS = /error|message/i;
42
+ var VENDOR_URL_KEYS = /* @__PURE__ */ new Set([
43
+ "hosted_url",
44
+ "hostedUrl",
45
+ "live_view_url",
46
+ "liveViewUrl",
47
+ "cdp_ws_url",
48
+ "cdpWsUrl",
49
+ "browser_live_view_url"
50
+ ]);
51
+ var VENDOR_URL_RE = /\b(?:wss?|https?):\/\/[^\s"'<>]*\bonkernel\.com[^\s"'<>]*/gi;
52
+ function redactVendorUrls(value) {
53
+ return value.replace(VENDOR_URL_RE, "[browser-service]");
54
+ }
55
+ function sanitizeOutboundDiagnostics(value, parentKey = "") {
56
+ if (typeof value === "string") {
57
+ let out = redactVendorUrls(value);
58
+ if (SANITIZED_VALUE_KEYS.test(parentKey) && /kernel/i.test(out)) {
59
+ out = sanitizeVendorName(out);
60
+ }
61
+ return out;
62
+ }
63
+ if (Array.isArray(value)) return value.map((v) => sanitizeOutboundDiagnostics(v, parentKey));
64
+ if (value !== null && typeof value === "object") {
65
+ const out = {};
66
+ for (const [key, val] of Object.entries(value)) {
67
+ const renamed = KEY_RENAMES[key] ?? key;
68
+ if (VENDOR_URL_KEYS.has(key)) {
69
+ out[renamed] = null;
70
+ continue;
71
+ }
72
+ out[renamed] = sanitizeOutboundDiagnostics(val, key);
73
+ }
74
+ return out;
75
+ }
76
+ return value;
77
+ }
78
+ function sanitizeAttempts(attempts) {
79
+ return attempts.map((a) => sanitizeOutboundDiagnostics(a));
80
+ }
81
+ function sanitizeHarvestResult(result) {
82
+ const diagnostics = result?.diagnostics;
83
+ if (!diagnostics?.debug) return result;
84
+ return {
85
+ ...result,
86
+ diagnostics: {
87
+ ...diagnostics,
88
+ debug: sanitizeOutboundDiagnostics(diagnostics.debug)
89
+ }
90
+ };
91
+ }
92
+
30
93
  // src/mcp/server-instructions.ts
31
94
  var SERVER_INSTRUCTIONS = `
32
95
  This server scrapes and analyzes web, social, search, maps, and site data. Pick a tool by NAME using
@@ -43,7 +106,11 @@ ROUTING
43
106
  - Workflows (multi-step): local directory build -> directory_workflow; rank blueprint -> rank_tracker_workflow;
44
107
  AI-answer citation fan-out (AEO) -> query_fanout_workflow; deep research -> deep_research_workflow.
45
108
  - 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_*.
109
+ (browser_open, then navigate/read).
110
+ - Logged-in sites (ChatGPT, Claude, Reddit, any account): save the login first -> browser_profile_connect
111
+ (returns an mcpscraper.dev sign-in link for the user; one profile holds MANY logins, call it again with
112
+ the same profile + a new domain to add accounts) -> poll browser_profile_list until AUTHENTICATED ->
113
+ browser_open with that profile. browser_profile_list also shows what a profile is connected to.
47
114
 
48
115
  NOTES
49
116
  - Bulk / full-site crawls: call extract_site with rotateProxies:true for blocked or rate-limited sites.
@@ -256,6 +323,99 @@ function buildLinkGraph(pages, startUrl) {
256
323
  return { edges, metrics };
257
324
  }
258
325
 
326
+ // src/api/seo-link-report.ts
327
+ function registrableDomain(host) {
328
+ const h = host.replace(/^www\./, "").toLowerCase();
329
+ const parts = h.split(".");
330
+ return parts.length <= 2 ? h : parts.slice(-2).join(".");
331
+ }
332
+ function buildLinkReport(edges, metrics, siteUrl) {
333
+ let siteReg = "";
334
+ try {
335
+ siteReg = registrableDomain(new URL(siteUrl).hostname);
336
+ } catch {
337
+ siteReg = "";
338
+ }
339
+ const internalEdges = edges.filter((e) => e.internal);
340
+ const pages = metrics.length;
341
+ const orphans = metrics.filter((m) => m.orphan).length;
342
+ const brokenInternal = internalEdges.filter((e) => e.targetStatus != null && e.targetStatus >= 400).length;
343
+ const sumInlinks = metrics.reduce((a, m) => a + m.inlinks, 0);
344
+ const sumOutlinks = metrics.reduce((a, m) => a + m.outlinksInternal + m.outlinksExternal, 0);
345
+ const distribution = { zero: 0, oneToTwo: 0, threeToTen: 0, elevenPlus: 0 };
346
+ for (const m of metrics) {
347
+ if (m.inlinks === 0) distribution.zero++;
348
+ else if (m.inlinks <= 2) distribution.oneToTwo++;
349
+ else if (m.inlinks <= 10) distribution.threeToTen++;
350
+ else distribution.elevenPlus++;
351
+ }
352
+ 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 }));
353
+ const domMap = /* @__PURE__ */ new Map();
354
+ let externalTotal = 0;
355
+ for (const e of edges) {
356
+ let domain;
357
+ try {
358
+ domain = registrableDomain(new URL(e.to).hostname);
359
+ } catch {
360
+ continue;
361
+ }
362
+ if (!domain || domain === siteReg) continue;
363
+ externalTotal++;
364
+ const d = domMap.get(domain) ?? { links: 0, nofollow: 0, pages: /* @__PURE__ */ new Set() };
365
+ d.links++;
366
+ if (e.nofollow) d.nofollow++;
367
+ d.pages.add(e.from);
368
+ domMap.set(domain, d);
369
+ }
370
+ 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);
371
+ const round = (n) => Math.round(n * 10) / 10;
372
+ return {
373
+ summary: {
374
+ internal: {
375
+ totalLinks: internalEdges.length,
376
+ pages,
377
+ orphans,
378
+ brokenInternal,
379
+ avgInlinks: pages ? round(sumInlinks / pages) : 0,
380
+ avgOutlinks: pages ? round(sumOutlinks / pages) : 0,
381
+ distribution,
382
+ topByInlinks
383
+ },
384
+ external: {
385
+ totalLinks: externalTotal,
386
+ uniqueDomains: externalDomains.length,
387
+ topDomains: externalDomains.slice(0, 20)
388
+ }
389
+ },
390
+ externalDomains
391
+ };
392
+ }
393
+ function renderLinkReport(r) {
394
+ const s = r.summary;
395
+ const lines = [
396
+ `## Link analysis`,
397
+ `**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`,
398
+ `**External:** ${s.external.totalLinks} links to ${s.external.uniqueDomains} domains`,
399
+ ``,
400
+ `### Inlink distribution`,
401
+ `- 0 inlinks (orphans): ${s.internal.distribution.zero}`,
402
+ `- 1\u20132: ${s.internal.distribution.oneToTwo}`,
403
+ `- 3\u201310: ${s.internal.distribution.threeToTen}`,
404
+ `- 11+: ${s.internal.distribution.elevenPlus}`,
405
+ ``,
406
+ `### Top 20 internal pages by inlinks`,
407
+ `| inlinks | out (int/ext) | URL |`,
408
+ `|---|---|---|`,
409
+ ...s.internal.topByInlinks.map((p) => `| ${p.inlinks} | ${p.outlinksInternal}/${p.outlinksExternal} | ${p.url} |`),
410
+ ``,
411
+ `### Top 20 external domains by links`,
412
+ `| links | nofollow | from pages | domain |`,
413
+ `|---|---|---|---|`,
414
+ ...s.external.topDomains.map((d) => `| ${d.links} | ${d.nofollow} | ${d.pages} | ${d.domain} |`)
415
+ ];
416
+ return lines.join("\n");
417
+ }
418
+
259
419
  // src/api/seo-issues.ts
260
420
  var THIN_WORDS = 200;
261
421
  var TITLE_MAX = 60;
@@ -564,6 +724,9 @@ function saveBulkSite(siteUrl, pages, seo, imageAudit) {
564
724
  `- \`pages.jsonl\` \u2014 per-page SEO fields, one JSON object per page`,
565
725
  `- \`links.jsonl\` \u2014 internal/external link edges (anchor, rel, target status)`,
566
726
  `- \`link-metrics.jsonl\` \u2014 inlinks, crawl depth, orphan flags per page`,
727
+ `- \`link-report.md\` \u2014 link analysis: top internal pages by inlinks, top external domains, distribution`,
728
+ `- \`links-summary.json\` \u2014 link totals + top-20 internal pages + top-20 external domains`,
729
+ `- \`external-domains.json\` \u2014 every external domain linked to, ordered by link count`,
567
730
  ...imageAudit ? [
568
731
  `- \`images.jsonl\` \u2014 every image with byte size, format, over-100KB and legacy-format flags`,
569
732
  `- \`images-summary.json\` \u2014 image totals (count, total weight, over-100KB, legacy)`
@@ -599,7 +762,13 @@ ${indexRows.join("\n")}`
599
762
  writeFileSync(reportFile, seo.reportMd + (imageAudit ? `
600
763
 
601
764
  ${renderImageSection(imageAudit)}` : ""), "utf8");
602
- seoFiles = [pagesJsonl, linksJsonl, metricsJsonl, issuesFile, reportFile];
765
+ const linkReportFile = join(dir, "link-report.md");
766
+ const linksSummaryFile = join(dir, "links-summary.json");
767
+ const externalDomainsFile = join(dir, "external-domains.json");
768
+ writeFileSync(linkReportFile, renderLinkReport(seo.linkReport), "utf8");
769
+ writeFileSync(linksSummaryFile, JSON.stringify(seo.linkReport.summary, null, 2), "utf8");
770
+ writeFileSync(externalDomainsFile, JSON.stringify(seo.linkReport.externalDomains, null, 2), "utf8");
771
+ seoFiles = [pagesJsonl, linksJsonl, metricsJsonl, issuesFile, reportFile, linkReportFile, linksSummaryFile, externalDomainsFile];
603
772
  if (imageAudit) {
604
773
  const imagesJsonl = join(dir, "images.jsonl");
605
774
  const imagesSummary = join(dir, "images-summary.json");
@@ -1051,6 +1220,7 @@ function buildSeoExport(siteUrl, pages, branding) {
1051
1220
  metrics: [...metrics.values()],
1052
1221
  issues,
1053
1222
  reportMd: renderIssueReport(siteUrl, pages, issues, metrics),
1223
+ linkReport: buildLinkReport(edges, [...metrics.values()], siteUrl),
1054
1224
  branding: branding ?? void 0
1055
1225
  };
1056
1226
  }
@@ -1159,6 +1329,9 @@ async function formatAuditSite(raw, input) {
1159
1329
  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");
1160
1330
  const img = imageAudit.summary;
1161
1331
  const imgLine = `${img.unique} images \xB7 ${img.totalSize} total \xB7 ${img.over100kb} over 100 KB \xB7 ${img.legacyFormat} legacy format`;
1332
+ const lr = seo.linkReport.summary;
1333
+ const topExt = lr.external.topDomains.slice(0, 3).map((dm) => `${dm.domain} (${dm.links})`).join(", ") || "\u2014";
1334
+ 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}`;
1162
1335
  const durationLine = `**${pages.length} pages** \xB7 ${((d.durationMs ?? 0) / 1e3).toFixed(1)}s`;
1163
1336
  const structuredContent = {
1164
1337
  url: input.url,
@@ -1166,7 +1339,8 @@ async function formatAuditSite(raw, input) {
1166
1339
  durationMs: d.durationMs ?? 0,
1167
1340
  bulkFolder: bulk?.dir ?? null,
1168
1341
  issues: Object.fromEntries(Object.entries(seo.issues).map(([k, v]) => [k, v.count])),
1169
- images: { unique: img.unique, totalBytes: img.totalBytes, over100kb: img.over100kb, legacyFormat: img.legacyFormat }
1342
+ images: { unique: img.unique, totalBytes: img.totalBytes, over100kb: img.over100kb, legacyFormat: img.legacyFormat },
1343
+ links: { internal: lr.internal.totalLinks, external: lr.external.totalLinks, orphans: lr.internal.orphans, brokenInternal: lr.internal.brokenInternal, externalDomains: lr.external.uniqueDomains }
1170
1344
  };
1171
1345
  const location = bulk ? `
1172
1346
  ## \u{1F4C1} Technical audit saved
@@ -1185,6 +1359,9 @@ Report saving is disabled in this environment. Enable \`MCP_SCRAPER_SAVE_REPORTS
1185
1359
  ## Top issues
1186
1360
  ${topIssues || "_none found_"}`,
1187
1361
  `
1362
+ ## Links
1363
+ ${linkLine}`,
1364
+ `
1188
1365
  ## Images
1189
1366
  ${imgLine}`
1190
1367
  ].join("\n");
@@ -2715,6 +2892,13 @@ var AuditSiteOutputSchema = {
2715
2892
  totalBytes: z.number().min(0),
2716
2893
  over100kb: z.number().int().min(0),
2717
2894
  legacyFormat: z.number().int().min(0)
2895
+ }),
2896
+ links: z.object({
2897
+ internal: z.number().int().min(0),
2898
+ external: z.number().int().min(0),
2899
+ orphans: z.number().int().min(0),
2900
+ brokenInternal: z.number().int().min(0),
2901
+ externalDomains: z.number().int().min(0)
2718
2902
  })
2719
2903
  };
2720
2904
  var MapsPlaceIntelOutputSchema = {
@@ -3566,7 +3750,7 @@ function registerPaaExtractorMcpTools(server, executor, options = {}) {
3566
3750
  }, async (input) => formatExtractSite(await executor.extractSite(input), input));
3567
3751
  server.registerTool("audit_site", {
3568
3752
  title: "Technical SEO Audit",
3569
- 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."),
3753
+ 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."),
3570
3754
  inputSchema: AuditSiteInputSchema,
3571
3755
  outputSchema: AuditSiteOutputSchema,
3572
3756
  annotations: liveWebToolAnnotations("Technical SEO Audit")
@@ -3975,9 +4159,143 @@ var HttpMcpToolExecutor = class {
3975
4159
  }
3976
4160
  };
3977
4161
 
4162
+ // src/services/fanout/export.ts
4163
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
4164
+ import { homedir as homedir2 } from "os";
4165
+ import { join as join3 } from "path";
4166
+ import Papa from "papaparse";
4167
+ function outputBaseDir2() {
4168
+ return process.env.MCP_SCRAPER_OUTPUT_DIR?.trim() || join3(homedir2(), "Downloads", "mcp-scraper");
4169
+ }
4170
+ function safe(value) {
4171
+ return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "capture";
4172
+ }
4173
+ function writeTable(path, rows, delimiter) {
4174
+ writeFileSync2(path, Papa.unparse(rows.length ? rows : [{}], { delimiter }));
4175
+ }
4176
+ function exportFanout(enriched) {
4177
+ const stamp = safe(enriched.capturedAt.replace(/[:.]/g, "-"));
4178
+ const outputDir = outputBaseDir2();
4179
+ const relativeDir = join3("fanout", `${stamp}-${safe(enriched.platform)}`);
4180
+ const dir = join3(outputDir, relativeDir);
4181
+ mkdirSync2(dir, { recursive: true });
4182
+ const queryRows = enriched.queries.map((q, i) => ({ index: i + 1, query: q }));
4183
+ const citationRows = enriched.aggregates.citationOrder.map((c) => {
4184
+ const src = enriched.citedUrls.find((s) => s.url === c.url);
4185
+ return { rank: c.rank, domain: c.domain, url: c.url, timesCited: c.timesCited, siteType: src?.siteType ?? "", title: src?.title ?? "" };
4186
+ });
4187
+ const sourceRows = enriched.browsedUrls.map((s) => ({ domain: s.domain, url: s.url, cited: s.cited, timesCited: s.timesCited, siteType: s.siteType, title: s.title, round: s.round ?? "" }));
4188
+ const browsedOnlyRows = enriched.browsedOnly.map((s) => ({ domain: s.domain, url: s.url, cited: s.cited, timesCited: s.timesCited, siteType: s.siteType, title: s.title, round: s.round ?? "" }));
4189
+ const snippetRows = enriched.snippets.map((s) => ({ domain: s.domain, url: s.url, title: s.title, text: s.text }));
4190
+ const domainRows = enriched.aggregates.topSites.map((d) => ({ domain: d.domain, count: d.count, cited: d.cited, timesCited: d.timesCited, siteType: d.siteType }));
4191
+ const relativePaths = {
4192
+ relativeTo: "MCP_SCRAPER_OUTPUT_DIR or ~/Downloads/mcp-scraper",
4193
+ dir: relativeDir,
4194
+ json: join3(relativeDir, "fanout.json"),
4195
+ queriesCsv: join3(relativeDir, "queries.csv"),
4196
+ queriesTsv: join3(relativeDir, "queries.tsv"),
4197
+ citationsCsv: join3(relativeDir, "citations.csv"),
4198
+ sourcesCsv: join3(relativeDir, "sources.csv"),
4199
+ browsedOnlyCsv: join3(relativeDir, "browsed-only.csv"),
4200
+ snippetsCsv: join3(relativeDir, "snippets.csv"),
4201
+ domainsCsv: join3(relativeDir, "domains.csv"),
4202
+ report: join3(relativeDir, "report.html")
4203
+ };
4204
+ writeFileSync2(join3(outputDir, relativePaths.json), JSON.stringify(enriched, null, 2));
4205
+ writeTable(join3(outputDir, relativePaths.queriesCsv), queryRows, ",");
4206
+ writeTable(join3(outputDir, relativePaths.queriesTsv), queryRows, " ");
4207
+ writeTable(join3(outputDir, relativePaths.citationsCsv), citationRows, ",");
4208
+ writeTable(join3(outputDir, relativePaths.sourcesCsv), sourceRows, ",");
4209
+ writeTable(join3(outputDir, relativePaths.browsedOnlyCsv), browsedOnlyRows, ",");
4210
+ writeTable(join3(outputDir, relativePaths.snippetsCsv), snippetRows, ",");
4211
+ writeTable(join3(outputDir, relativePaths.domainsCsv), domainRows, ",");
4212
+ writeFileSync2(join3(outputDir, relativePaths.report), renderReportHtml(enriched));
4213
+ return relativePaths;
4214
+ }
4215
+ function esc(s) {
4216
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
4217
+ }
4218
+ function renderReportHtml(enriched) {
4219
+ const data = JSON.stringify(enriched).replace(/</g, "\\u003c");
4220
+ return `<!doctype html>
4221
+ <html lang="en">
4222
+ <head>
4223
+ <meta charset="utf-8" />
4224
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
4225
+ <title>AI Search Fan-Out \u2014 ${esc(enriched.platform)}</title>
4226
+ <style>
4227
+ :root{--bg:#0b0d12;--panel:#151922;--line:#252b38;--ink:#e8ecf3;--mut:#8b95a7;--acc:#5b9dff;--cite:#39d98a;--browse:#5a6677}
4228
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif}
4229
+ .wrap{max-width:980px;margin:0 auto;padding:28px 20px 80px}
4230
+ h1{font-size:20px;margin:0 0 2px}.sub{color:var(--mut);margin:0 0 20px;font-size:13px}
4231
+ .stat{font-size:30px;font-weight:700}.stat small{font-size:13px;font-weight:400;color:var(--mut)}
4232
+ .cards{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:18px 0}
4233
+ .card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px 14px}
4234
+ .card b{font-size:22px;display:block}.card span{color:var(--mut);font-size:12px}
4235
+ .bars{margin:8px 0 22px}.bar{display:flex;align-items:center;gap:8px;margin:4px 0}
4236
+ .bar .lab{width:150px;color:var(--mut);font-size:12px;text-align:right}.bar .track{flex:1;background:#10141c;border-radius:5px;overflow:hidden}
4237
+ .bar .fill{background:var(--acc);height:16px}.bar .n{width:34px;font-size:12px;color:var(--mut)}
4238
+ .tabs{display:flex;gap:8px;margin:22px 0 12px}.tab{background:var(--panel);border:1px solid var(--line);color:var(--ink);padding:7px 14px;border-radius:8px;cursor:pointer}
4239
+ .tab.on{background:var(--acc);border-color:var(--acc);color:#04101f;font-weight:600}
4240
+ .chips{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:12px}
4241
+ .chip{background:var(--panel);border:1px solid var(--line);color:var(--mut);padding:4px 10px;border-radius:20px;cursor:pointer;font-size:12px}
4242
+ .chip.on{border-color:var(--acc);color:var(--ink)}.chip b{color:var(--ink)}
4243
+ .row{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px 12px;margin:6px 0;display:flex;gap:10px;align-items:flex-start}
4244
+ .row .idx{color:var(--mut);min-width:22px}.row .main{flex:1}
4245
+ .tag{font-size:11px;padding:2px 7px;border-radius:6px;border:1px solid var(--line);color:var(--mut);margin-right:5px}
4246
+ .dom{font-weight:600}.url{color:var(--mut);font-size:12px;word-break:break-all}
4247
+ .pill{font-size:11px;padding:2px 7px;border-radius:6px}.pill.cite{background:rgba(57,217,138,.15);color:var(--cite)}.pill.brow{background:rgba(90,102,119,.2);color:#aeb8c8}
4248
+ .copy{background:none;border:1px solid var(--line);color:var(--mut);border-radius:6px;cursor:pointer;font-size:11px;padding:2px 8px}
4249
+ .hide{display:none}
4250
+ </style>
4251
+ </head>
4252
+ <body><div class="wrap">
4253
+ <h1>AI Search Fan-Out</h1>
4254
+ <p class="sub" id="sub"></p>
4255
+ <div class="stat" id="stat"></div>
4256
+ <div class="cards" id="cards"></div>
4257
+ <div id="charts"></div>
4258
+ <div class="tabs"><button class="tab on" data-tab="q">Queries</button><button class="tab" data-tab="u">URLs</button></div>
4259
+ <div class="chips" id="chips"></div>
4260
+ <div id="list"></div>
4261
+ </div>
4262
+ <script>
4263
+ const D=${data};
4264
+ const el=(t,c,h)=>{const e=document.createElement(t);if(c)e.className=c;if(h!=null)e.innerHTML=h;return e};
4265
+ document.getElementById('sub').textContent=D.platform+' \xB7 '+(D.meta.model||'model n/a')+' \xB7 '+D.meta.rounds+' search round(s) \xB7 prompt: '+(D.prompt||'');
4266
+ document.getElementById('stat').innerHTML='Total Fan-Out: '+D.counts.subQueries+' <small>sub-queries \xB7 '+D.counts.browsed+' URLs researched \xB7 '+D.counts.cited+' cited</small>';
4267
+ const cards=[['Sub-queries',D.counts.subQueries],['Researched',D.counts.browsed],['Cited',D.counts.cited],['Browsed-only',D.counts.browsedOnly]];
4268
+ const cc=document.getElementById('cards');cards.forEach(([k,v])=>{const c=el('div','card');c.appendChild(el('b',null,v));c.appendChild(el('span',null,k));cc.appendChild(c)});
4269
+ function barChart(title,obj){const max=Math.max(1,...Object.values(obj));const box=el('div','bars');box.appendChild(el('div',null,'<b>'+title+'</b>'));Object.entries(obj).forEach(([k,v])=>{const r=el('div','bar');r.appendChild(el('div','lab',k));const t=el('div','track');const f=el('div','fill');f.style.width=(v/max*100)+'%';t.appendChild(f);r.appendChild(t);r.appendChild(el('div','n',v));box.appendChild(r)});return box}
4270
+ const charts=document.getElementById('charts');
4271
+ charts.appendChild(barChart('URL Categories',D.aggregates.byCategory));
4272
+ let tab='q',filter=null;
4273
+ const chips=document.getElementById('chips'),list=document.getElementById('list');
4274
+ function render(){
4275
+ chips.innerHTML='';list.innerHTML='';
4276
+ if(tab==='q'){
4277
+ D.queries.forEach((q,i)=>{const r=el('div','row');r.appendChild(el('div','idx',i+1));const m=el('div','main');m.appendChild(el('div',null,esc(q)));r.appendChild(m);const b=el('button','copy','copy');b.onclick=()=>navigator.clipboard.writeText(q);r.appendChild(b);list.appendChild(r)});
4278
+ } else {
4279
+ const counts={};D.browsedUrls.forEach(s=>{counts[s.siteType]=(counts[s.siteType]||0)+1});
4280
+ Object.entries(counts).forEach(([k,v])=>{const c=el('button','chip'+(filter===k?' on':''),k+' <b>'+v+'</b>');c.onclick=()=>{filter=filter===k?null:k;render()};chips.appendChild(c)});
4281
+ D.browsedUrls.forEach(s=>{if(filter&&s.siteType!==filter)return;const r=el('div','row');const m=el('div','main');m.appendChild(el('div',null,'<span class="dom">'+esc(s.domain)+'</span> <span class="pill '+(s.cited?'cite':'brow')+'">'+(s.cited?'cited '+s.timesCited+'\xD7':'browsed')+'</span> <span class="tag">'+s.siteType+'</span>'));m.appendChild(el('div','url',esc(s.url)));r.appendChild(m);list.appendChild(r)});
4282
+ }
4283
+ }
4284
+ function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
4285
+ document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.remove('on'));t.classList.add('on');tab=t.dataset.tab;filter=null;render()});
4286
+ render();
4287
+ </script>
4288
+ </body></html>`;
4289
+ }
4290
+
3978
4291
  export {
3979
4292
  harvestTimeoutBudget,
4293
+ sanitizeOutboundDiagnostics,
4294
+ sanitizeAttempts,
4295
+ sanitizeHarvestResult,
3980
4296
  buildLinkGraph,
4297
+ buildLinkReport,
4298
+ renderLinkReport,
3981
4299
  computeIssues,
3982
4300
  renderIssueReport,
3983
4301
  auditImages,
@@ -3994,6 +4312,7 @@ export {
3994
4312
  liveWebToolAnnotations,
3995
4313
  buildPaaExtractorMcpServer,
3996
4314
  registerPaaExtractorMcpTools,
3997
- HttpMcpToolExecutor
4315
+ HttpMcpToolExecutor,
4316
+ exportFanout
3998
4317
  };
3999
- //# sourceMappingURL=chunk-7XARJHS6.js.map
4318
+ //# sourceMappingURL=chunk-2XCNU22L.js.map