mcp-scraper 0.3.14 → 0.3.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/bin/api-server.cjs +2261 -511
  2. package/dist/bin/api-server.cjs.map +1 -1
  3. package/dist/bin/api-server.js +3 -3
  4. package/dist/bin/browser-agent-stdio-server.cjs +6 -6
  5. package/dist/bin/browser-agent-stdio-server.cjs.map +1 -1
  6. package/dist/bin/browser-agent-stdio-server.js +2 -2
  7. package/dist/bin/mcp-scraper-cli.cjs +1 -1
  8. package/dist/bin/mcp-scraper-cli.cjs.map +1 -1
  9. package/dist/bin/mcp-scraper-cli.js +1 -1
  10. package/dist/bin/mcp-scraper-combined-stdio-server.cjs +663 -48
  11. package/dist/bin/mcp-scraper-combined-stdio-server.cjs.map +1 -1
  12. package/dist/bin/mcp-scraper-combined-stdio-server.js +6 -5
  13. package/dist/bin/mcp-scraper-combined-stdio-server.js.map +1 -1
  14. package/dist/bin/mcp-scraper-install.cjs +4 -4
  15. package/dist/bin/mcp-scraper-install.cjs.map +1 -1
  16. package/dist/bin/mcp-scraper-install.js +2 -2
  17. package/dist/bin/mcp-stdio-server.cjs +655 -40
  18. package/dist/bin/mcp-stdio-server.cjs.map +1 -1
  19. package/dist/bin/mcp-stdio-server.js +2 -2
  20. package/dist/{chunk-DBQDG7EH.js → chunk-5H22TOXQ.js} +28 -1
  21. package/dist/chunk-5H22TOXQ.js.map +1 -0
  22. package/dist/{chunk-HM6FHV5U.js → chunk-7XARJHS6.js} +662 -41
  23. package/dist/chunk-7XARJHS6.js.map +1 -0
  24. package/dist/{chunk-KPXMPAJ3.js → chunk-ACIUCZ27.js} +4 -4
  25. package/dist/chunk-ACIUCZ27.js.map +1 -0
  26. package/dist/{chunk-AX7UBYLG.js → chunk-ISZWGIWL.js} +7 -7
  27. package/dist/chunk-ISZWGIWL.js.map +1 -0
  28. package/dist/chunk-SOMBZK3V.js +7 -0
  29. package/dist/chunk-SOMBZK3V.js.map +1 -0
  30. package/dist/{chunk-AZ5PKU4F.js → chunk-ZAUMSBV3.js} +2 -2
  31. package/dist/{db-BE4JVB3V.js → db-P76GVIFN.js} +2 -2
  32. package/dist/{server-ILIVSPNY.js → server-LPVBSE2E.js} +1133 -93
  33. package/dist/server-LPVBSE2E.js.map +1 -0
  34. package/dist/{worker-FG7ZWEGA.js → worker-2WVKKCC7.js} +3 -3
  35. package/docs/final-tooling-spec.md +206 -0
  36. package/docs/mcp-tool-design-guide.md +225 -0
  37. package/docs/mcp-tool-manifest.generated.json +57 -22
  38. package/docs/seo-crawl-report-spec.md +287 -0
  39. package/docs/tool-catalog-spec.md +388 -0
  40. package/package.json +2 -1
  41. package/dist/chunk-3QHZPR4U.js +0 -7
  42. package/dist/chunk-3QHZPR4U.js.map +0 -1
  43. package/dist/chunk-AX7UBYLG.js.map +0 -1
  44. package/dist/chunk-DBQDG7EH.js.map +0 -1
  45. package/dist/chunk-HM6FHV5U.js.map +0 -1
  46. package/dist/chunk-KPXMPAJ3.js.map +0 -1
  47. package/dist/server-ILIVSPNY.js.map +0 -1
  48. /package/dist/{chunk-AZ5PKU4F.js.map → chunk-ZAUMSBV3.js.map} +0 -0
  49. /package/dist/{db-BE4JVB3V.js.map → db-P76GVIFN.js.map} +0 -0
  50. /package/dist/{worker-FG7ZWEGA.js.map → worker-2WVKKCC7.js.map} +0 -0
@@ -4214,6 +4214,10 @@ async function captureScreenshot(url, kernelApiKey, device = "desktop") {
4214
4214
  const result = await capturePageData(url, { kernelApiKey, device, screenshot: true, branding: false });
4215
4215
  return result.screenshot;
4216
4216
  }
4217
+ async function extractBranding(url, kernelApiKey, device = "desktop") {
4218
+ const result = await capturePageData(url, { kernelApiKey, device, screenshot: false, branding: true });
4219
+ return result.branding;
4220
+ }
4217
4221
  async function capturePageData(url, opts = {}) {
4218
4222
  const device = opts.device ?? "desktop";
4219
4223
  const wantShot = opts.screenshot ?? false;
@@ -4646,9 +4650,10 @@ function baseDomain(url) {
4646
4650
  }
4647
4651
  async function spiderSite(opts) {
4648
4652
  const startMs = Date.now();
4649
- const maxUrls = Math.min(opts.maxUrls ?? 500, 2e3);
4653
+ const maxUrls = Math.min(opts.maxUrls ?? 500, 1e4);
4650
4654
  const concurrency = Math.min(opts.concurrency ?? 12, 20);
4651
4655
  const timeoutMs = opts.timeoutMs ?? 8e3;
4656
+ const budgetMs = opts.budgetMs ?? 24e4;
4652
4657
  const startNorm = normalizeUrl(opts.startUrl, opts.startUrl) ?? opts.startUrl;
4653
4658
  const startDomain = baseDomain(opts.startUrl);
4654
4659
  const visited = /* @__PURE__ */ new Set();
@@ -4662,7 +4667,7 @@ async function spiderSite(opts) {
4662
4667
  queue.push({ url: loc, from: "sitemap" });
4663
4668
  }
4664
4669
  }
4665
- while (queue.length > 0 && results.length < maxUrls) {
4670
+ while (queue.length > 0 && results.length < maxUrls && Date.now() - startMs < budgetMs) {
4666
4671
  const batch = queue.splice(0, concurrency);
4667
4672
  const settled = await Promise.all(
4668
4673
  batch.map(async ({ url, from }) => {
@@ -4819,9 +4824,193 @@ var init_site_mapper = __esm({
4819
4824
  }
4820
4825
  });
4821
4826
 
4827
+ // src/api/rotating-proxy-crawl.ts
4828
+ async function discoverSitemapUrls2(startUrl, maxUrls) {
4829
+ const domain = new URL(startUrl).hostname.replace(/^www\./, "");
4830
+ const urls = await discoverSitemapUrls(startUrl, domain);
4831
+ return urls.slice(0, maxUrls);
4832
+ }
4833
+ function withTimeout(p, ms) {
4834
+ return Promise.race([p, new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms))]);
4835
+ }
4836
+ async function createBrowserWithRetry(client2, headless) {
4837
+ for (let attempt = 0; attempt < 3; attempt++) {
4838
+ try {
4839
+ return await client2.browsers.create({ stealth: true, headless, timeout_seconds: 120 });
4840
+ } catch (err) {
4841
+ if (attempt === 2) throw err;
4842
+ await new Promise((r) => setTimeout(r, 5e3));
4843
+ }
4844
+ }
4845
+ throw new Error("unreachable");
4846
+ }
4847
+ async function crawlPage(context, url) {
4848
+ const startedAt = Date.now();
4849
+ let page = null;
4850
+ try {
4851
+ page = await context.newPage();
4852
+ const work = (async (p) => {
4853
+ const resp = await p.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
4854
+ const html = await p.content();
4855
+ const headers = resp ? Object.fromEntries(Object.entries(await resp.headers()).map(([k, v]) => [k.toLowerCase(), v])) : void 0;
4856
+ return { url, html, status: resp?.status() ?? 200, via: "browser", headers, responseTimeMs: Date.now() - startedAt, redirectUrl: headers?.["location"] ?? null };
4857
+ })(page);
4858
+ const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("page-hard-timeout")), PAGE_HARD_TIMEOUT_MS));
4859
+ return await Promise.race([work, timeout]);
4860
+ } catch {
4861
+ return { url, html: null, status: 0, via: "browser", responseTimeMs: Date.now() - startedAt };
4862
+ } finally {
4863
+ if (page) void page.close().catch(() => {
4864
+ });
4865
+ }
4866
+ }
4867
+ async function crawlWithRotation(urls, opts) {
4868
+ const client2 = new import_sdk3.default({ apiKey: opts.apiKey });
4869
+ const concurrency = Math.max(1, opts.concurrency ?? DEFAULT_CONCURRENCY);
4870
+ const urlsPerBrowser = Math.max(1, opts.urlsPerBrowser ?? DEFAULT_URLS_PER_BROWSER);
4871
+ const headless = opts.headless ?? DEFAULT_HEADLESS;
4872
+ const uniqueUrls = [...new Set(urls)];
4873
+ const byUrl = /* @__PURE__ */ new Map();
4874
+ const runPass = async (targets, phase) => {
4875
+ if (targets.length === 0) return;
4876
+ let next = 0;
4877
+ let done = 0;
4878
+ const workers = Math.min(concurrency, targets.length);
4879
+ const worker = async () => {
4880
+ let kb = null;
4881
+ let browser = null;
4882
+ let inBrowser = 0;
4883
+ const retire = () => {
4884
+ const b = browser, k = kb;
4885
+ browser = null;
4886
+ kb = null;
4887
+ if (b) void b.close().catch(() => {
4888
+ });
4889
+ if (k) void client2.browsers.deleteByID(k.session_id).catch(() => {
4890
+ });
4891
+ };
4892
+ try {
4893
+ while (true) {
4894
+ const i = next++;
4895
+ if (i >= targets.length) break;
4896
+ const url = targets[i];
4897
+ let r;
4898
+ try {
4899
+ if (!browser || inBrowser >= urlsPerBrowser) {
4900
+ retire();
4901
+ kb = await createBrowserWithRetry(client2, headless);
4902
+ browser = await withTimeout(import_playwright3.chromium.connectOverCDP(kb.cdp_ws_url), 3e4);
4903
+ inBrowser = 0;
4904
+ }
4905
+ const context = browser.contexts()[0] ?? await browser.newContext();
4906
+ r = await crawlPage(context, url);
4907
+ inBrowser++;
4908
+ } catch {
4909
+ r = { url, html: null, status: 0, via: "browser" };
4910
+ retire();
4911
+ }
4912
+ byUrl.set(url, r);
4913
+ done++;
4914
+ opts.onPage?.({ url, status: r.status, ok: !!(r.html && r.status >= 200 && r.status < 300), ms: r.responseTimeMs ?? 0, done, total: targets.length, phase });
4915
+ }
4916
+ } finally {
4917
+ retire();
4918
+ }
4919
+ };
4920
+ await Promise.all(Array.from({ length: workers }, () => worker()));
4921
+ };
4922
+ await runPass(uniqueUrls, "main");
4923
+ const failed = uniqueUrls.filter((u) => {
4924
+ const r = byUrl.get(u);
4925
+ return !r || !r.html || r.status < 200 || r.status >= 300;
4926
+ });
4927
+ if (failed.length > 0) await runPass(failed, "retry");
4928
+ return urls.map((u) => byUrl.get(u) ?? { url: u, html: null, status: 0, via: "browser" });
4929
+ }
4930
+ var import_sdk3, import_playwright3, DEFAULT_CONCURRENCY, DEFAULT_URLS_PER_BROWSER, DEFAULT_HEADLESS, PAGE_HARD_TIMEOUT_MS;
4931
+ var init_rotating_proxy_crawl = __esm({
4932
+ "src/api/rotating-proxy-crawl.ts"() {
4933
+ "use strict";
4934
+ import_sdk3 = __toESM(require("@onkernel/sdk"), 1);
4935
+ import_playwright3 = require("playwright");
4936
+ init_site_mapper();
4937
+ DEFAULT_CONCURRENCY = 1;
4938
+ DEFAULT_URLS_PER_BROWSER = 10;
4939
+ DEFAULT_HEADLESS = true;
4940
+ PAGE_HARD_TIMEOUT_MS = 35e3;
4941
+ }
4942
+ });
4943
+
4822
4944
  // src/api/site-extractor.ts
4823
- function parsePageData(url, html, status, via) {
4945
+ function estimatePixels(s) {
4946
+ let px = 0;
4947
+ for (const ch of s) px += PIXEL_WIDTHS[ch] ?? (ch === ch.toUpperCase() && ch !== ch.toLowerCase() ? 10 : 8);
4948
+ return px;
4949
+ }
4950
+ function emptyPageData(url, status, via) {
4951
+ return {
4952
+ url,
4953
+ status,
4954
+ via,
4955
+ contentType: null,
4956
+ responseTimeMs: null,
4957
+ sizeBytes: null,
4958
+ redirectUrl: null,
4959
+ lastModified: null,
4960
+ xRobotsTag: null,
4961
+ title: null,
4962
+ titleLength: null,
4963
+ titlePixels: null,
4964
+ metaDescription: null,
4965
+ metaDescLength: null,
4966
+ metaKeywords: null,
4967
+ h1: null,
4968
+ h1_2: null,
4969
+ h2Count: 0,
4970
+ headings: [],
4971
+ wordCount: 0,
4972
+ textRatio: 0,
4973
+ contentHash: "",
4974
+ metaRobots: null,
4975
+ relNext: null,
4976
+ relPrev: null,
4977
+ hreflang: [],
4978
+ og: null,
4979
+ twitter: null,
4980
+ ampHref: null,
4981
+ imageCount: 0,
4982
+ imagesMissingAlt: 0,
4983
+ imageLinks: [],
4984
+ indexable: status === 200,
4985
+ indexabilityReason: status === 200 ? null : "non-200",
4986
+ schemaTypes: [],
4987
+ canonicalUrl: null,
4988
+ internalLinks: 0,
4989
+ externalLinks: 0,
4990
+ outlinks: [],
4991
+ bodyMarkdown: "",
4992
+ schema: []
4993
+ };
4994
+ }
4995
+ function makeSeedSpider(startUrl, urls) {
4996
+ return {
4997
+ startUrl,
4998
+ urls: urls.map((url) => ({ url, status: 200, linkedFrom: "seed", via: "fetch" })),
4999
+ totalFound: urls.length,
5000
+ durationMs: 0,
5001
+ truncated: false,
5002
+ browserRetries: 0
5003
+ };
5004
+ }
5005
+ function parsePageData(url, html, status, via, resp) {
4824
5006
  const origin = new URL(url).origin;
5007
+ const headers = resp?.headers ?? {};
5008
+ const contentType = headers["content-type"] ?? null;
5009
+ const lastModified = headers["last-modified"] ?? null;
5010
+ const xRobotsTag = headers["x-robots-tag"] ?? null;
5011
+ const responseTimeMs = resp?.responseTimeMs ?? null;
5012
+ const redirectUrl = resp?.redirectUrl ?? headers["location"] ?? null;
5013
+ const sizeBytes = headers["content-length"] ? Number(headers["content-length"]) : Buffer.byteLength(html);
4825
5014
  const title = html.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1]?.trim() ?? null;
4826
5015
  const metaDescription = html.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i)?.[1]?.trim() ?? html.match(/<meta[^>]+content=["']([^"']*)["'][^>]+name=["']description["']/i)?.[1]?.trim() ?? null;
4827
5016
  const canonicalUrl = html.match(/<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']*)["']/i)?.[1]?.trim() ?? html.match(/<link[^>]+href=["']([^"']*)["'][^>]+rel=["']canonical["']/i)?.[1]?.trim() ?? null;
@@ -4858,38 +5047,170 @@ function parsePageData(url, html, status, via) {
4858
5047
  } catch {
4859
5048
  bodyMarkdown = bodyText.slice(0, MAX_PAGE_MARKDOWN);
4860
5049
  }
4861
- let internalLinks = 0;
4862
- let externalLinks = 0;
4863
- for (const m of html.matchAll(/href\s*=\s*["']([^"'\s>]+)/gi)) {
5050
+ const outlinks = [];
5051
+ for (const m of html.matchAll(/<a\b([^>]*?)href\s*=\s*["']([^"'#][^"']*)["']([^>]*)>([\s\S]*?)<\/a>/gi)) {
5052
+ try {
5053
+ const abs = new URL(m[2], url);
5054
+ if (abs.protocol !== "http:" && abs.protocol !== "https:") continue;
5055
+ const attrs = `${m[1]} ${m[3]}`;
5056
+ const rel = attrs.match(/\brel\s*=\s*["']([^"']*)["']/i)?.[1]?.toLowerCase() ?? null;
5057
+ const anchor = m[4].replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 160);
5058
+ outlinks.push({ href: abs.href, anchor, rel, internal: abs.origin === origin });
5059
+ } catch {
5060
+ }
5061
+ }
5062
+ const internalLinks = outlinks.filter((l) => l.internal).length;
5063
+ const externalLinks = outlinks.length - internalLinks;
5064
+ const titleLength = title?.length ?? null;
5065
+ const titlePixels = title ? estimatePixels(title) : null;
5066
+ const metaDescLength = metaDescription?.length ?? null;
5067
+ const metaKeywords = html.match(/<meta[^>]+name=["']keywords["'][^>]+content=["']([^"']*)["']/i)?.[1]?.trim() ?? null;
5068
+ const h1List = headings.filter((h) => h.level === 1);
5069
+ const h1_2 = h1List[1]?.text ?? null;
5070
+ const h2Count = headings.filter((h) => h.level === 2).length;
5071
+ const textRatio = Number((bodyText.length / Math.max(1, html.length)).toFixed(3));
5072
+ const contentHash = (0, import_node_crypto.createHash)("sha1").update(bodyText).digest("hex");
5073
+ const metaRobots = html.match(/<meta[^>]+name=["']robots["'][^>]+content=["']([^"']*)["']/i)?.[1]?.trim().toLowerCase() ?? null;
5074
+ const relNext = html.match(/<link[^>]+rel=["']next["'][^>]+href=["']([^"']*)["']/i)?.[1]?.trim() ?? null;
5075
+ const relPrev = html.match(/<link[^>]+rel=["']prev["'][^>]+href=["']([^"']*)["']/i)?.[1]?.trim() ?? null;
5076
+ const hreflang = [];
5077
+ for (const m of html.matchAll(/<link[^>]+rel=["']alternate["'][^>]*>/gi)) {
5078
+ const lang = m[0].match(/hreflang=["']([^"']*)["']/i)?.[1];
5079
+ const href = m[0].match(/href=["']([^"']*)["']/i)?.[1];
5080
+ if (lang && href) hreflang.push({ lang, href });
5081
+ }
5082
+ const ogTag = (p) => html.match(new RegExp(`<meta[^>]+property=["']og:${p}["'][^>]+content=["']([^"']*)["']`, "i"))?.[1]?.trim();
5083
+ const og = ogTag("title") || ogTag("description") || ogTag("image") || ogTag("type") ? { title: ogTag("title"), description: ogTag("description"), image: ogTag("image"), type: ogTag("type") } : null;
5084
+ const twTag = (p) => html.match(new RegExp(`<meta[^>]+name=["']twitter:${p}["'][^>]+content=["']([^"']*)["']`, "i"))?.[1]?.trim();
5085
+ const twitter = twTag("card") || twTag("title") || twTag("description") ? { card: twTag("card"), title: twTag("title"), description: twTag("description") } : null;
5086
+ const ampHref = html.match(/<link[^>]+rel=["']amphtml["'][^>]+href=["']([^"']*)["']/i)?.[1]?.trim() ?? null;
5087
+ let imageCount = 0;
5088
+ let imagesMissingAlt = 0;
5089
+ const imageLinkSet = /* @__PURE__ */ new Set();
5090
+ for (const m of html.matchAll(/<img\b([^>]*)>/gi)) {
5091
+ imageCount++;
5092
+ const alt = m[1].match(/\balt\s*=\s*["']([^"']*)["']/i)?.[1];
5093
+ if (!alt || !alt.trim()) imagesMissingAlt++;
5094
+ const src = m[1].match(/\bsrc\s*=\s*["']([^"']+)["']/i)?.[1];
5095
+ if (src) {
5096
+ try {
5097
+ const abs = new URL(src, url);
5098
+ if (abs.protocol === "http:" || abs.protocol === "https:") imageLinkSet.add(abs.href);
5099
+ } catch {
5100
+ }
5101
+ }
5102
+ }
5103
+ if (og?.image) {
5104
+ try {
5105
+ imageLinkSet.add(new URL(og.image, url).href);
5106
+ } catch {
5107
+ }
5108
+ }
5109
+ const imageLinks = [...imageLinkSet].slice(0, 300);
5110
+ const canonNorm = (u) => {
4864
5111
  try {
4865
- const abs = new URL(m[1], url);
4866
- if (abs.origin === origin) internalLinks++;
4867
- else externalLinks++;
5112
+ const x = new URL(u, url);
5113
+ return (x.origin + x.pathname).replace(/\/+$/, "");
4868
5114
  } catch {
5115
+ return u.replace(/\/+$/, "");
4869
5116
  }
5117
+ };
5118
+ const canonicalSelf = canonicalUrl ? canonNorm(canonicalUrl) === canonNorm(url) : true;
5119
+ let indexable = true;
5120
+ let indexabilityReason = null;
5121
+ if (status !== 200) {
5122
+ indexable = false;
5123
+ indexabilityReason = "non-200";
5124
+ } else if (metaRobots?.includes("noindex")) {
5125
+ indexable = false;
5126
+ indexabilityReason = "noindex";
5127
+ } else if (xRobotsTag?.toLowerCase().includes("noindex")) {
5128
+ indexable = false;
5129
+ indexabilityReason = "x-robots-noindex";
5130
+ } else if (canonicalUrl && !canonicalSelf) {
5131
+ indexable = false;
5132
+ indexabilityReason = "canonicalised";
4870
5133
  }
4871
- return { url, status, via, title, metaDescription, h1, headings, wordCount: wordCount2, schemaTypes, canonicalUrl, internalLinks, externalLinks, bodyMarkdown, schema };
5134
+ return {
5135
+ url,
5136
+ status,
5137
+ via,
5138
+ contentType,
5139
+ responseTimeMs,
5140
+ sizeBytes,
5141
+ redirectUrl,
5142
+ lastModified,
5143
+ xRobotsTag,
5144
+ title,
5145
+ titleLength,
5146
+ titlePixels,
5147
+ metaDescription,
5148
+ metaDescLength,
5149
+ metaKeywords,
5150
+ h1,
5151
+ h1_2,
5152
+ h2Count,
5153
+ headings,
5154
+ wordCount: wordCount2,
5155
+ textRatio,
5156
+ contentHash,
5157
+ metaRobots,
5158
+ relNext,
5159
+ relPrev,
5160
+ hreflang,
5161
+ og,
5162
+ twitter,
5163
+ ampHref,
5164
+ imageCount,
5165
+ imagesMissingAlt,
5166
+ imageLinks,
5167
+ indexable,
5168
+ indexabilityReason,
5169
+ schemaTypes,
5170
+ canonicalUrl,
5171
+ internalLinks,
5172
+ externalLinks,
5173
+ outlinks,
5174
+ bodyMarkdown,
5175
+ schema
5176
+ };
5177
+ }
5178
+ async function extractPagesRotating(urls, opts) {
5179
+ const fetched = await crawlWithRotation(urls, { apiKey: opts.kernelApiKey, concurrency: opts.concurrency, urlsPerBrowser: opts.urlsPerBrowser });
5180
+ return fetched.map((r) => r.html ? parsePageData(r.url, r.html, r.status || 200, "browser", { headers: r.headers, responseTimeMs: r.responseTimeMs, redirectUrl: r.redirectUrl }) : emptyPageData(r.url, r.status || null, "browser"));
5181
+ }
5182
+ async function discoverUrls(startUrl, maxPages, kernelApiKey) {
5183
+ const sitemapUrls = await discoverSitemapUrls2(startUrl, maxPages);
5184
+ if (sitemapUrls.length > 0) return sitemapUrls;
5185
+ const spider = await spiderSite({ startUrl, maxUrls: maxPages, concurrency: 12, kernelApiKey });
5186
+ return spider.urls.filter((u) => u.status === 200 || u.status === null).slice(0, maxPages).map((u) => u.url);
4872
5187
  }
4873
5188
  async function fetchAndParse(url, kernelApiKey) {
5189
+ const startedAt = Date.now();
4874
5190
  try {
4875
5191
  const res = await fetch(url, {
4876
5192
  headers: { "User-Agent": UA2, "Accept": "text/html,application/xhtml+xml" },
4877
5193
  signal: AbortSignal.timeout(1e4),
4878
5194
  redirect: "manual"
4879
5195
  });
5196
+ const meta = {
5197
+ headers: Object.fromEntries([...res.headers.entries()].map(([k, v]) => [k.toLowerCase(), v])),
5198
+ responseTimeMs: Date.now() - startedAt,
5199
+ redirectUrl: res.headers.get("location")
5200
+ };
4880
5201
  if (res.ok) {
4881
5202
  const ct = res.headers.get("content-type") ?? "";
4882
5203
  if (ct.includes("html")) {
4883
5204
  const html = await res.text();
4884
- return parsePageData(url, html, res.status, "fetch");
5205
+ return parsePageData(url, html, res.status, "fetch", meta);
4885
5206
  }
4886
- return { url, status: res.status, via: "fetch", title: null, metaDescription: null, h1: null, headings: [], wordCount: 0, schemaTypes: [], canonicalUrl: null, internalLinks: 0, externalLinks: 0, bodyMarkdown: "", schema: [] };
5207
+ return { ...emptyPageData(url, res.status, "fetch"), contentType: ct || null, responseTimeMs: meta.responseTimeMs ?? null };
4887
5208
  }
4888
5209
  if ((res.status === 403 || res.status === 429) && kernelApiKey) {
4889
5210
  const html = await fetchWithKernel(url);
4890
5211
  return parsePageData(url, html, 200, "browser");
4891
5212
  }
4892
- return { url, status: res.status, via: "fetch", title: null, metaDescription: null, h1: null, headings: [], wordCount: 0, schemaTypes: [], canonicalUrl: null, internalLinks: 0, externalLinks: 0, bodyMarkdown: "", schema: [] };
5213
+ return { ...emptyPageData(url, res.status, "fetch"), responseTimeMs: meta.responseTimeMs ?? null, redirectUrl: meta.redirectUrl ?? null };
4893
5214
  } catch {
4894
5215
  if (kernelApiKey) {
4895
5216
  try {
@@ -4898,7 +5219,7 @@ async function fetchAndParse(url, kernelApiKey) {
4898
5219
  } catch {
4899
5220
  }
4900
5221
  }
4901
- return { url, status: null, via: "fetch", title: null, metaDescription: null, h1: null, headings: [], wordCount: 0, schemaTypes: [], canonicalUrl: null, internalLinks: 0, externalLinks: 0, bodyMarkdown: "", schema: [] };
5222
+ return emptyPageData(url, null, "fetch");
4902
5223
  }
4903
5224
  }
4904
5225
  async function runWithConcurrency(items, concurrency, fn) {
@@ -4913,39 +5234,95 @@ async function runWithConcurrency(items, concurrency, fn) {
4913
5234
  }
4914
5235
  async function extractSite(opts) {
4915
5236
  const startMs = Date.now();
4916
- const maxPages = Math.min(opts.maxPages ?? 100, 200);
5237
+ const maxPages = Math.min(opts.maxPages ?? 100, 1e4);
4917
5238
  const concurrency = Math.min(opts.concurrency ?? EXTRACT_CONCURRENCY, 10);
4918
- const spider = await spiderSite({
4919
- startUrl: opts.startUrl,
4920
- maxUrls: maxPages,
4921
- concurrency: 12,
4922
- kernelApiKey: opts.kernelApiKey
4923
- });
4924
- const urlsToExtract = spider.urls.filter((u) => u.status === 200 || u.status === null).slice(0, maxPages).map((u) => u.url);
5239
+ const rotating = Boolean(opts.rotateProxyEvery && opts.kernelApiKey);
5240
+ const seeded = opts.seedUrls && opts.seedUrls.length > 0;
4925
5241
  const pages = [];
4926
- let browserRetries = spider.browserRetries;
4927
- await runWithConcurrency(urlsToExtract, concurrency, async (url) => {
4928
- const data = await fetchAndParse(url, opts.kernelApiKey);
4929
- if (data.via === "browser") browserRetries++;
4930
- pages.push(data);
4931
- });
5242
+ let browserRetries = 0;
5243
+ let truncated = false;
5244
+ let spider;
5245
+ if (rotating) {
5246
+ const browserConcurrency = Math.max(1, Math.min(opts.parallelism ?? 1, 8));
5247
+ const urlsPerBrowser = opts.rotateProxyEvery ?? 10;
5248
+ const followLinks = !seeded;
5249
+ const seen = /* @__PURE__ */ new Set();
5250
+ const frontier = [];
5251
+ const enqueue = (u) => {
5252
+ const n = normalizeUrl(u, opts.startUrl);
5253
+ if (n && !seen.has(n)) {
5254
+ seen.add(n);
5255
+ frontier.push(n);
5256
+ }
5257
+ };
5258
+ if (seeded) {
5259
+ for (const u of opts.seedUrls) enqueue(u);
5260
+ } else {
5261
+ enqueue(opts.startUrl);
5262
+ for (const u of await discoverSitemapUrls2(opts.startUrl, maxPages)) enqueue(u);
5263
+ }
5264
+ const waveCap = Math.max(browserConcurrency * urlsPerBrowser, 30);
5265
+ let done = 0;
5266
+ while (frontier.length > 0 && pages.length < maxPages) {
5267
+ const capacity = maxPages - pages.length;
5268
+ const wave = frontier.splice(0, Math.min(capacity, waveCap));
5269
+ const fetched = await crawlWithRotation(wave, {
5270
+ apiKey: opts.kernelApiKey,
5271
+ concurrency: browserConcurrency,
5272
+ urlsPerBrowser,
5273
+ onPage: (p) => {
5274
+ done++;
5275
+ opts.onProgress?.({ done, discovered: seen.size, frontier: frontier.length, lastOk: p.ok ? 1 : 0, lastFail: p.ok ? 0 : 1, phase: p.phase });
5276
+ }
5277
+ });
5278
+ for (const r of fetched) {
5279
+ const page = r.html ? parsePageData(r.url, r.html, r.status || 200, "browser", { headers: r.headers, responseTimeMs: r.responseTimeMs, redirectUrl: r.redirectUrl }) : emptyPageData(r.url, r.status || null, "browser");
5280
+ pages.push(page);
5281
+ browserRetries++;
5282
+ if (followLinks && r.html) {
5283
+ for (const l of page.outlinks) if (l.internal) enqueue(l.href);
5284
+ }
5285
+ }
5286
+ }
5287
+ truncated = frontier.length > 0;
5288
+ spider = makeSeedSpider(opts.startUrl, [...seen]);
5289
+ } else {
5290
+ spider = seeded ? makeSeedSpider(opts.startUrl, opts.seedUrls) : await spiderSite({ startUrl: opts.startUrl, maxUrls: maxPages, concurrency: 12, kernelApiKey: opts.kernelApiKey });
5291
+ const urlsToExtract = spider.urls.filter((u) => u.status === 200 || u.status === null).slice(0, maxPages).map((u) => u.url);
5292
+ browserRetries = spider.browserRetries;
5293
+ await runWithConcurrency(urlsToExtract, concurrency, async (url) => {
5294
+ const data = await fetchAndParse(url, opts.kernelApiKey);
5295
+ if (data.via === "browser") browserRetries++;
5296
+ pages.push(data);
5297
+ });
5298
+ truncated = spider.truncated || pages.length >= maxPages;
5299
+ }
5300
+ let branding = null;
5301
+ if (opts.formats?.includes("branding") && opts.kernelApiKey) {
5302
+ branding = await extractBranding(opts.startUrl, opts.kernelApiKey).catch(() => null);
5303
+ }
4932
5304
  return {
4933
5305
  startUrl: opts.startUrl,
4934
5306
  pages,
4935
5307
  totalPages: pages.length,
4936
5308
  spider,
4937
5309
  durationMs: Date.now() - startMs,
4938
- truncated: spider.truncated || pages.length >= maxPages,
4939
- browserRetries
5310
+ truncated,
5311
+ browserRetries,
5312
+ branding
4940
5313
  };
4941
5314
  }
4942
- var import_turndown2, UA2, EXTRACT_CONCURRENCY, MAX_PAGE_MARKDOWN, turndown;
5315
+ var import_turndown2, import_node_crypto, PIXEL_WIDTHS, UA2, EXTRACT_CONCURRENCY, MAX_PAGE_MARKDOWN, turndown;
4943
5316
  var init_site_extractor = __esm({
4944
5317
  "src/api/site-extractor.ts"() {
4945
5318
  "use strict";
4946
5319
  import_turndown2 = __toESM(require("turndown"), 1);
5320
+ import_node_crypto = require("crypto");
4947
5321
  init_site_mapper();
4948
5322
  init_kernel_fetch();
5323
+ init_rotating_proxy_crawl();
5324
+ init_screenshot();
5325
+ PIXEL_WIDTHS = { i: 4, l: 4, j: 4, ".": 4, ",": 4, "'": 4, t: 6, f: 6, r: 6, " ": 4, m: 14, w: 13, W: 16, M: 16 };
4949
5326
  UA2 = "Mozilla/5.0 (compatible; ThorbitBot/1.0; +https://thorbit.ai)";
4950
5327
  EXTRACT_CONCURRENCY = 6;
4951
5328
  MAX_PAGE_MARKDOWN = 4e4;
@@ -5033,11 +5410,11 @@ async function llmParseWithRetry(schema, promptFn, llm) {
5033
5410
  const detail = lastError?.message?.slice(0, 200) ?? "unknown error";
5034
5411
  throw new Error(`LLM failed to produce valid output after 3 attempts: ${detail}`);
5035
5412
  }
5036
- var import_sdk3, DeepInfraLlmClient, OpenRouterLlmClient, FallbackLlmClient;
5413
+ var import_sdk4, DeepInfraLlmClient, OpenRouterLlmClient, FallbackLlmClient;
5037
5414
  var init_llm_parse_with_retry = __esm({
5038
5415
  "src/lib/llm-parse-with-retry.ts"() {
5039
5416
  "use strict";
5040
- import_sdk3 = __toESM(require("@anthropic-ai/sdk"), 1);
5417
+ import_sdk4 = __toESM(require("@anthropic-ai/sdk"), 1);
5041
5418
  DeepInfraLlmClient = class {
5042
5419
  apiKey;
5043
5420
  model;
@@ -7185,6 +7562,33 @@ async function migrate() {
7185
7562
  await db.execute(`CREATE INDEX IF NOT EXISTS site_audit_jobs_user_id ON site_audit_jobs(user_id)`);
7186
7563
  await db.execute(`CREATE INDEX IF NOT EXISTS site_audit_jobs_status ON site_audit_jobs(status)`);
7187
7564
  await db.execute(`CREATE INDEX IF NOT EXISTS site_audit_phase_log_job_id ON site_audit_phase_log(job_id)`);
7565
+ await db.execute(`
7566
+ CREATE TABLE IF NOT EXISTS site_extract_jobs (
7567
+ id TEXT PRIMARY KEY,
7568
+ user_id INTEGER,
7569
+ status TEXT DEFAULT 'pending',
7570
+ start_url TEXT,
7571
+ options TEXT,
7572
+ total_urls INTEGER DEFAULT 0,
7573
+ done_urls INTEGER DEFAULT 0,
7574
+ artifacts TEXT,
7575
+ error TEXT,
7576
+ billed_mc INTEGER,
7577
+ created_at TEXT DEFAULT (datetime('now')),
7578
+ updated_at TEXT DEFAULT (datetime('now'))
7579
+ )
7580
+ `);
7581
+ await db.execute(`
7582
+ CREATE TABLE IF NOT EXISTS site_extract_pages (
7583
+ job_id TEXT,
7584
+ url TEXT,
7585
+ page TEXT,
7586
+ PRIMARY KEY (job_id, url)
7587
+ )
7588
+ `);
7589
+ await db.execute(`CREATE INDEX IF NOT EXISTS site_extract_jobs_user_id ON site_extract_jobs(user_id)`);
7590
+ await db.execute(`CREATE INDEX IF NOT EXISTS site_extract_jobs_status ON site_extract_jobs(status)`);
7591
+ await db.execute(`CREATE INDEX IF NOT EXISTS site_extract_pages_job_id ON site_extract_pages(job_id)`);
7188
7592
  await db.execute(`
7189
7593
  CREATE TABLE IF NOT EXISTS password_reset_tokens (
7190
7594
  token TEXT PRIMARY KEY,
@@ -7341,18 +7745,18 @@ async function stripeEventAlreadyProcessed(eventId) {
7341
7745
  return res.rows.length > 0;
7342
7746
  }
7343
7747
  function generateApiKey() {
7344
- return "sk_" + (0, import_node_crypto.randomBytes)(24).toString("hex");
7748
+ return "sk_" + (0, import_node_crypto2.randomBytes)(24).toString("hex");
7345
7749
  }
7346
7750
  function hashPassword(password) {
7347
- const salt = (0, import_node_crypto.randomBytes)(16).toString("hex");
7348
- const hash = (0, import_node_crypto.scryptSync)(password, salt, 64);
7751
+ const salt = (0, import_node_crypto2.randomBytes)(16).toString("hex");
7752
+ const hash = (0, import_node_crypto2.scryptSync)(password, salt, 64);
7349
7753
  return `${salt}:${hash.toString("hex")}`;
7350
7754
  }
7351
7755
  function verifyPassword(password, stored) {
7352
7756
  const [salt, hash] = stored.split(":");
7353
7757
  const hashBuf = Buffer.from(hash, "hex");
7354
- const derived = (0, import_node_crypto.scryptSync)(password, salt, 64);
7355
- return (0, import_node_crypto.timingSafeEqual)(hashBuf, derived);
7758
+ const derived = (0, import_node_crypto2.scryptSync)(password, salt, 64);
7759
+ return (0, import_node_crypto2.timingSafeEqual)(hashBuf, derived);
7356
7760
  }
7357
7761
  function rowToUser(row) {
7358
7762
  return {
@@ -7405,7 +7809,7 @@ async function getUserStats(userId) {
7405
7809
  async function createUser(email, name, password, stripeCustomerId) {
7406
7810
  const db = getDb();
7407
7811
  const api_key = generateApiKey();
7408
- const plainPassword = password ?? (0, import_node_crypto.randomBytes)(6).toString("hex");
7812
+ const plainPassword = password ?? (0, import_node_crypto2.randomBytes)(6).toString("hex");
7409
7813
  const password_hash = hashPassword(plainPassword);
7410
7814
  const result = await db.execute({
7411
7815
  sql: "INSERT INTO users (email, name, api_key, password_hash, stripe_customer_id) VALUES (?, ?, ?, ?, ?)",
@@ -7416,7 +7820,7 @@ async function createUser(email, name, password, stripeCustomerId) {
7416
7820
  async function checkRateLimit(scope, key, limit, windowSeconds) {
7417
7821
  const nowSeconds = Math.floor(Date.now() / 1e3);
7418
7822
  const windowStart = Math.floor(nowSeconds / windowSeconds) * windowSeconds;
7419
- const keyHash = (0, import_node_crypto.createHash)("sha256").update(key).digest("hex");
7823
+ const keyHash = (0, import_node_crypto2.createHash)("sha256").update(key).digest("hex");
7420
7824
  const db = getDb();
7421
7825
  if (!_rateLimitSchemaReady) {
7422
7826
  await db.execute(`
@@ -7453,8 +7857,8 @@ async function checkRateLimit(scope, key, limit, windowSeconds) {
7453
7857
  };
7454
7858
  }
7455
7859
  async function createPasswordResetToken(userId) {
7456
- const token = (0, import_node_crypto.randomBytes)(32).toString("hex");
7457
- const tokenHash = (0, import_node_crypto.createHash)("sha256").update(token).digest("hex");
7860
+ const token = (0, import_node_crypto2.randomBytes)(32).toString("hex");
7861
+ const tokenHash = (0, import_node_crypto2.createHash)("sha256").update(token).digest("hex");
7458
7862
  const expiresAt = new Date(Date.now() + 60 * 60 * 1e3).toISOString();
7459
7863
  await getDb().execute({
7460
7864
  sql: "INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?)",
@@ -7464,7 +7868,7 @@ async function createPasswordResetToken(userId) {
7464
7868
  }
7465
7869
  async function consumePasswordResetToken(token) {
7466
7870
  const db = getDb();
7467
- const tokenHash = (0, import_node_crypto.createHash)("sha256").update(token).digest("hex");
7871
+ const tokenHash = (0, import_node_crypto2.createHash)("sha256").update(token).digest("hex");
7468
7872
  const res = await db.execute({
7469
7873
  sql: "SELECT user_id, expires_at, used FROM password_reset_tokens WHERE token = ?",
7470
7874
  args: [tokenHash]
@@ -7552,7 +7956,7 @@ function deserialize(raw) {
7552
7956
  };
7553
7957
  }
7554
7958
  async function createJob(userId, query, options, callbackUrl) {
7555
- const id = (0, import_node_crypto.randomUUID)();
7959
+ const id = (0, import_node_crypto2.randomUUID)();
7556
7960
  await getDb().execute({
7557
7961
  sql: "INSERT INTO jobs (id, user_id, query, options, callback_url) VALUES (?, ?, ?, ?, ?)",
7558
7962
  args: [id, userId, query, JSON.stringify(options), callbackUrl ?? null]
@@ -7560,7 +7964,7 @@ async function createJob(userId, query, options, callbackUrl) {
7560
7964
  return id;
7561
7965
  }
7562
7966
  async function createRunningJob(userId, query, options) {
7563
- const id = (0, import_node_crypto.randomUUID)();
7967
+ const id = (0, import_node_crypto2.randomUUID)();
7564
7968
  await getDb().execute({
7565
7969
  sql: `INSERT INTO jobs (id, user_id, query, options, status, started_at) VALUES (?, ?, ?, ?, 'running', datetime('now'))`,
7566
7970
  args: [id, userId, query, JSON.stringify(options)]
@@ -7583,7 +7987,7 @@ async function startHarvestAttempt(input) {
7583
7987
  debug_json = NULL
7584
7988
  `,
7585
7989
  args: [
7586
- (0, import_node_crypto.randomUUID)(),
7990
+ (0, import_node_crypto2.randomUUID)(),
7587
7991
  input.jobId,
7588
7992
  input.userId,
7589
7993
  input.attemptNumber,
@@ -7675,7 +8079,7 @@ function rowToRequestEvent(row) {
7675
8079
  };
7676
8080
  }
7677
8081
  async function logRequestEvent(input) {
7678
- const id = (0, import_node_crypto.randomUUID)();
8082
+ const id = (0, import_node_crypto2.randomUUID)();
7679
8083
  await getDb().execute({
7680
8084
  sql: `
7681
8085
  INSERT INTO request_events (id, user_id, source, status, query, location, result_count, result, error)
@@ -7780,7 +8184,7 @@ function rowToWorkflowArtifact(row) {
7780
8184
  };
7781
8185
  }
7782
8186
  async function createWorkflowSchedule(input) {
7783
- const id = (0, import_node_crypto.randomUUID)();
8187
+ const id = (0, import_node_crypto2.randomUUID)();
7784
8188
  await getDb().execute({
7785
8189
  sql: `
7786
8190
  INSERT INTO workflow_schedules (
@@ -7880,7 +8284,7 @@ async function markWorkflowScheduleRan(id, lastRunAt, nextRunAt) {
7880
8284
  });
7881
8285
  }
7882
8286
  async function createWorkflowRun(input) {
7883
- const id = input.id ?? (0, import_node_crypto.randomUUID)();
8287
+ const id = input.id ?? (0, import_node_crypto2.randomUUID)();
7884
8288
  const args = [
7885
8289
  id,
7886
8290
  input.userId,
@@ -7956,7 +8360,7 @@ async function insertWorkflowArtifact(runId, userId, artifact) {
7956
8360
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
7957
8361
  `,
7958
8362
  args: [
7959
- (0, import_node_crypto.randomUUID)(),
8363
+ (0, import_node_crypto2.randomUUID)(),
7960
8364
  runId,
7961
8365
  userId,
7962
8366
  artifact.kind,
@@ -8019,7 +8423,7 @@ async function recordWorkflowWebhookDelivery(input) {
8019
8423
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
8020
8424
  `,
8021
8425
  args: [
8022
- (0, import_node_crypto.randomUUID)(),
8426
+ (0, import_node_crypto2.randomUUID)(),
8023
8427
  input.runId,
8024
8428
  input.scheduleId ?? null,
8025
8429
  input.userId,
@@ -8227,12 +8631,12 @@ async function reconcileBalanceMc(userId) {
8227
8631
  });
8228
8632
  return balanceMc;
8229
8633
  }
8230
- var import_http, import_node_crypto, import_zod10, DB_URL, DB_TOKEN, _db, _rateLimitSchemaReady, SiteAuditJobRowSchema, SiteAuditPhaseLogRowSchema;
8634
+ var import_http, import_node_crypto2, import_zod10, DB_URL, DB_TOKEN, _db, _rateLimitSchemaReady, SiteAuditJobRowSchema, SiteAuditPhaseLogRowSchema;
8231
8635
  var init_db = __esm({
8232
8636
  "src/api/db.ts"() {
8233
8637
  "use strict";
8234
8638
  import_http = require("@libsql/client/http");
8235
- import_node_crypto = require("crypto");
8639
+ import_node_crypto2 = require("crypto");
8236
8640
  import_zod10 = require("zod");
8237
8641
  DB_URL = process.env.TURSO_DATABASE_URL ?? "file:./paa-api.db";
8238
8642
  DB_TOKEN = process.env.TURSO_AUTH_TOKEN;
@@ -8568,212 +8972,394 @@ var init_site_audit = __esm({
8568
8972
  }
8569
8973
  });
8570
8974
 
8571
- // src/api/site-audit-middleware.ts
8572
- var import_factory2, siteAuditAuth;
8573
- var init_site_audit_middleware = __esm({
8574
- "src/api/site-audit-middleware.ts"() {
8575
- "use strict";
8576
- import_factory2 = require("hono/factory");
8577
- init_db();
8578
- siteAuditAuth = (0, import_factory2.createMiddleware)(async (c, next) => {
8579
- const apiKey = c.req.header("x-api-key");
8580
- if (apiKey) {
8581
- const user = await getUserByApiKey(apiKey);
8582
- if (!user) return c.json({ error: "Unauthorized" }, 401);
8583
- c.set("siteAuditUserId", user.id);
8584
- return next();
8975
+ // src/api/seo-link-graph.ts
8976
+ function normalize(u) {
8977
+ return u.split("#")[0].replace(/\/$/, "");
8978
+ }
8979
+ function buildLinkGraph(pages, startUrl) {
8980
+ const statusByUrl = /* @__PURE__ */ new Map();
8981
+ for (const p of pages) statusByUrl.set(normalize(p.url), p.status);
8982
+ const edges = [];
8983
+ for (const p of pages) {
8984
+ for (const l of p.outlinks ?? []) {
8985
+ const nofollow = (l.rel ?? "").split(/\s+/).includes("nofollow");
8986
+ edges.push({
8987
+ from: p.url,
8988
+ to: l.href,
8989
+ anchor: l.anchor,
8990
+ rel: l.rel,
8991
+ internal: l.internal,
8992
+ nofollow,
8993
+ targetStatus: statusByUrl.get(normalize(l.href)) ?? null
8994
+ });
8995
+ }
8996
+ }
8997
+ const inboundFrom = /* @__PURE__ */ new Map();
8998
+ const inboundAnchors = /* @__PURE__ */ new Map();
8999
+ const adjacency = /* @__PURE__ */ new Map();
9000
+ for (const e of edges) {
9001
+ if (!e.internal) continue;
9002
+ const to = normalize(e.to);
9003
+ const from = normalize(e.from);
9004
+ if (!inboundFrom.has(to)) inboundFrom.set(to, /* @__PURE__ */ new Set());
9005
+ inboundFrom.get(to).add(from);
9006
+ if (e.anchor) {
9007
+ if (!inboundAnchors.has(to)) inboundAnchors.set(to, []);
9008
+ inboundAnchors.get(to).push(e.anchor);
9009
+ }
9010
+ if (!e.nofollow && (statusByUrl.get(to) === 200 || !statusByUrl.has(to))) {
9011
+ if (!adjacency.has(from)) adjacency.set(from, /* @__PURE__ */ new Set());
9012
+ adjacency.get(from).add(to);
9013
+ }
9014
+ }
9015
+ const depth = /* @__PURE__ */ new Map();
9016
+ const start = normalize(startUrl);
9017
+ const queue = [start];
9018
+ depth.set(start, 0);
9019
+ while (queue.length > 0) {
9020
+ const cur = queue.shift();
9021
+ const d = depth.get(cur);
9022
+ for (const next of adjacency.get(cur) ?? []) {
9023
+ if (!depth.has(next)) {
9024
+ depth.set(next, d + 1);
9025
+ queue.push(next);
8585
9026
  }
8586
- return c.json({ error: "Unauthorized" }, 401);
9027
+ }
9028
+ }
9029
+ const topAnchorsFor = (url) => {
9030
+ const counts = /* @__PURE__ */ new Map();
9031
+ for (const a of inboundAnchors.get(url) ?? []) counts.set(a, (counts.get(a) ?? 0) + 1);
9032
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map((e) => e[0]);
9033
+ };
9034
+ const metrics = /* @__PURE__ */ new Map();
9035
+ for (const p of pages) {
9036
+ const key = normalize(p.url);
9037
+ const inbound = inboundFrom.get(key) ?? /* @__PURE__ */ new Set();
9038
+ metrics.set(p.url, {
9039
+ url: p.url,
9040
+ inlinks: inbound.size,
9041
+ uniqueInlinks: inbound.size,
9042
+ outlinksInternal: (p.outlinks ?? []).filter((l) => l.internal).length,
9043
+ outlinksExternal: (p.outlinks ?? []).filter((l) => !l.internal).length,
9044
+ crawlDepth: depth.has(key) ? depth.get(key) : null,
9045
+ orphan: inbound.size === 0 && key !== start,
9046
+ topAnchors: topAnchorsFor(key)
8587
9047
  });
8588
9048
  }
9049
+ return { edges, metrics };
9050
+ }
9051
+ var init_seo_link_graph = __esm({
9052
+ "src/api/seo-link-graph.ts"() {
9053
+ "use strict";
9054
+ }
8589
9055
  });
8590
9056
 
8591
- // src/api/site-audit-routes.ts
8592
- function isPathInside(root, target) {
8593
- const relative = import_node_path2.default.relative(root, target);
8594
- return relative === "" || !relative.startsWith("..") && !import_node_path2.default.isAbsolute(relative);
9057
+ // src/api/seo-issues.ts
9058
+ function dupes(pages, key) {
9059
+ const groups = /* @__PURE__ */ new Map();
9060
+ for (const p of pages) {
9061
+ const k = key(p);
9062
+ if (k === null || k === void 0 || k === "") continue;
9063
+ const ks = String(k);
9064
+ if (!groups.has(ks)) groups.set(ks, []);
9065
+ groups.get(ks).push(p.url);
9066
+ }
9067
+ return [...groups.values()].filter((g) => g.length > 1).flat();
9068
+ }
9069
+ function computeIssues(pages, metrics) {
9070
+ const group = (urls) => ({ count: urls.length, urls: urls.slice(0, 200) });
9071
+ const where = (fn) => pages.filter(fn).map((p) => p.url);
9072
+ const ok = (p) => p.status === 200;
9073
+ const pathname = (u) => {
9074
+ try {
9075
+ return new URL(u).pathname;
9076
+ } catch {
9077
+ return "";
9078
+ }
9079
+ };
9080
+ const report = {
9081
+ "title.missing": group(where((p) => ok(p) && !p.title)),
9082
+ "title.duplicate": group(dupes(pages.filter(ok), (p) => p.title)),
9083
+ "title.tooLong": group(where((p) => (p.titleLength ?? 0) > TITLE_MAX || (p.titlePixels ?? 0) > TITLE_PX_MAX)),
9084
+ "title.tooShort": group(where((p) => p.title != null && (p.titleLength ?? 0) < TITLE_MIN)),
9085
+ "title.sameAsH1": group(where((p) => !!p.title && !!p.h1 && p.title.trim() === p.h1.trim())),
9086
+ "meta.missing": group(where((p) => ok(p) && !p.metaDescription)),
9087
+ "meta.duplicate": group(dupes(pages.filter(ok), (p) => p.metaDescription)),
9088
+ "meta.tooLong": group(where((p) => (p.metaDescLength ?? 0) > META_MAX)),
9089
+ "meta.tooShort": group(where((p) => p.metaDescription != null && (p.metaDescLength ?? 0) < META_MIN)),
9090
+ "h1.missing": group(where((p) => ok(p) && !p.h1)),
9091
+ "h1.multiple": group(where((p) => !!p.h1_2)),
9092
+ "h1.duplicate": group(dupes(pages.filter(ok), (p) => p.h1)),
9093
+ "h1.tooLong": group(where((p) => (p.h1?.length ?? 0) > H1_MAX)),
9094
+ "h2.missing": group(where((p) => ok(p) && p.h2Count === 0)),
9095
+ "indexability.nonIndexable": group(where((p) => !p.indexable)),
9096
+ "indexability.noindex": group(where((p) => p.indexabilityReason === "noindex")),
9097
+ "canonical.missing": group(where((p) => ok(p) && !p.canonicalUrl)),
9098
+ "canonical.canonicalised": group(where((p) => p.indexabilityReason === "canonicalised")),
9099
+ "response.broken4xx": group(where((p) => (p.status ?? 0) >= 400 && (p.status ?? 0) < 500)),
9100
+ "response.error5xx": group(where((p) => (p.status ?? 0) >= 500)),
9101
+ "response.redirect3xx": group(where((p) => (p.status ?? 0) >= 300 && (p.status ?? 0) < 400)),
9102
+ "response.noResponse": group(where((p) => p.status === null)),
9103
+ "content.thin": group(where((p) => ok(p) && p.wordCount > 0 && p.wordCount < THIN_WORDS)),
9104
+ "content.exactDuplicate": group(dupes(pages.filter((p) => ok(p) && p.contentHash), (p) => p.contentHash)),
9105
+ "images.missingAlt": group(where((p) => p.imagesMissingAlt > 0)),
9106
+ "schema.missing": group(where((p) => ok(p) && p.schemaTypes.length === 0)),
9107
+ "url.tooLong": group(where((p) => p.url.length > URL_MAX)),
9108
+ "url.uppercase": group(where((p) => /[A-Z]/.test(pathname(p.url)))),
9109
+ "url.underscores": group(where((p) => pathname(p.url).includes("_"))),
9110
+ "links.orphan": group([...metrics.values()].filter((m) => m.orphan).map((m) => m.url))
9111
+ };
9112
+ const normUrl2 = (u) => {
9113
+ try {
9114
+ const x = new URL(u);
9115
+ x.hash = "";
9116
+ return (x.origin + x.pathname).replace(/\/+$/, "") + x.search;
9117
+ } catch {
9118
+ return u.replace(/#.*$/, "").replace(/\/+$/, "");
9119
+ }
9120
+ };
9121
+ const statusByUrl = new Map(pages.map((p) => [normUrl2(p.url), p.status]));
9122
+ const brokenLinkPages = /* @__PURE__ */ new Set();
9123
+ for (const p of pages) {
9124
+ for (const l of p.outlinks ?? []) {
9125
+ if (!l.internal) continue;
9126
+ const st = statusByUrl.get(normUrl2(l.href));
9127
+ if (st != null && st >= 400) brokenLinkPages.add(p.url);
9128
+ }
9129
+ }
9130
+ report["links.brokenInternal"] = group([...brokenLinkPages]);
9131
+ return report;
9132
+ }
9133
+ function renderIssueReport(siteUrl, pages, report, metrics) {
9134
+ const total = pages.length;
9135
+ 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}";
9136
+ 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" : ""} |`);
9137
+ const depths = [...metrics.values()].map((m) => m.crawlDepth).filter((d) => d != null);
9138
+ const maxDepth = depths.length ? Math.max(...depths) : 0;
9139
+ const orphans = [...metrics.values()].filter((m) => m.orphan).length;
9140
+ const topLinked = [...metrics.values()].sort((a, b) => b.inlinks - a.inlinks).slice(0, 5);
9141
+ return [
9142
+ `# SEO Crawl Report: ${siteUrl}`,
9143
+ `**${total} pages crawled** \xB7 max crawl depth ${maxDepth} \xB7 ${orphans} orphan page(s)`,
9144
+ `
9145
+ ## Issues
9146
+ | | Issue | Count | Examples |
9147
+ |---|-------|-------|----------|
9148
+ ${rows.join("\n") || "| \u2705 | none | 0 | \u2014 |"}`,
9149
+ `
9150
+ ## Most-linked pages
9151
+ ${topLinked.map((m) => `- ${m.inlinks} inlinks \xB7 depth ${m.crawlDepth ?? "\u2014"} \xB7 ${m.url}`).join("\n")}`,
9152
+ `
9153
+ _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._`
9154
+ ].join("\n");
8595
9155
  }
8596
- var import_node_path2, import_node_os2, import_hono, import_zod11, siteAuditApp;
8597
- var init_site_audit_routes = __esm({
8598
- "src/api/site-audit-routes.ts"() {
9156
+ var THIN_WORDS, TITLE_MAX, TITLE_PX_MAX, TITLE_MIN, META_MAX, META_MIN, H1_MAX, URL_MAX;
9157
+ var init_seo_issues = __esm({
9158
+ "src/api/seo-issues.ts"() {
8599
9159
  "use strict";
8600
- import_node_path2 = __toESM(require("path"), 1);
8601
- import_node_os2 = __toESM(require("os"), 1);
8602
- import_hono = require("hono");
8603
- import_zod11 = require("zod");
8604
- init_site_audit_middleware();
8605
- init_factory();
8606
- init_phases();
8607
- init_schemas();
8608
- siteAuditApp = new import_hono.Hono();
8609
- siteAuditApp.onError((err, c) => {
8610
- if (err instanceof import_zod11.ZodError) {
8611
- return c.json({ error: "Invalid request body", issues: err.issues }, 400);
9160
+ THIN_WORDS = 200;
9161
+ TITLE_MAX = 60;
9162
+ TITLE_PX_MAX = 561;
9163
+ TITLE_MIN = 30;
9164
+ META_MAX = 155;
9165
+ META_MIN = 70;
9166
+ H1_MAX = 70;
9167
+ URL_MAX = 115;
9168
+ }
9169
+ });
9170
+
9171
+ // src/api/image-audit.ts
9172
+ function formatBytes(n) {
9173
+ if (n == null) return null;
9174
+ if (n < 1024) return `${n} B`;
9175
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
9176
+ return `${(n / (1024 * 1024)).toFixed(2)} MB`;
9177
+ }
9178
+ function collectUrls(pages) {
9179
+ const byKey = /* @__PURE__ */ new Map();
9180
+ for (const p of pages) for (const raw of p.imageLinks || []) {
9181
+ const u = decodeEntities(raw);
9182
+ if (!/^https?:\/\//i.test(u)) continue;
9183
+ if (/[{}]|%7[bd]/i.test(u)) continue;
9184
+ const k = dedupKey(u);
9185
+ const prev = byKey.get(k);
9186
+ if (!prev || /^https:/i.test(u) && /^http:/i.test(prev)) byKey.set(k, u);
9187
+ }
9188
+ return [...byKey.values()];
9189
+ }
9190
+ async function sizeAndType(url, timeoutMs) {
9191
+ const ctrl = new AbortController();
9192
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
9193
+ const read = (res) => ({
9194
+ len: res.headers.get("content-length"),
9195
+ cr: res.headers.get("content-range"),
9196
+ ct: res.headers.get("content-type"),
9197
+ status: res.status
9198
+ });
9199
+ try {
9200
+ let bytes = null;
9201
+ let ct = null;
9202
+ let status = null;
9203
+ try {
9204
+ const h = read(await fetch(url, { method: "HEAD", redirect: "follow", signal: ctrl.signal }));
9205
+ status = h.status;
9206
+ ct = h.ct;
9207
+ if (h.len) bytes = Number(h.len);
9208
+ } catch {
9209
+ }
9210
+ if (bytes == null) {
9211
+ const g = await fetch(url, { method: "GET", headers: { Range: "bytes=0-0" }, redirect: "follow", signal: ctrl.signal });
9212
+ const r = read(g);
9213
+ status = status ?? r.status;
9214
+ ct = ct ?? r.ct;
9215
+ if (r.cr && r.cr.includes("/")) {
9216
+ const tail = r.cr.split("/")[1];
9217
+ if (tail && tail !== "*") bytes = Number(tail);
9218
+ } else if (r.len) bytes = Number(r.len);
9219
+ try {
9220
+ await g.body?.cancel();
9221
+ } catch {
8612
9222
  }
8613
- const msg = err instanceof Error ? err.message : String(err);
8614
- return c.json({ error: msg }, 500);
8615
- });
8616
- siteAuditApp.use("*", siteAuditAuth);
8617
- siteAuditApp.post("/audit", async (c) => {
8618
- const body = SiteAuditStartRequestSchema.parse(await c.req.json());
8619
- const allowedRoot = import_node_path2.default.resolve(process.env["SESSION_ROOT"] ?? import_node_os2.default.tmpdir());
8620
- const sessionPath = import_node_path2.default.resolve(body.sessionPath);
8621
- if (!isPathInside(allowedRoot, sessionPath)) {
8622
- return c.json({ error: "sessionPath must be inside the configured session root" }, 400);
9223
+ }
9224
+ return { url, status, bytes: Number.isFinite(bytes) ? bytes : null, contentType: ct };
9225
+ } catch (e) {
9226
+ return { url, status: null, bytes: null, contentType: null, error: String(e.message || e) };
9227
+ } finally {
9228
+ clearTimeout(t);
9229
+ }
9230
+ }
9231
+ async function pool(items, n, fn) {
9232
+ const out = new Array(items.length);
9233
+ let i = 0;
9234
+ await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => {
9235
+ while (i < items.length) {
9236
+ const idx = i++;
9237
+ out[idx] = await fn(items[idx]);
9238
+ }
9239
+ }));
9240
+ return out;
9241
+ }
9242
+ async function auditImages(pages, opts = {}) {
9243
+ const concurrency = opts.concurrency ?? 12;
9244
+ const timeoutMs = opts.timeoutMs ?? 12e3;
9245
+ const urls = collectUrls(pages).slice(0, opts.max ?? 5e3);
9246
+ const heads = await pool(urls, concurrency, (u) => sizeAndType(u, timeoutMs));
9247
+ const rows = heads.map((r) => {
9248
+ const format = formatOf(r.contentType, r.url);
9249
+ return {
9250
+ ...r,
9251
+ size: formatBytes(r.bytes),
9252
+ format,
9253
+ over100kb: r.bytes != null && r.bytes > OVER_BYTES,
9254
+ legacyFormat: format !== "unknown" && !MODERN.has(format)
9255
+ };
9256
+ });
9257
+ const sized = rows.filter((r) => r.bytes != null);
9258
+ const totalBytes = sized.reduce((a, r) => a + r.bytes, 0);
9259
+ const formatCounts = {};
9260
+ for (const r of rows) formatCounts[r.format] = (formatCounts[r.format] || 0) + 1;
9261
+ return {
9262
+ rows: rows.sort((a, b) => (b.bytes ?? 0) - (a.bytes ?? 0)),
9263
+ summary: {
9264
+ unique: rows.length,
9265
+ sized: sized.length,
9266
+ totalBytes,
9267
+ totalSize: formatBytes(totalBytes) ?? "0 B",
9268
+ avgSize: formatBytes(Math.round(totalBytes / (sized.length || 1))) ?? "0 B",
9269
+ over100kb: rows.filter((r) => r.over100kb).length,
9270
+ legacyFormat: rows.filter((r) => r.legacyFormat).length,
9271
+ formatCounts
9272
+ }
9273
+ };
9274
+ }
9275
+ function renderImageSection(audit) {
9276
+ const s = audit.summary;
9277
+ const fmt = Object.entries(s.formatCounts).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k}:${v}`).join(" ");
9278
+ const heaviest = audit.rows.filter((r) => r.over100kb).slice(0, 15);
9279
+ const lines = [
9280
+ `## Images`,
9281
+ `**${s.unique} unique images** \xB7 ${s.totalSize} total \xB7 ${s.avgSize} avg \xB7 ${s.over100kb} over 100 KB \xB7 ${s.legacyFormat} legacy format`,
9282
+ `Formats: ${fmt}`
9283
+ ];
9284
+ if (heaviest.length) {
9285
+ lines.push("", "| Size | Format | URL |", "|------|--------|-----|");
9286
+ for (const r of heaviest) lines.push(`| ${r.size} | ${r.format} | ${r.url} |`);
9287
+ }
9288
+ return lines.join("\n");
9289
+ }
9290
+ var OVER_BYTES, MODERN, decodeEntities, dedupKey, formatOf;
9291
+ var init_image_audit = __esm({
9292
+ "src/api/image-audit.ts"() {
9293
+ "use strict";
9294
+ OVER_BYTES = 100 * 1024;
9295
+ MODERN = /* @__PURE__ */ new Set(["webp", "avif", "svg", "svg+xml"]);
9296
+ decodeEntities = (u) => u.replace(/&amp;/g, "&").replace(/&#0?38;/g, "&").replace(/&#x26;/gi, "&").trim();
9297
+ dedupKey = (u) => u.replace(/^https?:\/\//i, "//").replace(/\/$/, "");
9298
+ formatOf = (ct, url) => {
9299
+ if (ct) {
9300
+ const m = ct.split(";")[0].trim().toLowerCase();
9301
+ if (m.startsWith("image/")) return m.replace("image/", "");
9302
+ }
9303
+ return (url.split("?")[0].match(/\.([a-z0-9]+)$/i)?.[1] || "unknown").toLowerCase();
9304
+ };
9305
+ }
9306
+ });
9307
+
9308
+ // src/api/blob-store.ts
9309
+ function byteLength(data) {
9310
+ return Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data);
9311
+ }
9312
+ function localBaseDir() {
9313
+ return process.env.MCP_SCRAPER_OUTPUT_DIR?.trim() || (0, import_node_path2.join)((0, import_node_os2.homedir)(), "Downloads", "mcp-scraper");
9314
+ }
9315
+ function getBlobStore() {
9316
+ if (cached) return cached;
9317
+ if (process.env.BLOB_READ_WRITE_TOKEN) {
9318
+ cached = new VercelBlobStore(process.env.BLOB_READ_WRITE_TOKEN);
9319
+ } else {
9320
+ cached = new LocalBlobStore(localBaseDir());
9321
+ }
9322
+ return cached;
9323
+ }
9324
+ var import_node_fs2, import_node_os2, import_node_path2, LocalBlobStore, cached, VercelBlobStore;
9325
+ var init_blob_store = __esm({
9326
+ "src/api/blob-store.ts"() {
9327
+ "use strict";
9328
+ import_node_fs2 = require("fs");
9329
+ import_node_os2 = require("os");
9330
+ import_node_path2 = require("path");
9331
+ LocalBlobStore = class {
9332
+ constructor(baseDir) {
9333
+ this.baseDir = baseDir;
8623
9334
  }
8624
- const pathFields = [
8625
- body.sessionPath,
8626
- body.sfInternalPath,
8627
- body.sfInlinksPath,
8628
- body.sfOutlinksPath,
8629
- body.sitemapPath,
8630
- body.competitorDomainsCsvPath,
8631
- body.visualSitemapsPath,
8632
- body.gscPath,
8633
- body.ga4Path,
8634
- body.ahrefsPath
8635
- ];
8636
- for (const filePath of pathFields) {
8637
- if (filePath != null && !isPathInside(allowedRoot, import_node_path2.default.resolve(filePath))) {
8638
- return c.json({ error: "Path traversal attempt" }, 400);
8639
- }
9335
+ baseDir;
9336
+ kind = "local";
9337
+ async put(key, data, contentType = "application/octet-stream") {
9338
+ const path6 = (0, import_node_path2.join)(this.baseDir, "blobs", key);
9339
+ (0, import_node_fs2.mkdirSync)((0, import_node_path2.dirname)(path6), { recursive: true });
9340
+ (0, import_node_fs2.writeFileSync)(path6, data);
9341
+ return { key, url: `file://${path6}`, bytes: byteLength(data), contentType };
8640
9342
  }
8641
- const userId = c.get("siteAuditUserId");
8642
- const service = makeSiteAuditService();
8643
- const jobId = await service.startAudit(userId, body);
8644
- void dispatchSiteAuditPhase(service, "phase1-ingest", { jobId, request: body }).catch(
8645
- (err) => {
8646
- console.error(
8647
- "[site-audit-routes] phase1-ingest dispatch failed for job",
8648
- jobId,
8649
- err instanceof Error ? err.message : String(err)
8650
- );
8651
- }
8652
- );
8653
- return c.json({ jobId }, 202);
8654
- });
8655
- siteAuditApp.post("/ingest", async (c) => {
8656
- const body = SiteAuditIngestRequestSchema.parse(await c.req.json());
8657
- const service = makeSiteAuditService();
8658
- const job = await service.getJob(body.jobId);
8659
- if (!job) return c.json({ error: "Not found" }, 404);
8660
- if (job.user_id !== c.get("siteAuditUserId")) return c.json({ error: "Not found" }, 404);
8661
- void dispatchSiteAuditPhase(service, "phase1-ingest", {
8662
- jobId: body.jobId,
8663
- request: JSON.parse(job.request)
8664
- }).catch((err) => {
8665
- console.error(
8666
- "[site-audit-routes] phase1-ingest re-dispatch failed for job",
8667
- body.jobId,
8668
- err instanceof Error ? err.message : String(err)
8669
- );
8670
- });
8671
- return c.json({ jobId: body.jobId }, 202);
8672
- });
8673
- siteAuditApp.post("/build-graph", async (c) => {
8674
- const body = SiteAuditBuildGraphRequestSchema.parse(await c.req.json());
8675
- const service = makeSiteAuditService();
8676
- const job = await service.getJob(body.jobId);
8677
- if (!job) return c.json({ error: "Not found" }, 404);
8678
- if (job.user_id !== c.get("siteAuditUserId")) return c.json({ error: "Not found" }, 404);
8679
- void dispatchSiteAuditPhase(service, "phase2-build-graph", { jobId: body.jobId }).catch(
8680
- (err) => {
8681
- console.error(
8682
- "[site-audit-routes] phase2-build-graph failed for job",
8683
- body.jobId,
8684
- err instanceof Error ? err.message : String(err)
8685
- );
8686
- }
8687
- );
8688
- return c.json({ jobId: body.jobId }, 202);
8689
- });
8690
- siteAuditApp.post("/classify", async (c) => {
8691
- const body = SiteAuditClassifyRequestSchema.parse(await c.req.json());
8692
- const service = makeSiteAuditService();
8693
- const job = await service.getJob(body.jobId);
8694
- if (!job) return c.json({ error: "Not found" }, 404);
8695
- if (job.user_id !== c.get("siteAuditUserId")) return c.json({ error: "Not found" }, 404);
8696
- void dispatchSiteAuditPhase(service, "phase3-classify", { jobId: body.jobId }).catch(
8697
- (err) => {
8698
- console.error(
8699
- "[site-audit-routes] phase3-classify failed for job",
8700
- body.jobId,
8701
- err instanceof Error ? err.message : String(err)
8702
- );
8703
- }
8704
- );
8705
- return c.json({ jobId: body.jobId }, 202);
8706
- });
8707
- siteAuditApp.post("/compare", async (c) => {
8708
- const body = SiteAuditCompareRequestSchema.parse(await c.req.json());
8709
- const service = makeSiteAuditService();
8710
- const job = await service.getJob(body.jobId);
8711
- if (!job) return c.json({ error: "Not found" }, 404);
8712
- if (job.user_id !== c.get("siteAuditUserId")) return c.json({ error: "Not found" }, 404);
8713
- void dispatchSiteAuditPhase(service, "phase4-compare", { jobId: body.jobId }).catch(
8714
- (err) => {
8715
- console.error(
8716
- "[site-audit-routes] phase4-compare failed for job",
8717
- body.jobId,
8718
- err instanceof Error ? err.message : String(err)
8719
- );
8720
- }
8721
- );
8722
- return c.json({ jobId: body.jobId }, 202);
8723
- });
8724
- siteAuditApp.post("/score", async (c) => {
8725
- const body = SiteAuditScoreRequestSchema.parse(await c.req.json());
8726
- const service = makeSiteAuditService();
8727
- const job = await service.getJob(body.jobId);
8728
- if (!job) return c.json({ error: "Not found" }, 404);
8729
- if (job.user_id !== c.get("siteAuditUserId")) return c.json({ error: "Not found" }, 404);
8730
- void dispatchSiteAuditPhase(service, "phase5-synthesize", {
8731
- jobId: body.jobId,
8732
- scoringOnly: true,
8733
- reportOnly: false
8734
- }).catch((err) => {
8735
- console.error(
8736
- "[site-audit-routes] phase5-synthesize(scoringOnly) failed for job",
8737
- body.jobId,
8738
- err instanceof Error ? err.message : String(err)
8739
- );
8740
- });
8741
- return c.json({ jobId: body.jobId }, 202);
8742
- });
8743
- siteAuditApp.post("/report", async (c) => {
8744
- const body = SiteAuditReportRequestSchema.parse(await c.req.json());
8745
- const service = makeSiteAuditService();
8746
- const job = await service.getJob(body.jobId);
8747
- if (!job) return c.json({ error: "Not found" }, 404);
8748
- if (job.user_id !== c.get("siteAuditUserId")) return c.json({ error: "Not found" }, 404);
8749
- void dispatchSiteAuditPhase(service, "phase5-synthesize", {
8750
- jobId: body.jobId,
8751
- scoringOnly: false,
8752
- reportOnly: true
8753
- }).catch((err) => {
8754
- console.error(
8755
- "[site-audit-routes] phase5-synthesize(reportOnly) failed for job",
8756
- body.jobId,
8757
- err instanceof Error ? err.message : String(err)
8758
- );
8759
- });
8760
- return c.json({ jobId: body.jobId }, 202);
8761
- });
8762
- siteAuditApp.get("/jobs/:id", async (c) => {
8763
- const jobId = c.req.param("id");
8764
- const userId = c.get("siteAuditUserId");
8765
- const service = makeSiteAuditService();
8766
- const job = await service.getJob(jobId);
8767
- if (!job) return c.json({ error: "Not found" }, 404);
8768
- if (job.user_id !== userId) return c.json({ error: "Not found" }, 404);
8769
- return c.json(job);
8770
- });
8771
- siteAuditApp.get("/jobs", async (c) => {
8772
- const userId = c.get("siteAuditUserId");
8773
- const service = makeSiteAuditService();
8774
- const jobs = await service.getJobsForUser(userId);
8775
- return c.json(jobs);
8776
- });
9343
+ };
9344
+ cached = null;
9345
+ VercelBlobStore = class {
9346
+ constructor(token) {
9347
+ this.token = token;
9348
+ }
9349
+ token;
9350
+ kind = "vercel-blob";
9351
+ async put(key, data, contentType = "application/octet-stream") {
9352
+ const { put } = await import("@vercel/blob");
9353
+ const body = Buffer.isBuffer(data) ? data : Buffer.from(data);
9354
+ const result = await put(key, body, {
9355
+ access: "public",
9356
+ token: this.token,
9357
+ contentType,
9358
+ addRandomSuffix: true
9359
+ });
9360
+ return { key, url: result.url, bytes: byteLength(data), contentType };
9361
+ }
9362
+ };
8777
9363
  }
8778
9364
  });
8779
9365
 
@@ -9023,6 +9609,792 @@ var init_rates = __esm({
9023
9609
  }
9024
9610
  });
9025
9611
 
9612
+ // src/api/site-extract-repository.ts
9613
+ function rowToJob(r) {
9614
+ return {
9615
+ id: String(r.id),
9616
+ userId: r.user_id != null ? Number(r.user_id) : null,
9617
+ status: r.status != null ? String(r.status) : "pending",
9618
+ startUrl: String(r.start_url ?? ""),
9619
+ options: r.options ? JSON.parse(String(r.options)) : {},
9620
+ totalUrls: Number(r.total_urls ?? 0),
9621
+ doneUrls: Number(r.done_urls ?? 0),
9622
+ artifacts: r.artifacts ? JSON.parse(String(r.artifacts)) : null,
9623
+ error: r.error != null ? String(r.error) : null,
9624
+ billedMc: r.billed_mc != null ? Number(r.billed_mc) : null,
9625
+ createdAt: String(r.created_at ?? ""),
9626
+ updatedAt: String(r.updated_at ?? "")
9627
+ };
9628
+ }
9629
+ async function createExtractJob(jobId, userId, startUrl, options) {
9630
+ const db = getDb();
9631
+ await db.execute({
9632
+ sql: `INSERT INTO site_extract_jobs (id, user_id, status, start_url, options, created_at, updated_at)
9633
+ VALUES (?, ?, 'pending', ?, ?, datetime('now'), datetime('now'))`,
9634
+ args: [jobId, userId, startUrl, JSON.stringify(options)]
9635
+ });
9636
+ }
9637
+ async function getExtractJob(jobId) {
9638
+ const db = getDb();
9639
+ const res = await db.execute({ sql: `SELECT * FROM site_extract_jobs WHERE id = ?`, args: [jobId] });
9640
+ return res.rows[0] ? rowToJob(res.rows[0]) : null;
9641
+ }
9642
+ async function setExtractJobTotal(jobId, totalUrls) {
9643
+ const db = getDb();
9644
+ await db.execute({
9645
+ sql: `UPDATE site_extract_jobs SET status = 'running', total_urls = ?, updated_at = datetime('now') WHERE id = ?`,
9646
+ args: [totalUrls, jobId]
9647
+ });
9648
+ }
9649
+ async function saveExtractPages(jobId, pages) {
9650
+ if (pages.length === 0) return;
9651
+ const db = getDb();
9652
+ await db.batch(
9653
+ pages.map((p) => ({
9654
+ sql: `INSERT OR REPLACE INTO site_extract_pages (job_id, url, page) VALUES (?, ?, ?)`,
9655
+ args: [jobId, p.url, JSON.stringify({ ...p, bodyMarkdown: "", schema: [] })]
9656
+ }))
9657
+ );
9658
+ await db.execute({
9659
+ sql: `UPDATE site_extract_jobs
9660
+ SET done_urls = (SELECT COUNT(*) FROM site_extract_pages WHERE job_id = ?), updated_at = datetime('now')
9661
+ WHERE id = ?`,
9662
+ args: [jobId, jobId]
9663
+ });
9664
+ }
9665
+ async function getExtractedPages(jobId) {
9666
+ const db = getDb();
9667
+ const res = await db.execute({ sql: `SELECT page FROM site_extract_pages WHERE job_id = ?`, args: [jobId] });
9668
+ return res.rows.map((r) => JSON.parse(String(r.page)));
9669
+ }
9670
+ async function countSuccessfulPages(jobId) {
9671
+ const db = getDb();
9672
+ const res = await db.execute({
9673
+ sql: `SELECT COUNT(*) AS n FROM site_extract_pages
9674
+ WHERE job_id = ? AND json_extract(page, '$.status') = 200 AND json_extract(page, '$.wordCount') > 0`,
9675
+ args: [jobId]
9676
+ });
9677
+ return Number(res.rows[0]?.n ?? 0);
9678
+ }
9679
+ async function completeExtractJob(jobId, artifacts) {
9680
+ const db = getDb();
9681
+ await db.execute({
9682
+ sql: `UPDATE site_extract_jobs SET status = 'complete', artifacts = ?, updated_at = datetime('now') WHERE id = ?`,
9683
+ args: [JSON.stringify(artifacts ?? []), jobId]
9684
+ });
9685
+ }
9686
+ async function failExtractJob(jobId, error) {
9687
+ const db = getDb();
9688
+ await db.execute({
9689
+ sql: `UPDATE site_extract_jobs SET status = 'failed', error = ?, updated_at = datetime('now') WHERE id = ?`,
9690
+ args: [sanitizeVendorName(error).slice(0, 2e3), jobId]
9691
+ });
9692
+ }
9693
+ async function settleExtractJob(jobId, userId, refundMc, netChargeMc, reference) {
9694
+ const db = getDb();
9695
+ const job = await getExtractJob(jobId);
9696
+ if (!job || job.billedMc != null) return;
9697
+ if (refundMc <= 0) {
9698
+ await db.execute({ sql: `UPDATE site_extract_jobs SET billed_mc = ?, updated_at = datetime('now') WHERE id = ? AND billed_mc IS NULL`, args: [Math.max(0, netChargeMc), jobId] });
9699
+ return;
9700
+ }
9701
+ await db.batch([
9702
+ { sql: "UPDATE users SET balance_mc = balance_mc + ? WHERE id = ?", args: [refundMc, userId] },
9703
+ { sql: "INSERT INTO ledger (user_id, amount_mc, operation, description) VALUES (?, ?, ?, ?)", args: [userId, refundMc, LedgerOperation.EXTRACT_SITE_REFUND, reference] },
9704
+ { sql: `UPDATE site_extract_jobs SET billed_mc = ?, updated_at = datetime('now') WHERE id = ? AND billed_mc IS NULL`, args: [Math.max(0, netChargeMc), jobId] }
9705
+ ]);
9706
+ }
9707
+ var init_site_extract_repository = __esm({
9708
+ "src/api/site-extract-repository.ts"() {
9709
+ "use strict";
9710
+ init_db();
9711
+ init_errors();
9712
+ init_rates();
9713
+ }
9714
+ });
9715
+
9716
+ // src/inngest/functions/site-extract.ts
9717
+ function chunk(items, size) {
9718
+ const out = [];
9719
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
9720
+ return out;
9721
+ }
9722
+ function buildArtifacts(job, pages, branding, imageAudit) {
9723
+ const { edges, metrics } = buildLinkGraph(pages, job.startUrl);
9724
+ const issues = computeIssues(pages, metrics);
9725
+ let reportMd = renderIssueReport(job.startUrl, pages, issues, metrics);
9726
+ if (imageAudit) reportMd += `
9727
+
9728
+ ${renderImageSection(imageAudit)}`;
9729
+ const pageRows = pages.map((p) => {
9730
+ const { bodyMarkdown: _b, schema: _s, outlinks: _o, ...rest } = p;
9731
+ const m = metrics.get(p.url);
9732
+ return { ...rest, inlinks: m?.inlinks ?? 0, crawlDepth: m?.crawlDepth ?? null, orphan: m?.orphan ?? false };
9733
+ });
9734
+ const jsonl = (rows) => rows.map((r) => JSON.stringify(r)).join("\n");
9735
+ const files = [
9736
+ { name: "report.md", body: reportMd, type: "text/markdown" },
9737
+ { name: "issues.json", body: JSON.stringify(issues, null, 2), type: "application/json" },
9738
+ { name: "pages.jsonl", body: jsonl(pageRows), type: "application/x-ndjson" },
9739
+ { name: "links.jsonl", body: jsonl(edges), type: "application/x-ndjson" },
9740
+ { name: "link-metrics.jsonl", body: jsonl([...metrics.values()]), type: "application/x-ndjson" }
9741
+ ];
9742
+ if (imageAudit) {
9743
+ files.push({ name: "images.jsonl", body: jsonl(imageAudit.rows), type: "application/x-ndjson" });
9744
+ files.push({ name: "images-summary.json", body: JSON.stringify(imageAudit.summary, null, 2), type: "application/json" });
9745
+ }
9746
+ if (branding) files.push({ name: "branding.json", body: JSON.stringify(branding, null, 2), type: "application/json" });
9747
+ return files;
9748
+ }
9749
+ var BATCH, siteExtractFn;
9750
+ var init_site_extract = __esm({
9751
+ "src/inngest/functions/site-extract.ts"() {
9752
+ "use strict";
9753
+ init_client();
9754
+ init_site_extractor();
9755
+ init_seo_link_graph();
9756
+ init_seo_issues();
9757
+ init_image_audit();
9758
+ init_blob_store();
9759
+ init_screenshot();
9760
+ init_browser_service_env();
9761
+ init_rates();
9762
+ init_site_extract_repository();
9763
+ BATCH = 30;
9764
+ siteExtractFn = inngest.createFunction(
9765
+ {
9766
+ id: "site-extract",
9767
+ retries: 2,
9768
+ triggers: [{ event: "mcp-scraper/extract.requested" }],
9769
+ onFailure: async ({ event }) => {
9770
+ const jobId = event?.data?.event?.data?.jobId;
9771
+ if (!jobId) return;
9772
+ const current = await getExtractJob(jobId);
9773
+ if (current && current.userId != null && current.billedMc == null) {
9774
+ const heldMc = Number(current.options.heldMc ?? 0);
9775
+ await settleExtractJob(jobId, current.userId, heldMc, 0, "crawl failed refund").catch(() => {
9776
+ });
9777
+ }
9778
+ await failExtractJob(jobId, String(event?.data?.error?.message ?? "crawl failed")).catch(() => {
9779
+ });
9780
+ }
9781
+ },
9782
+ async ({ event, step }) => {
9783
+ const jobId = event.data.jobId;
9784
+ const job = await step.run("load-job", () => getExtractJob(jobId));
9785
+ if (!job) return { jobId, status: "missing" };
9786
+ const key = browserServiceApiKey();
9787
+ if (!key) throw new Error("browser service key not configured");
9788
+ const maxPages = Number(job.options.maxPages ?? 1e4);
9789
+ const concurrency = Number(job.options.concurrency ?? 1);
9790
+ const urlsPerBrowser = Number(job.options.urlsPerBrowser ?? job.options.rotateProxyEvery ?? 10);
9791
+ const urls = await step.run("discover", async () => {
9792
+ const discovered = await discoverUrls(job.startUrl, maxPages, key);
9793
+ await setExtractJobTotal(jobId, discovered.length);
9794
+ return discovered;
9795
+ });
9796
+ const batches = chunk(urls, BATCH);
9797
+ for (let i = 0; i < batches.length; i++) {
9798
+ await step.run(`crawl-batch-${i}`, async () => {
9799
+ const pages = await extractPagesRotating(batches[i], { kernelApiKey: key, concurrency, urlsPerBrowser });
9800
+ await saveExtractPages(jobId, pages);
9801
+ return pages.length;
9802
+ });
9803
+ }
9804
+ const branding = Array.isArray(job.options.formats) && job.options.formats.includes("branding") ? await step.run("branding", () => extractBranding(job.startUrl, key).catch(() => null)) : null;
9805
+ const imageAudit = Array.isArray(job.options.formats) && job.options.formats.includes("images") ? await step.run("image-audit", async () => {
9806
+ const pages = await getExtractedPages(jobId);
9807
+ return auditImages(pages, { concurrency: 12, timeoutMs: 12e3 });
9808
+ }) : null;
9809
+ const artifacts = await step.run("finalize", async () => {
9810
+ const pages = await getExtractedPages(jobId);
9811
+ const store = getBlobStore();
9812
+ const files = buildArtifacts(job, pages, branding, imageAudit);
9813
+ const stored = [];
9814
+ for (const f of files) stored.push(await store.put(`extract/${jobId}/${f.name}`, f.body, f.type));
9815
+ await completeExtractJob(jobId, stored);
9816
+ return stored;
9817
+ });
9818
+ await step.run("settle", async () => {
9819
+ const current = await getExtractJob(jobId);
9820
+ if (!current || current.billedMc != null || current.userId == null) return;
9821
+ const heldMc = Number(current.options.heldMc ?? 0);
9822
+ const successful = await countSuccessfulPages(jobId);
9823
+ const usedMc = Math.min(successful * MC_COSTS.page_scrape, heldMc);
9824
+ await settleExtractJob(jobId, current.userId, heldMc - usedMc, usedMc, new URL(job.startUrl).hostname);
9825
+ });
9826
+ return { jobId, status: "complete", artifacts };
9827
+ }
9828
+ );
9829
+ }
9830
+ });
9831
+
9832
+ // src/api/catalog-seed.ts
9833
+ var URL_PARAM, FORMATS_PARAM, CATALOG;
9834
+ var init_catalog_seed = __esm({
9835
+ "src/api/catalog-seed.ts"() {
9836
+ "use strict";
9837
+ URL_PARAM = { key: "url", label: "URL", type: "url", required: true, placeholder: "https://example.com" };
9838
+ FORMATS_PARAM = {
9839
+ key: "formats",
9840
+ label: "Formats",
9841
+ type: "multi_enum",
9842
+ default: ["markdown", "links"],
9843
+ enumValues: [
9844
+ { value: "markdown", label: "Markdown" },
9845
+ { value: "links", label: "Links" },
9846
+ { value: "json", label: "JSON / schema" },
9847
+ { value: "images", label: "Image links" },
9848
+ { value: "branding", label: "Branding" }
9849
+ ]
9850
+ };
9851
+ CATALOG = {
9852
+ groups: [
9853
+ {
9854
+ key: "youtube",
9855
+ label: "YouTube",
9856
+ icon: "youtube",
9857
+ tools: [
9858
+ {
9859
+ key: "youtube_harvest",
9860
+ label: "Search",
9861
+ kind: "simple",
9862
+ endpoint: "/youtube/harvest",
9863
+ mcpName: "youtube_harvest",
9864
+ tagline: "Find or list videos by topic or channel",
9865
+ params: [
9866
+ { key: "mode", label: "Mode", type: "enum", required: true, default: "search", enumValues: [{ value: "search", label: "Search" }, { value: "channel", label: "Channel" }] },
9867
+ { key: "query", label: "Query", type: "string", placeholder: "best local SEO 2026" },
9868
+ { key: "channelHandle", label: "Channel handle", type: "string", placeholder: "@mkbhd" },
9869
+ { key: "maxVideos", label: "Max videos", type: "number", min: 1, max: 500, default: 50, advanced: true }
9870
+ ],
9871
+ examples: ["Find YouTube videos about local SEO", "Harvest videos from @mkbhd"]
9872
+ },
9873
+ {
9874
+ key: "youtube_transcribe",
9875
+ label: "Transcribe",
9876
+ kind: "simple",
9877
+ endpoint: "/youtube/transcribe",
9878
+ mcpName: "youtube_transcribe",
9879
+ tagline: "Transcribe one video to text",
9880
+ params: [{ key: "url", label: "Video URL", type: "url", required: true, placeholder: "https://www.youtube.com/watch?v=\u2026" }],
9881
+ examples: ["Transcribe this YouTube URL"]
9882
+ }
9883
+ ]
9884
+ },
9885
+ {
9886
+ key: "facebook",
9887
+ label: "Facebook",
9888
+ icon: "facebook",
9889
+ tools: [
9890
+ {
9891
+ key: "facebook_ad_search",
9892
+ label: "Ad Search",
9893
+ kind: "simple",
9894
+ endpoint: "/facebook/search",
9895
+ mcpName: "facebook_ad_search",
9896
+ tagline: "Find ads in the Ad Library",
9897
+ params: [
9898
+ { key: "query", label: "Advertiser / query", type: "string", required: true },
9899
+ { key: "country", label: "Country", type: "string", default: "US" },
9900
+ { key: "maxResults", label: "Max results", type: "number", min: 1, max: 20, default: 10, advanced: true }
9901
+ ],
9902
+ examples: ["Find Facebook ads for this brand"]
9903
+ },
9904
+ {
9905
+ key: "facebook_video_transcribe",
9906
+ label: "Video Transcribe",
9907
+ kind: "simple",
9908
+ endpoint: "/facebook/video-transcribe",
9909
+ mcpName: "facebook_video_transcribe",
9910
+ tagline: "Transcribe a Facebook video/reel",
9911
+ params: [{ ...URL_PARAM, label: "Video URL", placeholder: "https://www.facebook.com/share/v/\u2026" }]
9912
+ },
9913
+ {
9914
+ key: "facebook_ad_transcribe",
9915
+ label: "Ad Transcribe",
9916
+ kind: "simple",
9917
+ endpoint: "/facebook/transcribe",
9918
+ mcpName: "facebook_ad_transcribe",
9919
+ tagline: "Transcribe an ad creative",
9920
+ params: [{ ...URL_PARAM, label: "Ad video URL" }]
9921
+ },
9922
+ {
9923
+ key: "facebook_page_intel",
9924
+ label: "Page Intel",
9925
+ kind: "simple",
9926
+ endpoint: "/facebook/page-intel",
9927
+ mcpName: "facebook_page_intel",
9928
+ tagline: "Advertiser page profile + ads",
9929
+ params: [{ key: "libraryId", label: "Page / Library ID or URL", type: "string", required: true }]
9930
+ }
9931
+ ]
9932
+ },
9933
+ {
9934
+ key: "instagram",
9935
+ label: "Instagram",
9936
+ icon: "instagram",
9937
+ tools: [
9938
+ {
9939
+ key: "instagram_profile_content",
9940
+ label: "Profile",
9941
+ kind: "simple",
9942
+ endpoint: "/instagram/profile-content",
9943
+ mcpName: "instagram_profile_content",
9944
+ tagline: "Inventory a profile grid",
9945
+ params: [
9946
+ { key: "url", label: "Profile URL or handle", type: "string", required: true, placeholder: "https://instagram.com/nasa" },
9947
+ { key: "maxItems", label: "Max items", type: "number", min: 1, max: 2e3, default: 50, advanced: true },
9948
+ { key: "maxScrolls", label: "Max scrolls", type: "number", min: 0, max: 250, default: 10, advanced: true }
9949
+ ]
9950
+ },
9951
+ {
9952
+ key: "instagram_media_download",
9953
+ label: "Post / Reel",
9954
+ kind: "simple",
9955
+ endpoint: "/instagram/media-download",
9956
+ mcpName: "instagram_media_download",
9957
+ tagline: "Download a post or reel (+ transcript)",
9958
+ params: [
9959
+ { ...URL_PARAM, label: "Post / reel URL", placeholder: "https://www.instagram.com/reel/\u2026" },
9960
+ { key: "includeTranscript", label: "Include transcript", type: "boolean", default: true }
9961
+ ]
9962
+ }
9963
+ ]
9964
+ },
9965
+ {
9966
+ key: "reddit",
9967
+ label: "Reddit",
9968
+ icon: "message-circle",
9969
+ tools: [
9970
+ {
9971
+ key: "reddit_browser",
9972
+ label: "Research (Browser)",
9973
+ kind: "simple",
9974
+ endpoint: "/agent",
9975
+ mcpName: "browser_open",
9976
+ tagline: "No dedicated tool yet \u2014 driven by the Browser agent",
9977
+ params: [{ key: "query", label: "What to research", type: "string", required: true, placeholder: "ICP pain points in [niche]" }]
9978
+ }
9979
+ ]
9980
+ },
9981
+ {
9982
+ key: "google_search",
9983
+ label: "Google Search",
9984
+ icon: "search",
9985
+ tools: [
9986
+ {
9987
+ key: "search_serp",
9988
+ label: "SERP",
9989
+ kind: "simple",
9990
+ endpoint: "/harvest/sync",
9991
+ mcpName: "search_serp",
9992
+ tagline: "Organic results only",
9993
+ params: [
9994
+ { key: "query", label: "Query", type: "string", required: true },
9995
+ { key: "location", label: "Location", type: "string", placeholder: "Denver, CO" }
9996
+ ],
9997
+ examples: ["Top restaurants in San Francisco"]
9998
+ },
9999
+ {
10000
+ key: "harvest_paa",
10001
+ label: "PAA + SERP Detail",
10002
+ kind: "simple",
10003
+ endpoint: "/harvest/sync",
10004
+ mcpName: "harvest_paa",
10005
+ tagline: "People-Also-Ask, AI Overview, full SERP features",
10006
+ params: [
10007
+ { key: "query", label: "Query", type: "string", required: true },
10008
+ { key: "maxQuestions", label: "Max questions", type: "number", min: 1, max: 200, default: 30, advanced: true },
10009
+ { key: "location", label: "Location", type: "string" }
10010
+ ]
10011
+ }
10012
+ ]
10013
+ },
10014
+ {
10015
+ key: "google_maps",
10016
+ label: "Google Maps",
10017
+ icon: "map-pin",
10018
+ tools: [
10019
+ {
10020
+ key: "maps_search",
10021
+ label: "Search",
10022
+ kind: "simple",
10023
+ endpoint: "/maps/search",
10024
+ mcpName: "maps_search",
10025
+ tagline: "Find local businesses",
10026
+ params: [
10027
+ { key: "query", label: "Query", type: "string", required: true, placeholder: "roofers" },
10028
+ { key: "location", label: "Location", type: "string", required: true, placeholder: "Denver, CO" },
10029
+ { key: "maxResults", label: "Max results", type: "number", min: 1, max: 20, default: 10, advanced: true }
10030
+ ]
10031
+ },
10032
+ {
10033
+ key: "maps_place_intel",
10034
+ label: "Place Intel",
10035
+ kind: "simple",
10036
+ endpoint: "/maps/place",
10037
+ mcpName: "maps_place_intel",
10038
+ tagline: "One place: details + reviews",
10039
+ params: [
10040
+ { key: "businessName", label: "Business name", type: "string", required: true },
10041
+ { key: "location", label: "Location", type: "string", required: true },
10042
+ { key: "includeReviews", label: "Include reviews", type: "boolean", default: false },
10043
+ { key: "maxReviews", label: "Max reviews", type: "number", min: 1, max: 500, default: 50, advanced: true }
10044
+ ]
10045
+ }
10046
+ ]
10047
+ },
10048
+ {
10049
+ key: "websites",
10050
+ label: "Websites",
10051
+ icon: "globe",
10052
+ tools: [
10053
+ {
10054
+ key: "scrape",
10055
+ label: "Scrape",
10056
+ kind: "modal",
10057
+ endpoint: "/extract-url",
10058
+ mcpName: "extract_url",
10059
+ tagline: "Headless single or bulk page extraction",
10060
+ modes: [
10061
+ { groupKey: "scope", groupLabel: "Scope", optionKey: "single", optionLabel: "Headless Single", isDefault: true, endpointOverride: "/extract-url", mcpNameOverride: "extract_url" },
10062
+ { groupKey: "scope", groupLabel: "Scope", optionKey: "bulk", optionLabel: "Headless Bulk", endpointOverride: "/extract-site", mcpNameOverride: "extract_site", paramOverrides: { rotateProxies: true } }
10063
+ ],
10064
+ params: [
10065
+ URL_PARAM,
10066
+ FORMATS_PARAM,
10067
+ { key: "maxPages", label: "Max pages", type: "number", min: 1, max: 1e4, default: 100, advanced: true, sourceKey: "scope:bulk" },
10068
+ { key: "screenshot", label: "Screenshot", type: "boolean", advanced: true }
10069
+ ],
10070
+ examples: ["https://steenshoney.com/"]
10071
+ },
10072
+ {
10073
+ key: "map_site_urls",
10074
+ label: "Map",
10075
+ kind: "simple",
10076
+ endpoint: "/map-urls",
10077
+ mcpName: "map_site_urls",
10078
+ tagline: "URL inventory only",
10079
+ params: [URL_PARAM, { key: "maxUrls", label: "Max URLs", type: "number", min: 1, max: 1e4, default: 100, advanced: true }]
10080
+ },
10081
+ {
10082
+ key: "extract_site",
10083
+ label: "Crawl",
10084
+ kind: "simple",
10085
+ endpoint: "/extract-site",
10086
+ mcpName: "extract_site",
10087
+ tagline: "Map + bulk scrape (content)",
10088
+ params: [
10089
+ URL_PARAM,
10090
+ { key: "maxPages", label: "Max pages", type: "number", min: 1, max: 1e4, default: 100 },
10091
+ FORMATS_PARAM,
10092
+ { key: "rotateProxies", label: "Rotate proxies (blocked sites)", type: "boolean", default: true },
10093
+ { key: "background", label: "Run in background", type: "boolean", default: true, advanced: true }
10094
+ ],
10095
+ examples: ["https://steenshoney.com/ (full-site content crawl)"]
10096
+ },
10097
+ {
10098
+ key: "audit_site",
10099
+ label: "Audit",
10100
+ kind: "simple",
10101
+ endpoint: "/extract-site",
10102
+ mcpName: "audit_site",
10103
+ tagline: "Technical SEO audit (Screaming-Frog style)",
10104
+ params: [
10105
+ URL_PARAM,
10106
+ { key: "maxPages", label: "Max pages", type: "number", min: 1, max: 1e4, default: 100 },
10107
+ { key: "rotateProxies", label: "Rotate proxies (blocked sites)", type: "boolean", default: true }
10108
+ ],
10109
+ examples: ["https://steenshoney.com/ (issues + link graph + image audit)"]
10110
+ }
10111
+ ]
10112
+ },
10113
+ {
10114
+ key: "browser",
10115
+ label: "Browser",
10116
+ icon: "monitor",
10117
+ tools: [
10118
+ {
10119
+ key: "browser_agent",
10120
+ label: "Browser Agent",
10121
+ kind: "simple",
10122
+ endpoint: "/agent",
10123
+ mcpName: "browser_open",
10124
+ tagline: "Drive a live browser (open, navigate, read, click)",
10125
+ params: [
10126
+ { key: "url", label: "Start URL", type: "url" },
10127
+ { key: "profile", label: "Use Chrome profile", type: "string", placeholder: "saved profile name", advanced: true }
10128
+ ]
10129
+ },
10130
+ {
10131
+ key: "query_fanout_workflow",
10132
+ label: "AI Visibility (Fan-out)",
10133
+ kind: "simple",
10134
+ mcpName: "query_fanout_workflow",
10135
+ tagline: "Capture what ChatGPT/Claude search & cite (AEO)",
10136
+ params: [
10137
+ { key: "prompt", label: "Prompt", type: "string" },
10138
+ { key: "first_party_domain", label: "Your domain", type: "string", advanced: true },
10139
+ { key: "export", label: "Export artifacts", type: "boolean", advanced: true }
10140
+ ]
10141
+ }
10142
+ ]
10143
+ },
10144
+ {
10145
+ key: "workflows",
10146
+ label: "Workflows",
10147
+ icon: "workflow",
10148
+ tools: [
10149
+ {
10150
+ key: "directory_workflow",
10151
+ label: "Directory",
10152
+ kind: "simple",
10153
+ endpoint: "/directory/run",
10154
+ mcpName: "directory_workflow",
10155
+ isSpecial: true,
10156
+ tagline: "Build a local business directory",
10157
+ params: [
10158
+ { key: "query", label: "Business type", type: "string", required: true, placeholder: "roofers" },
10159
+ { key: "state", label: "State", type: "string", required: true, placeholder: "TN" },
10160
+ { key: "minPopulation", label: "Min city population", type: "number", default: 1e5, advanced: true }
10161
+ ]
10162
+ },
10163
+ {
10164
+ key: "rank_tracker_workflow",
10165
+ label: "Rank Tracker",
10166
+ kind: "simple",
10167
+ mcpName: "rank_tracker_workflow",
10168
+ isSpecial: true,
10169
+ tagline: "Generate a rank-tracker blueprint",
10170
+ params: [{ key: "domain", label: "Domain", type: "string", required: true }]
10171
+ },
10172
+ {
10173
+ key: "deep_research_workflow",
10174
+ label: "Deep Research",
10175
+ kind: "simple",
10176
+ endpoint: "/workflows/run",
10177
+ mcpName: "workflow_run",
10178
+ isSpecial: true,
10179
+ tagline: "Multi-source, fact-checked research report",
10180
+ params: [{ key: "question", label: "Question", type: "string", required: true }]
10181
+ }
10182
+ ]
10183
+ }
10184
+ ]
10185
+ };
10186
+ }
10187
+ });
10188
+
10189
+ // src/api/site-audit-middleware.ts
10190
+ var import_factory2, siteAuditAuth;
10191
+ var init_site_audit_middleware = __esm({
10192
+ "src/api/site-audit-middleware.ts"() {
10193
+ "use strict";
10194
+ import_factory2 = require("hono/factory");
10195
+ init_db();
10196
+ siteAuditAuth = (0, import_factory2.createMiddleware)(async (c, next) => {
10197
+ const apiKey = c.req.header("x-api-key");
10198
+ if (apiKey) {
10199
+ const user = await getUserByApiKey(apiKey);
10200
+ if (!user) return c.json({ error: "Unauthorized" }, 401);
10201
+ c.set("siteAuditUserId", user.id);
10202
+ return next();
10203
+ }
10204
+ return c.json({ error: "Unauthorized" }, 401);
10205
+ });
10206
+ }
10207
+ });
10208
+
10209
+ // src/api/site-audit-routes.ts
10210
+ function isPathInside(root, target) {
10211
+ const relative = import_node_path3.default.relative(root, target);
10212
+ return relative === "" || !relative.startsWith("..") && !import_node_path3.default.isAbsolute(relative);
10213
+ }
10214
+ var import_node_path3, import_node_os3, import_hono, import_zod11, siteAuditApp;
10215
+ var init_site_audit_routes = __esm({
10216
+ "src/api/site-audit-routes.ts"() {
10217
+ "use strict";
10218
+ import_node_path3 = __toESM(require("path"), 1);
10219
+ import_node_os3 = __toESM(require("os"), 1);
10220
+ import_hono = require("hono");
10221
+ import_zod11 = require("zod");
10222
+ init_site_audit_middleware();
10223
+ init_factory();
10224
+ init_phases();
10225
+ init_schemas();
10226
+ siteAuditApp = new import_hono.Hono();
10227
+ siteAuditApp.onError((err, c) => {
10228
+ if (err instanceof import_zod11.ZodError) {
10229
+ return c.json({ error: "Invalid request body", issues: err.issues }, 400);
10230
+ }
10231
+ const msg = err instanceof Error ? err.message : String(err);
10232
+ return c.json({ error: msg }, 500);
10233
+ });
10234
+ siteAuditApp.use("*", siteAuditAuth);
10235
+ siteAuditApp.post("/audit", async (c) => {
10236
+ const body = SiteAuditStartRequestSchema.parse(await c.req.json());
10237
+ const allowedRoot = import_node_path3.default.resolve(process.env["SESSION_ROOT"] ?? import_node_os3.default.tmpdir());
10238
+ const sessionPath = import_node_path3.default.resolve(body.sessionPath);
10239
+ if (!isPathInside(allowedRoot, sessionPath)) {
10240
+ return c.json({ error: "sessionPath must be inside the configured session root" }, 400);
10241
+ }
10242
+ const pathFields = [
10243
+ body.sessionPath,
10244
+ body.sfInternalPath,
10245
+ body.sfInlinksPath,
10246
+ body.sfOutlinksPath,
10247
+ body.sitemapPath,
10248
+ body.competitorDomainsCsvPath,
10249
+ body.visualSitemapsPath,
10250
+ body.gscPath,
10251
+ body.ga4Path,
10252
+ body.ahrefsPath
10253
+ ];
10254
+ for (const filePath of pathFields) {
10255
+ if (filePath != null && !isPathInside(allowedRoot, import_node_path3.default.resolve(filePath))) {
10256
+ return c.json({ error: "Path traversal attempt" }, 400);
10257
+ }
10258
+ }
10259
+ const userId = c.get("siteAuditUserId");
10260
+ const service = makeSiteAuditService();
10261
+ const jobId = await service.startAudit(userId, body);
10262
+ void dispatchSiteAuditPhase(service, "phase1-ingest", { jobId, request: body }).catch(
10263
+ (err) => {
10264
+ console.error(
10265
+ "[site-audit-routes] phase1-ingest dispatch failed for job",
10266
+ jobId,
10267
+ err instanceof Error ? err.message : String(err)
10268
+ );
10269
+ }
10270
+ );
10271
+ return c.json({ jobId }, 202);
10272
+ });
10273
+ siteAuditApp.post("/ingest", async (c) => {
10274
+ const body = SiteAuditIngestRequestSchema.parse(await c.req.json());
10275
+ const service = makeSiteAuditService();
10276
+ const job = await service.getJob(body.jobId);
10277
+ if (!job) return c.json({ error: "Not found" }, 404);
10278
+ if (job.user_id !== c.get("siteAuditUserId")) return c.json({ error: "Not found" }, 404);
10279
+ void dispatchSiteAuditPhase(service, "phase1-ingest", {
10280
+ jobId: body.jobId,
10281
+ request: JSON.parse(job.request)
10282
+ }).catch((err) => {
10283
+ console.error(
10284
+ "[site-audit-routes] phase1-ingest re-dispatch failed for job",
10285
+ body.jobId,
10286
+ err instanceof Error ? err.message : String(err)
10287
+ );
10288
+ });
10289
+ return c.json({ jobId: body.jobId }, 202);
10290
+ });
10291
+ siteAuditApp.post("/build-graph", async (c) => {
10292
+ const body = SiteAuditBuildGraphRequestSchema.parse(await c.req.json());
10293
+ const service = makeSiteAuditService();
10294
+ const job = await service.getJob(body.jobId);
10295
+ if (!job) return c.json({ error: "Not found" }, 404);
10296
+ if (job.user_id !== c.get("siteAuditUserId")) return c.json({ error: "Not found" }, 404);
10297
+ void dispatchSiteAuditPhase(service, "phase2-build-graph", { jobId: body.jobId }).catch(
10298
+ (err) => {
10299
+ console.error(
10300
+ "[site-audit-routes] phase2-build-graph failed for job",
10301
+ body.jobId,
10302
+ err instanceof Error ? err.message : String(err)
10303
+ );
10304
+ }
10305
+ );
10306
+ return c.json({ jobId: body.jobId }, 202);
10307
+ });
10308
+ siteAuditApp.post("/classify", async (c) => {
10309
+ const body = SiteAuditClassifyRequestSchema.parse(await c.req.json());
10310
+ const service = makeSiteAuditService();
10311
+ const job = await service.getJob(body.jobId);
10312
+ if (!job) return c.json({ error: "Not found" }, 404);
10313
+ if (job.user_id !== c.get("siteAuditUserId")) return c.json({ error: "Not found" }, 404);
10314
+ void dispatchSiteAuditPhase(service, "phase3-classify", { jobId: body.jobId }).catch(
10315
+ (err) => {
10316
+ console.error(
10317
+ "[site-audit-routes] phase3-classify failed for job",
10318
+ body.jobId,
10319
+ err instanceof Error ? err.message : String(err)
10320
+ );
10321
+ }
10322
+ );
10323
+ return c.json({ jobId: body.jobId }, 202);
10324
+ });
10325
+ siteAuditApp.post("/compare", async (c) => {
10326
+ const body = SiteAuditCompareRequestSchema.parse(await c.req.json());
10327
+ const service = makeSiteAuditService();
10328
+ const job = await service.getJob(body.jobId);
10329
+ if (!job) return c.json({ error: "Not found" }, 404);
10330
+ if (job.user_id !== c.get("siteAuditUserId")) return c.json({ error: "Not found" }, 404);
10331
+ void dispatchSiteAuditPhase(service, "phase4-compare", { jobId: body.jobId }).catch(
10332
+ (err) => {
10333
+ console.error(
10334
+ "[site-audit-routes] phase4-compare failed for job",
10335
+ body.jobId,
10336
+ err instanceof Error ? err.message : String(err)
10337
+ );
10338
+ }
10339
+ );
10340
+ return c.json({ jobId: body.jobId }, 202);
10341
+ });
10342
+ siteAuditApp.post("/score", async (c) => {
10343
+ const body = SiteAuditScoreRequestSchema.parse(await c.req.json());
10344
+ const service = makeSiteAuditService();
10345
+ const job = await service.getJob(body.jobId);
10346
+ if (!job) return c.json({ error: "Not found" }, 404);
10347
+ if (job.user_id !== c.get("siteAuditUserId")) return c.json({ error: "Not found" }, 404);
10348
+ void dispatchSiteAuditPhase(service, "phase5-synthesize", {
10349
+ jobId: body.jobId,
10350
+ scoringOnly: true,
10351
+ reportOnly: false
10352
+ }).catch((err) => {
10353
+ console.error(
10354
+ "[site-audit-routes] phase5-synthesize(scoringOnly) failed for job",
10355
+ body.jobId,
10356
+ err instanceof Error ? err.message : String(err)
10357
+ );
10358
+ });
10359
+ return c.json({ jobId: body.jobId }, 202);
10360
+ });
10361
+ siteAuditApp.post("/report", async (c) => {
10362
+ const body = SiteAuditReportRequestSchema.parse(await c.req.json());
10363
+ const service = makeSiteAuditService();
10364
+ const job = await service.getJob(body.jobId);
10365
+ if (!job) return c.json({ error: "Not found" }, 404);
10366
+ if (job.user_id !== c.get("siteAuditUserId")) return c.json({ error: "Not found" }, 404);
10367
+ void dispatchSiteAuditPhase(service, "phase5-synthesize", {
10368
+ jobId: body.jobId,
10369
+ scoringOnly: false,
10370
+ reportOnly: true
10371
+ }).catch((err) => {
10372
+ console.error(
10373
+ "[site-audit-routes] phase5-synthesize(reportOnly) failed for job",
10374
+ body.jobId,
10375
+ err instanceof Error ? err.message : String(err)
10376
+ );
10377
+ });
10378
+ return c.json({ jobId: body.jobId }, 202);
10379
+ });
10380
+ siteAuditApp.get("/jobs/:id", async (c) => {
10381
+ const jobId = c.req.param("id");
10382
+ const userId = c.get("siteAuditUserId");
10383
+ const service = makeSiteAuditService();
10384
+ const job = await service.getJob(jobId);
10385
+ if (!job) return c.json({ error: "Not found" }, 404);
10386
+ if (job.user_id !== userId) return c.json({ error: "Not found" }, 404);
10387
+ return c.json(job);
10388
+ });
10389
+ siteAuditApp.get("/jobs", async (c) => {
10390
+ const userId = c.get("siteAuditUserId");
10391
+ const service = makeSiteAuditService();
10392
+ const jobs = await service.getJobsForUser(userId);
10393
+ return c.json(jobs);
10394
+ });
10395
+ }
10396
+ });
10397
+
9026
10398
  // src/youtube/schemas.ts
9027
10399
  var import_zod12, YouTubeHarvestOptionsSchema;
9028
10400
  var init_schemas2 = __esm({
@@ -9184,7 +10556,7 @@ function rankCheckContextOptions(config) {
9184
10556
  ...config.hasTouch !== void 0 ? { hasTouch: config.hasTouch } : {}
9185
10557
  };
9186
10558
  }
9187
- async function withTimeout(promise, timeoutMs, label) {
10559
+ async function withTimeout2(promise, timeoutMs, label) {
9188
10560
  let timeout;
9189
10561
  try {
9190
10562
  return await Promise.race([
@@ -9224,14 +10596,14 @@ function buildYouTubeChannelVideosUrl(channelInput) {
9224
10596
  }
9225
10597
  return `https://www.youtube.com/${handle}/videos`;
9226
10598
  }
9227
- var import_playwright_extra, import_puppeteer_extra_plugin_stealth, import_playwright3, import_sdk4, DESKTOP_USER_AGENT, MOBILE_USER_AGENT, DEFAULT_KERNEL_BROWSER_TIMEOUT_SECONDS, KERNEL_BROWSER_CLOSE_TIMEOUT_MS, KERNEL_SESSION_DELETE_TIMEOUT_MS, DEFAULT_SERP_CHALLENGE_SOLVE_WAIT_MS, SERP_CHALLENGE_SOLVE_POLL_MS, BrowserDriver;
10599
+ var import_playwright_extra, import_puppeteer_extra_plugin_stealth, import_playwright4, import_sdk5, DESKTOP_USER_AGENT, MOBILE_USER_AGENT, DEFAULT_KERNEL_BROWSER_TIMEOUT_SECONDS, KERNEL_BROWSER_CLOSE_TIMEOUT_MS, KERNEL_SESSION_DELETE_TIMEOUT_MS, DEFAULT_SERP_CHALLENGE_SOLVE_WAIT_MS, SERP_CHALLENGE_SOLVE_POLL_MS, BrowserDriver;
9228
10600
  var init_BrowserDriver = __esm({
9229
10601
  "src/driver/BrowserDriver.ts"() {
9230
10602
  "use strict";
9231
10603
  import_playwright_extra = require("playwright-extra");
9232
10604
  import_puppeteer_extra_plugin_stealth = __toESM(require("puppeteer-extra-plugin-stealth"), 1);
9233
- import_playwright3 = require("playwright");
9234
- import_sdk4 = __toESM(require("@onkernel/sdk"), 1);
10605
+ import_playwright4 = require("playwright");
10606
+ import_sdk5 = __toESM(require("@onkernel/sdk"), 1);
9235
10607
  init_selectors();
9236
10608
  init_errors();
9237
10609
  import_playwright_extra.chromium.use((0, import_puppeteer_extra_plugin_stealth.default)());
@@ -9274,7 +10646,7 @@ var init_BrowserDriver = __esm({
9274
10646
  serpNavigation: null
9275
10647
  };
9276
10648
  if (config.kernelApiKey) {
9277
- this.kernelClient = new import_sdk4.default({ apiKey: config.kernelApiKey });
10649
+ this.kernelClient = new import_sdk5.default({ apiKey: config.kernelApiKey });
9278
10650
  const timeoutSeconds = positiveIntFromEnv("KERNEL_BROWSER_TIMEOUT_SECONDS", DEFAULT_KERNEL_BROWSER_TIMEOUT_SECONDS);
9279
10651
  if (config.kernelProfileName && config.kernelProfileSaveChanges === true) {
9280
10652
  await ensureKernelProfile(this.kernelClient, config.kernelProfileName);
@@ -9295,7 +10667,7 @@ var init_BrowserDriver = __esm({
9295
10667
  let defaultProxyDisableError = null;
9296
10668
  if (proxyMode === "none") {
9297
10669
  try {
9298
- await withTimeout(
10670
+ await withTimeout2(
9299
10671
  this.kernelClient.browsers.update(this.kernelSessionId, { disable_default_proxy: true }),
9300
10672
  5e3,
9301
10673
  `Kernel session ${this.kernelSessionId} disable default proxy`
@@ -9338,7 +10710,7 @@ var init_BrowserDriver = __esm({
9338
10710
  if (this.debugEnabled) {
9339
10711
  await this.populateKernelRetrieveDebug(kernelDebug, config.kernelProxyId);
9340
10712
  }
9341
- this.browser = await import_playwright3.chromium.connectOverCDP(kernelBrowser.cdp_ws_url);
10713
+ this.browser = await import_playwright4.chromium.connectOverCDP(kernelBrowser.cdp_ws_url);
9342
10714
  this.context = await this.browser.newContext(rankCheckContextOptions(config));
9343
10715
  await this.installEsbuildHelperShims(this.context);
9344
10716
  this.page = await this.context.newPage();
@@ -9375,7 +10747,7 @@ var init_BrowserDriver = __esm({
9375
10747
  async populateKernelRetrieveDebug(kernelDebug, requestedProxyId) {
9376
10748
  if (!this.kernelClient || !this.kernelSessionId) return;
9377
10749
  try {
9378
- const retrieved = await withTimeout(
10750
+ const retrieved = await withTimeout2(
9379
10751
  this.kernelClient.browsers.retrieve(this.kernelSessionId),
9380
10752
  5e3,
9381
10753
  `Kernel session ${this.kernelSessionId} retrieve`
@@ -9433,7 +10805,7 @@ var init_BrowserDriver = __esm({
9433
10805
  error: null
9434
10806
  };
9435
10807
  }
9436
- await withTimeout(
10808
+ await withTimeout2(
9437
10809
  debugPage.goto("https://ipapi.co/json/", { waitUntil: "domcontentloaded", timeout: 7e3 }),
9438
10810
  8e3,
9439
10811
  "browser network location navigation"
@@ -9459,7 +10831,7 @@ var init_BrowserDriver = __esm({
9459
10831
  }
9460
10832
  async loadJsonInDebugPage(debugPage, url) {
9461
10833
  try {
9462
- await withTimeout(
10834
+ await withTimeout2(
9463
10835
  debugPage.goto(url, { waitUntil: "domcontentloaded", timeout: 7e3 }),
9464
10836
  8e3,
9465
10837
  `browser network location navigation ${url}`
@@ -9654,12 +11026,12 @@ var init_BrowserDriver = __esm({
9654
11026
  event: "kernel_browser_delete_started",
9655
11027
  kernel_session_id: sessionId
9656
11028
  }));
9657
- const deleteSession = withTimeout(
11029
+ const deleteSession = withTimeout2(
9658
11030
  client2.browsers.deleteByID(sessionId),
9659
11031
  KERNEL_SESSION_DELETE_TIMEOUT_MS,
9660
11032
  `Kernel session ${sessionId} delete`
9661
11033
  );
9662
- const closeBrowser = withTimeout(
11034
+ const closeBrowser = withTimeout2(
9663
11035
  b.close(),
9664
11036
  KERNEL_BROWSER_CLOSE_TIMEOUT_MS,
9665
11037
  `Kernel browser ${sessionId} close`
@@ -10060,14 +11432,14 @@ var init_YouTubeExtractor = __esm({
10060
11432
  });
10061
11433
 
10062
11434
  // src/youtube/MP3Downloader.ts
10063
- var import_node_child_process, import_node_util, import_promises3, import_node_path3, execFileAsync, MP3Downloader;
11435
+ var import_node_child_process, import_node_util, import_promises3, import_node_path4, execFileAsync, MP3Downloader;
10064
11436
  var init_MP3Downloader = __esm({
10065
11437
  "src/youtube/MP3Downloader.ts"() {
10066
11438
  "use strict";
10067
11439
  import_node_child_process = require("child_process");
10068
11440
  import_node_util = require("util");
10069
11441
  import_promises3 = require("fs/promises");
10070
- import_node_path3 = __toESM(require("path"), 1);
11442
+ import_node_path4 = __toESM(require("path"), 1);
10071
11443
  execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
10072
11444
  MP3Downloader = class {
10073
11445
  constructor(outputDir, reporter, concurrency = 3) {
@@ -10089,7 +11461,7 @@ var init_MP3Downloader = __esm({
10089
11461
  }
10090
11462
  async downloadOne(video) {
10091
11463
  const url = video.url;
10092
- const outputTemplate = import_node_path3.default.join(this.outputDir, "%(title)s.%(ext)s");
11464
+ const outputTemplate = import_node_path4.default.join(this.outputDir, "%(title)s.%(ext)s");
10093
11465
  try {
10094
11466
  const { stdout } = await execFileAsync("yt-dlp", [
10095
11467
  "--extract-audio",
@@ -10167,24 +11539,24 @@ async function extractOnce(options) {
10167
11539
  return extractor.extract(options);
10168
11540
  }
10169
11541
  async function writeOutputs(result, outputDir) {
10170
- await import_node_fs2.promises.mkdir(outputDir, { recursive: true });
11542
+ await import_node_fs3.promises.mkdir(outputDir, { recursive: true });
10171
11543
  const slug = result.target.toLowerCase().replace(/\W+/g, "-").slice(0, 40);
10172
11544
  const ts = Date.now();
10173
- await import_node_fs2.promises.writeFile(
10174
- import_node_path4.default.join(outputDir, `${slug}-${ts}.json`),
11545
+ await import_node_fs3.promises.writeFile(
11546
+ import_node_path5.default.join(outputDir, `${slug}-${ts}.json`),
10175
11547
  JSON.stringify(result, null, 2),
10176
11548
  "utf8"
10177
11549
  );
10178
11550
  if (result.videos.length > 0) {
10179
- await import_node_fs2.promises.writeFile(
10180
- import_node_path4.default.join(outputDir, `${slug}-videos-${ts}.csv`),
11551
+ await import_node_fs3.promises.writeFile(
11552
+ import_node_path5.default.join(outputDir, `${slug}-videos-${ts}.csv`),
10181
11553
  import_papaparse.default.unparse(result.videos, { header: true }),
10182
11554
  "utf8"
10183
11555
  );
10184
11556
  }
10185
11557
  if (result.downloads.length > 0) {
10186
- await import_node_fs2.promises.writeFile(
10187
- import_node_path4.default.join(outputDir, `${slug}-downloads-${ts}.csv`),
11558
+ await import_node_fs3.promises.writeFile(
11559
+ import_node_path5.default.join(outputDir, `${slug}-downloads-${ts}.csv`),
10188
11560
  import_papaparse.default.unparse(result.downloads, { header: true }),
10189
11561
  "utf8"
10190
11562
  );
@@ -10224,13 +11596,13 @@ async function ytHarvest(rawOptions) {
10224
11596
  `YouTube blocked all ${MAX_ATTEMPTS} attempts. Try again in a few minutes.`
10225
11597
  );
10226
11598
  }
10227
- var import_node_fs2, import_node_path4, import_papaparse, MAX_ATTEMPTS, RETRY_DELAY_MS;
11599
+ var import_node_fs3, import_node_path5, import_papaparse, MAX_ATTEMPTS, RETRY_DELAY_MS;
10228
11600
  var init_youtube_harvest = __esm({
10229
11601
  "src/youtube/youtube-harvest.ts"() {
10230
11602
  "use strict";
10231
- import_node_fs2 = require("fs");
11603
+ import_node_fs3 = require("fs");
10232
11604
  init_browser_service_env();
10233
- import_node_path4 = __toESM(require("path"), 1);
11605
+ import_node_path5 = __toESM(require("path"), 1);
10234
11606
  import_papaparse = __toESM(require("papaparse"), 1);
10235
11607
  init_schemas2();
10236
11608
  init_BrowserDriver();
@@ -10428,11 +11800,11 @@ async function attemptKernelWhisper(videoId, kernelApiKey, kernelProxyId, falKey
10428
11800
  const audioInfo = await captureAudioViaMediaRecorder(page);
10429
11801
  await driver.close();
10430
11802
  if (!audioInfo || audioInfo.bytes.length < 1e4) return null;
10431
- import_client2.fal.config({ credentials: falKey });
11803
+ import_client3.fal.config({ credentials: falKey });
10432
11804
  const ext = audioInfo.mimeType.includes("ogg") ? "ogg" : "webm";
10433
11805
  const audioFile = new File([new Uint8Array(audioInfo.bytes)], `audio.${ext}`, { type: audioInfo.mimeType });
10434
- const uploadedUrl = await import_client2.fal.storage.upload(audioFile);
10435
- const result = await import_client2.fal.subscribe("fal-ai/wizper", {
11806
+ const uploadedUrl = await import_client3.fal.storage.upload(audioFile);
11807
+ const result = await import_client3.fal.subscribe("fal-ai/wizper", {
10436
11808
  input: { audio_url: uploadedUrl, task: "transcribe", language: "en" },
10437
11809
  logs: false,
10438
11810
  pollInterval: 3e3
@@ -10487,13 +11859,13 @@ async function fetchCaptions(videoId) {
10487
11859
  if (whisper) return finalizeCaptions(whisper);
10488
11860
  throw new Error(`No captions available for ${videoId} \u2014 all three tiers failed`);
10489
11861
  }
10490
- var import_client2, WHISPER_RECORD_SECONDS;
11862
+ var import_client3, WHISPER_RECORD_SECONDS;
10491
11863
  var init_CaptionFetcher = __esm({
10492
11864
  "src/youtube/CaptionFetcher.ts"() {
10493
11865
  "use strict";
10494
11866
  init_BrowserDriver();
10495
11867
  init_browser_service_env();
10496
- import_client2 = require("@fal-ai/client");
11868
+ import_client3 = require("@fal-ai/client");
10497
11869
  WHISPER_RECORD_SECONDS = 90;
10498
11870
  }
10499
11871
  });
@@ -10557,9 +11929,13 @@ var init_server_schemas = __esm({
10557
11929
  });
10558
11930
  ExtractSiteBodySchema = import_zod13.z.object({
10559
11931
  url: import_zod13.z.string().min(1, "url is required"),
10560
- maxPages: import_zod13.z.number().int().min(1).max(200).optional(),
11932
+ maxPages: import_zod13.z.number().int().min(1).max(1e4).optional(),
10561
11933
  browserFallback: import_zod13.z.boolean().optional(),
10562
- kernelFallback: import_zod13.z.boolean().optional()
11934
+ kernelFallback: import_zod13.z.boolean().optional(),
11935
+ rotateProxies: import_zod13.z.boolean().optional(),
11936
+ rotateProxyEvery: import_zod13.z.number().int().min(1).max(100).optional(),
11937
+ background: import_zod13.z.boolean().optional(),
11938
+ formats: import_zod13.z.array(import_zod13.z.enum(["markdown", "links", "json", "images", "branding"])).optional()
10563
11939
  });
10564
11940
  YoutubeHarvestBodySchema = import_zod13.z.object({
10565
11941
  mode: import_zod13.z.enum(["search", "channel"]),
@@ -10686,7 +12062,7 @@ async function acquireConcurrencyGate(user, operation, options = {}) {
10686
12062
  return { ok: true, lockId: null, active: await countActiveUsageForUser(user.id), limit, operation, reused: true };
10687
12063
  }
10688
12064
  await expireConcurrencyLocksForUser(user.id);
10689
- const lockId = `cl_${(0, import_node_crypto2.randomUUID)().replace(/-/g, "").slice(0, 24)}`;
12065
+ const lockId = `cl_${(0, import_node_crypto3.randomUUID)().replace(/-/g, "").slice(0, 24)}`;
10690
12066
  const res = await getDb().execute({
10691
12067
  sql: `INSERT INTO concurrency_locks (id, user_id, operation, status, expires_at, metadata)
10692
12068
  SELECT ?, ?, ?, 'active', datetime('now', ?), ?
@@ -10740,11 +12116,11 @@ async function extendConcurrencyGate(lockId, ttlSeconds = DEFAULT_LOCK_TTL_SECON
10740
12116
  args: [lockTtlModifier(ttlSeconds), lockId]
10741
12117
  });
10742
12118
  }
10743
- var import_node_crypto2, DEFAULT_LOCK_TTL_SECONDS, DEFAULT_RETRY_AFTER_SECONDS, MAX_LOCK_TTL_SECONDS, schemaReady;
12119
+ var import_node_crypto3, DEFAULT_LOCK_TTL_SECONDS, DEFAULT_RETRY_AFTER_SECONDS, MAX_LOCK_TTL_SECONDS, schemaReady;
10744
12120
  var init_concurrency_gates = __esm({
10745
12121
  "src/api/concurrency-gates.ts"() {
10746
12122
  "use strict";
10747
- import_node_crypto2 = require("crypto");
12123
+ import_node_crypto3 = require("crypto");
10748
12124
  init_db();
10749
12125
  init_rates();
10750
12126
  DEFAULT_LOCK_TTL_SECONDS = 15 * 60;
@@ -10768,10 +12144,10 @@ function buildTranscriptMarkdown(result) {
10768
12144
  if (result.chunks?.length) {
10769
12145
  lines.push("## Timestamped Segments");
10770
12146
  lines.push("");
10771
- for (const chunk of result.chunks) {
10772
- const start = fmtTs(chunk.timestamp[0]);
10773
- const end = fmtTs(chunk.timestamp[1]);
10774
- lines.push(`**[${start} \u2192 ${end}]** ${chunk.text.trim()}`);
12147
+ for (const chunk2 of result.chunks) {
12148
+ const start = fmtTs(chunk2.timestamp[0]);
12149
+ const end = fmtTs(chunk2.timestamp[1]);
12150
+ lines.push(`**[${start} \u2192 ${end}]** ${chunk2.text.trim()}`);
10775
12151
  lines.push("");
10776
12152
  }
10777
12153
  }
@@ -11409,20 +12785,20 @@ var init_FacebookAdExtractor = __esm({
11409
12785
  if (splitRe.lastIndex === m.index) splitRe.lastIndex++;
11410
12786
  }
11411
12787
  if (last < bodyText.length) adChunks.push(bodyText.slice(last));
11412
- const ads = adChunks.filter((c) => /Library ID[:\s]+\d{10,20}/i.test(c)).slice(0, maxAds).map((chunk) => {
11413
- const lidM = chunk.match(/Library ID[:\s]+([0-9]{10,20})/i);
12788
+ const ads = adChunks.filter((c) => /Library ID[:\s]+\d{10,20}/i.test(c)).slice(0, maxAds).map((chunk2) => {
12789
+ const lidM = chunk2.match(/Library ID[:\s]+([0-9]{10,20})/i);
11414
12790
  const libraryId = lidM ? lidM[1] : null;
11415
- const statusM = chunk.match(/^(Active|Inactive)/i);
12791
+ const statusM = chunk2.match(/^(Active|Inactive)/i);
11416
12792
  const status = statusM ? statusM[1] : null;
11417
- const startM = chunk.match(/Started running on\s+([A-Za-z]+\.?\s+\d{1,2},?\s*\d{4})/i);
12793
+ const startM = chunk2.match(/Started running on\s+([A-Za-z]+\.?\s+\d{1,2},?\s*\d{4})/i);
11418
12794
  const started = startM ? startM[1].replace(/\s+/g, " ").trim() : null;
11419
12795
  const visual = adVisuals[libraryId ?? ""] ?? { imageSrc: null, videoSrc: null, videoPoster: null };
11420
- const hasVideoText = /0:00\s*\/\s*0:00|\d+:\d+\s*\/\s*\d+:\d+/.test(chunk);
12796
+ const hasVideoText = /0:00\s*\/\s*0:00|\d+:\d+\s*\/\s*\d+:\d+/.test(chunk2);
11421
12797
  const creativeType = visual.videoSrc || hasVideoText ? "video" : visual.imageSrc ? "image" : "unknown";
11422
12798
  let domain = null;
11423
12799
  const domainRe = /\b([A-Z0-9]{2,}(?:\.[A-Z]{2,})+)\b/g;
11424
12800
  let dm;
11425
- while ((dm = domainRe.exec(chunk)) !== null) {
12801
+ while ((dm = domainRe.exec(chunk2)) !== null) {
11426
12802
  const c = dm[1];
11427
12803
  if (c.length >= 6 && c !== "LIBRARY") {
11428
12804
  domain = c;
@@ -11431,9 +12807,9 @@ var init_FacebookAdExtractor = __esm({
11431
12807
  }
11432
12808
  let primaryText = null;
11433
12809
  let headline = null;
11434
- const sponsoredIdx = chunk.search(/\bSponsored\b/i);
12810
+ const sponsoredIdx = chunk2.search(/\bSponsored\b/i);
11435
12811
  if (sponsoredIdx >= 0) {
11436
- let afterSponsored = chunk.slice(sponsoredIdx + "Sponsored".length).trim();
12812
+ let afterSponsored = chunk2.slice(sponsoredIdx + "Sponsored".length).trim();
11437
12813
  afterSponsored = afterSponsored.replace(/\s*\d*:?\d+\s*\/\s*\d*:?\d+.*$/, "");
11438
12814
  if (domain) {
11439
12815
  const di = afterSponsored.indexOf(domain);
@@ -11444,18 +12820,18 @@ var init_FacebookAdExtractor = __esm({
11444
12820
  }
11445
12821
  const ctaRe2 = new RegExp("\\b(" + CTA_LABELS.map((l) => l.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|") + ")\\b", "i");
11446
12822
  if (domain) {
11447
- const di = chunk.indexOf(domain);
12823
+ const di = chunk2.indexOf(domain);
11448
12824
  if (di >= 0) {
11449
- let afterDomain = chunk.slice(di + domain.length).trim();
12825
+ let afterDomain = chunk2.slice(di + domain.length).trim();
11450
12826
  const ctaMatch = afterDomain.search(ctaRe2);
11451
12827
  if (ctaMatch >= 0) afterDomain = afterDomain.slice(0, ctaMatch).trim();
11452
12828
  if (afterDomain.length > 3 && afterDomain.length < 120) headline = afterDomain;
11453
12829
  }
11454
12830
  }
11455
12831
  if (!headline) {
11456
- const ctaIdx = chunk.search(ctaRe2);
12832
+ const ctaIdx = chunk2.search(ctaRe2);
11457
12833
  if (ctaIdx > 0) {
11458
- const beforeCta = chunk.slice(0, ctaIdx).trimEnd();
12834
+ const beforeCta = chunk2.slice(0, ctaIdx).trimEnd();
11459
12835
  const sentenceEnd = Math.max(beforeCta.lastIndexOf(". "), beforeCta.lastIndexOf("! "), beforeCta.lastIndexOf("? "));
11460
12836
  if (sentenceEnd >= 0) {
11461
12837
  const candidate = beforeCta.slice(sentenceEnd + 2).trim();
@@ -11467,12 +12843,12 @@ var init_FacebookAdExtractor = __esm({
11467
12843
  }
11468
12844
  let cta = null;
11469
12845
  for (const lbl of CTA_LABELS) {
11470
- if (chunk.toLowerCase().includes(lbl.toLowerCase())) {
12846
+ if (chunk2.toLowerCase().includes(lbl.toLowerCase())) {
11471
12847
  cta = lbl;
11472
12848
  break;
11473
12849
  }
11474
12850
  }
11475
- const multiM = chunk.match(/(\d+)\s+ad[s]?\s+use[s]?\s+this/i);
12851
+ const multiM = chunk2.match(/(\d+)\s+ad[s]?\s+use[s]?\s+this/i);
11476
12852
  const clusterCount = multiM ? parseInt(multiM[1]) : null;
11477
12853
  return {
11478
12854
  libraryId,
@@ -11495,8 +12871,8 @@ var init_FacebookAdExtractor = __esm({
11495
12871
  const unknownCreativeCount = ads.filter((a) => a.creativeType === "unknown").length;
11496
12872
  const advertiserPageId = new URL(page.url()).searchParams.get("view_all_page_id") ?? null;
11497
12873
  const nameFreq = /* @__PURE__ */ new Map();
11498
- for (const chunk of adChunks) {
11499
- const detailsParts = chunk.split("See ad details ");
12874
+ for (const chunk2 of adChunks) {
12875
+ const detailsParts = chunk2.split("See ad details ");
11500
12876
  if (detailsParts.length < 2) continue;
11501
12877
  const sponsoredIdx = detailsParts[1].indexOf(" Sponsored");
11502
12878
  if (sponsoredIdx < 0) continue;
@@ -12221,7 +13597,7 @@ async function createFreshLocationProxy(kernel, options, target) {
12221
13597
  }
12222
13598
  async function deleteKernelProxyId(kernelApiKey, proxyId) {
12223
13599
  if (!kernelApiKey || !proxyId) return;
12224
- const kernel = new import_sdk5.default({ apiKey: kernelApiKey });
13600
+ const kernel = new import_sdk6.default({ apiKey: kernelApiKey });
12225
13601
  await kernel.proxies.delete(proxyId);
12226
13602
  }
12227
13603
  async function resolveKernelProxyId(options) {
@@ -12235,7 +13611,7 @@ async function resolveKernelProxyId(options) {
12235
13611
  if (!target || !options.kernelApiKey) {
12236
13612
  return resolution("configured_fallback", options.proxyMode, options.configuredKernelProxyId, target, target ? null : "location could not be normalized to a US city/state proxy target");
12237
13613
  }
12238
- const kernel = new import_sdk5.default({ apiKey: options.kernelApiKey });
13614
+ const kernel = new import_sdk6.default({ apiKey: options.kernelApiKey });
12239
13615
  try {
12240
13616
  const attemptIndex = options.attemptIndex ?? 0;
12241
13617
  if (options.fresh) {
@@ -12341,11 +13717,11 @@ async function resolveKernelProxyId(options) {
12341
13717
  return resolution("configured_fallback", options.proxyMode, options.configuredKernelProxyId, target, errorText2(err));
12342
13718
  }
12343
13719
  }
12344
- var import_sdk5, US_STATE_CODES, US_CITY_CENTER_ZIPS;
13720
+ var import_sdk6, US_STATE_CODES, US_CITY_CENTER_ZIPS;
12345
13721
  var init_kernel_proxy_resolver = __esm({
12346
13722
  "src/kernel-proxy-resolver.ts"() {
12347
13723
  "use strict";
12348
- import_sdk5 = __toESM(require("@onkernel/sdk"), 1);
13724
+ import_sdk6 = __toESM(require("@onkernel/sdk"), 1);
12349
13725
  init_uule();
12350
13726
  US_STATE_CODES = {
12351
13727
  alabama: "AL",
@@ -12561,7 +13937,7 @@ function transcriptMarkdown(title, text, chunks, durationMs) {
12561
13937
  }
12562
13938
  async function transcribeMediaUrl(mediaUrl, markdownTitle = "# Media Transcript") {
12563
13939
  const startMs = Date.now();
12564
- const result = await import_client3.fal.subscribe("fal-ai/wizper", {
13940
+ const result = await import_client4.fal.subscribe("fal-ai/wizper", {
12565
13941
  input: { audio_url: mediaUrl, task: "transcribe", language: "en" },
12566
13942
  logs: false,
12567
13943
  pollInterval: 3e3
@@ -12577,11 +13953,11 @@ async function transcribeMediaUrl(mediaUrl, markdownTitle = "# Media Transcript"
12577
13953
  markdown: transcriptMarkdown(markdownTitle, text, chunks, durationMs)
12578
13954
  };
12579
13955
  }
12580
- var import_client3;
13956
+ var import_client4;
12581
13957
  var init_media_transcription = __esm({
12582
13958
  "src/services/media-transcription.ts"() {
12583
13959
  "use strict";
12584
- import_client3 = require("@fal-ai/client");
13960
+ import_client4 = require("@fal-ai/client");
12585
13961
  }
12586
13962
  });
12587
13963
 
@@ -12624,7 +14000,7 @@ async function kernelLaunchOptsResidential() {
12624
14000
  }
12625
14001
  return { headless: true, kernelApiKey: browserServiceApiKey(), kernelProxyId: proxyId, viewport: { width: 1280, height: 900 }, locale: "en-US" };
12626
14002
  }
12627
- var import_hono4, import_zod16, import_client4, FacebookAdBodySchema, FacebookPageIntelBodySchema, FacebookTranscribeBodySchema, FacebookVideoTranscribeBodySchema, FacebookSearchBodySchema, FacebookMediaBodySchema, facebookAdApp, ALLOWED_MEDIA_HOSTS;
14003
+ var import_hono4, import_zod16, import_client5, FacebookAdBodySchema, FacebookPageIntelBodySchema, FacebookTranscribeBodySchema, FacebookVideoTranscribeBodySchema, FacebookSearchBodySchema, FacebookMediaBodySchema, facebookAdApp, ALLOWED_MEDIA_HOSTS;
12628
14004
  var init_facebook_ad_routes = __esm({
12629
14005
  "src/api/facebook-ad-routes.ts"() {
12630
14006
  "use strict";
@@ -12639,7 +14015,7 @@ var init_facebook_ad_routes = __esm({
12639
14015
  init_FacebookOrganicVideoExtractor();
12640
14016
  init_kernel_proxy_resolver();
12641
14017
  init_schemas3();
12642
- import_client4 = require("@fal-ai/client");
14018
+ import_client5 = require("@fal-ai/client");
12643
14019
  init_api_auth();
12644
14020
  init_url_utils();
12645
14021
  init_concurrency_gates();
@@ -12782,7 +14158,7 @@ var init_facebook_ad_routes = __esm({
12782
14158
  metadata: { videoUrl }
12783
14159
  });
12784
14160
  if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
12785
- import_client4.fal.config({ credentials: process.env.FAL_KEY });
14161
+ import_client5.fal.config({ credentials: process.env.FAL_KEY });
12786
14162
  let debited = false;
12787
14163
  try {
12788
14164
  const { ok, balance_mc } = await debitMc(fbUser.id, MC_COSTS.fb_transcribe, LedgerOperation.FB_TRANSCRIBE, videoUrl);
@@ -12821,7 +14197,7 @@ var init_facebook_ad_routes = __esm({
12821
14197
  metadata: { url: sourceUrl.href }
12822
14198
  });
12823
14199
  if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
12824
- import_client4.fal.config({ credentials: process.env.FAL_KEY });
14200
+ import_client5.fal.config({ credentials: process.env.FAL_KEY });
12825
14201
  const driver = new BrowserDriver();
12826
14202
  let debited = false;
12827
14203
  try {
@@ -12915,11 +14291,11 @@ var init_facebook_ad_routes = __esm({
12915
14291
  }
12916
14292
  if (last < bodyText.length) adChunks.push(bodyText.slice(last));
12917
14293
  const advertiserMap = /* @__PURE__ */ new Map();
12918
- for (const chunk of adChunks) {
12919
- const lidM = chunk.match(/Library ID[:\s]+([0-9]{10,20})/i);
14294
+ for (const chunk2 of adChunks) {
14295
+ const lidM = chunk2.match(/Library ID[:\s]+([0-9]{10,20})/i);
12920
14296
  if (!lidM) continue;
12921
14297
  const libraryId = lidM[1];
12922
- const detailsParts = chunk.split("See ad details ");
14298
+ const detailsParts = chunk2.split("See ad details ");
12923
14299
  if (detailsParts.length < 2) continue;
12924
14300
  const afterDetails = detailsParts[1];
12925
14301
  const sponsoredIdx = afterDetails.indexOf(" Sponsored");
@@ -13396,7 +14772,7 @@ async function resolveInstagramLaunch(body) {
13396
14772
  };
13397
14773
  }
13398
14774
  function outputBaseDir() {
13399
- return process.env.MCP_SCRAPER_OUTPUT_DIR?.trim() || (0, import_node_path5.join)((0, import_node_os3.homedir)(), "Downloads", "mcp-scraper");
14775
+ return process.env.MCP_SCRAPER_OUTPUT_DIR?.trim() || (0, import_node_path6.join)((0, import_node_os4.homedir)(), "Downloads", "mcp-scraper");
13400
14776
  }
13401
14777
  function safeFilePart(input) {
13402
14778
  return input.replace(/^https?:\/\//, "").replace(/[^a-z0-9._-]+/gi, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "instagram";
@@ -13404,8 +14780,8 @@ function safeFilePart(input) {
13404
14780
  function mediaOutputDir(shortcode, sourceUrl) {
13405
14781
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
13406
14782
  const slug = shortcode ? `instagram-${shortcode}` : safeFilePart(sourceUrl);
13407
- const dir = (0, import_node_path5.join)(outputBaseDir(), "instagram", `${stamp}-${slug}`);
13408
- (0, import_node_fs3.mkdirSync)(dir, { recursive: true });
14783
+ const dir = (0, import_node_path6.join)(outputBaseDir(), "instagram", `${stamp}-${slug}`);
14784
+ (0, import_node_fs4.mkdirSync)(dir, { recursive: true });
13409
14785
  return dir;
13410
14786
  }
13411
14787
  async function downloadToFile(url, destPath, referer) {
@@ -13418,10 +14794,10 @@ async function downloadToFile(url, destPath, referer) {
13418
14794
  });
13419
14795
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
13420
14796
  if (!res.body) throw new Error("Empty response body");
13421
- await (0, import_promises4.pipeline)(import_node_stream2.Readable.fromWeb(res.body), (0, import_node_fs3.createWriteStream)(destPath));
14797
+ await (0, import_promises4.pipeline)(import_node_stream2.Readable.fromWeb(res.body), (0, import_node_fs4.createWriteStream)(destPath));
13422
14798
  return {
13423
14799
  savedPath: destPath,
13424
- sizeBytes: (0, import_node_fs3.statSync)(destPath).size,
14800
+ sizeBytes: (0, import_node_fs4.statSync)(destPath).size,
13425
14801
  mimeType: res.headers.get("content-type")?.split(";")[0]?.trim() ?? null
13426
14802
  };
13427
14803
  }
@@ -13443,8 +14819,8 @@ function runFfmpegMux(videoPath, audioPath, outPath) {
13443
14819
  stdio: ["ignore", "ignore", "pipe"]
13444
14820
  });
13445
14821
  let stderr = "";
13446
- child.stderr.on("data", (chunk) => {
13447
- stderr += String(chunk);
14822
+ child.stderr.on("data", (chunk2) => {
14823
+ stderr += String(chunk2);
13448
14824
  });
13449
14825
  child.on("error", (err) => resolve({ ok: false, error: err.message }));
13450
14826
  child.on("close", (code) => resolve({ ok: code === 0, error: code === 0 ? null : stderr.trim() || `ffmpeg exited ${code}` }));
@@ -13456,15 +14832,15 @@ function trackFilename(track, index, shortcode) {
13456
14832
  const bitrate = track.bitrate ? `-${track.bitrate}` : "";
13457
14833
  return `${label}-${type}-${index}${bitrate}.mp4`;
13458
14834
  }
13459
- var import_hono5, import_zod17, import_node_fs3, import_node_os3, import_node_path5, import_node_stream2, import_promises4, import_node_child_process2, InstagramProfileContentBodySchema, InstagramMediaTypeSchema, InstagramMediaDownloadBodySchema, instagramApp;
14835
+ var import_hono5, import_zod17, import_node_fs4, import_node_os4, import_node_path6, import_node_stream2, import_promises4, import_node_child_process2, InstagramProfileContentBodySchema, InstagramMediaTypeSchema, InstagramMediaDownloadBodySchema, instagramApp;
13460
14836
  var init_instagram_routes = __esm({
13461
14837
  "src/api/instagram-routes.ts"() {
13462
14838
  "use strict";
13463
14839
  import_hono5 = require("hono");
13464
14840
  import_zod17 = require("zod");
13465
- import_node_fs3 = require("fs");
13466
- import_node_os3 = require("os");
13467
- import_node_path5 = require("path");
14841
+ import_node_fs4 = require("fs");
14842
+ import_node_os4 = require("os");
14843
+ import_node_path6 = require("path");
13468
14844
  import_node_stream2 = require("stream");
13469
14845
  import_promises4 = require("stream/promises");
13470
14846
  import_node_child_process2 = require("child_process");
@@ -13592,16 +14968,16 @@ var init_instagram_routes = __esm({
13592
14968
  let outputDir = null;
13593
14969
  if (body.downloadMedia) {
13594
14970
  outputDir = mediaOutputDir(extraction.shortcode, sourceUrl.href);
13595
- const textPath = (0, import_node_path5.join)(outputDir, `${extraction.shortcode ?? "instagram"}-text.txt`);
13596
- (0, import_node_fs3.writeFileSync)(textPath, [
14971
+ const textPath = (0, import_node_path6.join)(outputDir, `${extraction.shortcode ?? "instagram"}-text.txt`);
14972
+ (0, import_node_fs4.writeFileSync)(textPath, [
13597
14973
  `URL: ${extraction.pageUrl}`,
13598
14974
  extraction.caption ? `Caption: ${extraction.caption}` : "",
13599
14975
  "",
13600
14976
  extraction.bodyText
13601
14977
  ].filter(Boolean).join("\n"), "utf8");
13602
- downloads.push({ kind: "text", url: null, savedPath: textPath, sizeBytes: (0, import_node_fs3.statSync)(textPath).size, mimeType: "text/plain", error: null });
14978
+ downloads.push({ kind: "text", url: null, savedPath: textPath, sizeBytes: (0, import_node_fs4.statSync)(textPath).size, mimeType: "text/plain", error: null });
13603
14979
  if (mediaTypes.has("image") && extraction.imageUrl) {
13604
- const imagePathBase = (0, import_node_path5.join)(outputDir, `${extraction.shortcode ?? "instagram"}-image`);
14980
+ const imagePathBase = (0, import_node_path6.join)(outputDir, `${extraction.shortcode ?? "instagram"}-image`);
13605
14981
  try {
13606
14982
  const downloaded = await downloadToFile(extraction.imageUrl, imagePathBase, extraction.pageUrl);
13607
14983
  const ext = extFromMime(downloaded.mimeType, "jpg");
@@ -13610,7 +14986,7 @@ var init_instagram_routes = __esm({
13610
14986
  const { renameSync } = await import("fs");
13611
14987
  renameSync(downloaded.savedPath, finalPath);
13612
14988
  }
13613
- downloads.push({ kind: "image", url: extraction.imageUrl, savedPath: finalPath, sizeBytes: (0, import_node_fs3.statSync)(finalPath).size, mimeType: downloaded.mimeType, error: null });
14989
+ downloads.push({ kind: "image", url: extraction.imageUrl, savedPath: finalPath, sizeBytes: (0, import_node_fs4.statSync)(finalPath).size, mimeType: downloaded.mimeType, error: null });
13614
14990
  } catch (err) {
13615
14991
  downloads.push({ kind: "image", url: extraction.imageUrl, savedPath: null, sizeBytes: null, mimeType: null, error: err instanceof Error ? err.message : String(err) });
13616
14992
  }
@@ -13621,7 +14997,7 @@ var init_instagram_routes = __esm({
13621
14997
  if (track.streamType === "video" && !mediaTypes.has("video")) continue;
13622
14998
  if (track.streamType === "audio" && !mediaTypes.has("audio")) continue;
13623
14999
  const kind = track.streamType === "audio" ? "audio" : "video";
13624
- const target = (0, import_node_path5.join)(outputDir, trackFilename(track, index, extraction.shortcode));
15000
+ const target = (0, import_node_path6.join)(outputDir, trackFilename(track, index, extraction.shortcode));
13625
15001
  try {
13626
15002
  const downloaded = await downloadToFile(track.url, target, extraction.pageUrl);
13627
15003
  downloads.push({ kind, url: track.url, savedPath: downloaded.savedPath, sizeBytes: downloaded.sizeBytes, mimeType: downloaded.mimeType, error: null });
@@ -13631,10 +15007,10 @@ var init_instagram_routes = __esm({
13631
15007
  }
13632
15008
  }
13633
15009
  if (body.mux && mediaTypes.has("video") && mediaTypes.has("audio") && extraction.selectedVideoTrack && extraction.selectedAudioTrack && savedByUrl.has(extraction.selectedVideoTrack.url) && savedByUrl.has(extraction.selectedAudioTrack.url)) {
13634
- const outPath = (0, import_node_path5.join)(outputDir, `${extraction.shortcode ?? "instagram"}-muxed.mp4`);
15010
+ const outPath = (0, import_node_path6.join)(outputDir, `${extraction.shortcode ?? "instagram"}-muxed.mp4`);
13635
15011
  const muxed = await runFfmpegMux(savedByUrl.get(extraction.selectedVideoTrack.url), savedByUrl.get(extraction.selectedAudioTrack.url), outPath);
13636
15012
  if (muxed.ok) {
13637
- downloads.push({ kind: "muxed_video", url: null, savedPath: outPath, sizeBytes: (0, import_node_fs3.statSync)(outPath).size, mimeType: "video/mp4", error: null });
15013
+ downloads.push({ kind: "muxed_video", url: null, savedPath: outPath, sizeBytes: (0, import_node_fs4.statSync)(outPath).size, mimeType: "video/mp4", error: null });
13638
15014
  } else {
13639
15015
  warnings.push(`Mux skipped/failed: ${muxed.error}`);
13640
15016
  }
@@ -14669,13 +16045,13 @@ var init_maps_routes = __esm({
14669
16045
  });
14670
16046
 
14671
16047
  // src/mcp/workflow-catalog.ts
14672
- function normalize(value) {
16048
+ function normalize2(value) {
14673
16049
  return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
14674
16050
  }
14675
16051
  function suggestWorkflowRecipes(goal, limit = 3) {
14676
- const normalized = normalize(goal);
16052
+ const normalized = normalize2(goal);
14677
16053
  const scored = WORKFLOW_RECIPES.map((recipe) => {
14678
- const haystack = normalize([
16054
+ const haystack = normalize2([
14679
16055
  recipe.id,
14680
16056
  recipe.title,
14681
16057
  recipe.description,
@@ -14806,16 +16182,119 @@ function reportTitle(full) {
14806
16182
  return title?.replace(/^#\s+/, "").trim() || "MCP Scraper Report";
14807
16183
  }
14808
16184
  function outputBaseDir2() {
14809
- return process.env.MCP_SCRAPER_OUTPUT_DIR?.trim() || (0, import_node_path6.join)((0, import_node_os4.homedir)(), "Downloads", "mcp-scraper");
16185
+ return process.env.MCP_SCRAPER_OUTPUT_DIR?.trim() || (0, import_node_path7.join)((0, import_node_os5.homedir)(), "Downloads", "mcp-scraper");
14810
16186
  }
14811
16187
  function saveFullReport(full) {
14812
16188
  if (!reportSavingEnabled || process.env.MCP_SCRAPER_SAVE_REPORTS === "false") return null;
14813
16189
  const outDir = outputBaseDir2();
14814
16190
  try {
14815
- (0, import_node_fs4.mkdirSync)(outDir, { recursive: true });
16191
+ (0, import_node_fs5.mkdirSync)(outDir, { recursive: true });
16192
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
16193
+ const file = (0, import_node_path7.join)(outDir, `${stamp}-${slugifyReportName(reportTitle(full))}.md`);
16194
+ (0, import_node_fs5.writeFileSync)(file, full, "utf8");
16195
+ return file;
16196
+ } catch {
16197
+ return null;
16198
+ }
16199
+ }
16200
+ function reportSavingActive() {
16201
+ return reportSavingEnabled && process.env.MCP_SCRAPER_SAVE_REPORTS !== "false";
16202
+ }
16203
+ function saveBulkSite(siteUrl, pages, seo, imageAudit) {
16204
+ if (!reportSavingActive()) return null;
16205
+ try {
16206
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
16207
+ const dir = (0, import_node_path7.join)(outputBaseDir2(), `extract-${slugifyReportName(siteUrl)}-${stamp}`);
16208
+ const pagesDir = (0, import_node_path7.join)(dir, "pages");
16209
+ (0, import_node_fs5.mkdirSync)(pagesDir, { recursive: true });
16210
+ const indexRows = pages.map((p, i) => {
16211
+ const num = String(i + 1).padStart(4, "0");
16212
+ const slug = slugifyReportName(p.url.replace(/^https?:\/\//, "")).slice(0, 60) || "page";
16213
+ const fname = `${num}-${slug}.md`;
16214
+ const body = (p.bodyMarkdown ?? "").trim();
16215
+ const content = [
16216
+ `# ${p.title ?? "Untitled"}`,
16217
+ `- **URL:** ${p.url}`,
16218
+ p.metaDescription ? `- **Description:** ${p.metaDescription}` : "",
16219
+ p.schemaTypes?.length ? `- **Schema:** ${p.schemaTypes.join(", ")}` : "",
16220
+ "",
16221
+ body || "_(no content extracted)_"
16222
+ ].filter(Boolean).join("\n");
16223
+ (0, import_node_fs5.writeFileSync)((0, import_node_path7.join)(pagesDir, fname), content, "utf8");
16224
+ return `| ${i + 1} | ${cell(p.title ?? "Untitled")} | ${p.url} | pages/${fname} |`;
16225
+ });
16226
+ const dataFilesSection = seo ? [
16227
+ `
16228
+ ## Data files (in this folder)`,
16229
+ `- \`report.md\` \u2014 SEO issues summary (counts + example URLs)`,
16230
+ `- \`issues.json\` \u2014 every finding with the full list of offender URLs`,
16231
+ `- \`pages.jsonl\` \u2014 per-page SEO fields, one JSON object per page`,
16232
+ `- \`links.jsonl\` \u2014 internal/external link edges (anchor, rel, target status)`,
16233
+ `- \`link-metrics.jsonl\` \u2014 inlinks, crawl depth, orphan flags per page`,
16234
+ ...imageAudit ? [
16235
+ `- \`images.jsonl\` \u2014 every image with byte size, format, over-100KB and legacy-format flags`,
16236
+ `- \`images-summary.json\` \u2014 image totals (count, total weight, over-100KB, legacy)`
16237
+ ] : [],
16238
+ ...seo.branding ? [`- \`branding.json\` \u2014 site logo, colors, fonts`] : []
16239
+ ].join("\n") : "";
16240
+ const index = [
16241
+ `# Site Extract: ${siteUrl}`,
16242
+ `**${pages.length} pages** crawled. Everything is in this folder.`,
16243
+ `- Page content: one Markdown file per page under \`pages/\` (listed in the table below).`,
16244
+ seo ? `- SEO crawl data: the data files listed below.` : "",
16245
+ dataFilesSection,
16246
+ `
16247
+ ## Pages
16248
+ | # | Title | URL | File |
16249
+ |---|-------|-----|------|
16250
+ ${indexRows.join("\n")}`
16251
+ ].filter(Boolean).join("\n");
16252
+ const indexFile = (0, import_node_path7.join)(dir, "index.md");
16253
+ (0, import_node_fs5.writeFileSync)(indexFile, index, "utf8");
16254
+ let seoFiles;
16255
+ if (seo) {
16256
+ const toJsonl = (rows) => rows.map((r) => JSON.stringify(r)).join("\n");
16257
+ const pagesJsonl = (0, import_node_path7.join)(dir, "pages.jsonl");
16258
+ const linksJsonl = (0, import_node_path7.join)(dir, "links.jsonl");
16259
+ const metricsJsonl = (0, import_node_path7.join)(dir, "link-metrics.jsonl");
16260
+ const issuesFile = (0, import_node_path7.join)(dir, "issues.json");
16261
+ const reportFile = (0, import_node_path7.join)(dir, "report.md");
16262
+ (0, import_node_fs5.writeFileSync)(pagesJsonl, toJsonl(seo.pageRows), "utf8");
16263
+ (0, import_node_fs5.writeFileSync)(linksJsonl, toJsonl(seo.edges), "utf8");
16264
+ (0, import_node_fs5.writeFileSync)(metricsJsonl, toJsonl(seo.metrics), "utf8");
16265
+ (0, import_node_fs5.writeFileSync)(issuesFile, JSON.stringify(seo.issues, null, 2), "utf8");
16266
+ (0, import_node_fs5.writeFileSync)(reportFile, seo.reportMd + (imageAudit ? `
16267
+
16268
+ ${renderImageSection(imageAudit)}` : ""), "utf8");
16269
+ seoFiles = [pagesJsonl, linksJsonl, metricsJsonl, issuesFile, reportFile];
16270
+ if (imageAudit) {
16271
+ const imagesJsonl = (0, import_node_path7.join)(dir, "images.jsonl");
16272
+ const imagesSummary = (0, import_node_path7.join)(dir, "images-summary.json");
16273
+ (0, import_node_fs5.writeFileSync)(imagesJsonl, imageAudit.rows.map((r) => JSON.stringify(r)).join("\n"), "utf8");
16274
+ (0, import_node_fs5.writeFileSync)(imagesSummary, JSON.stringify(imageAudit.summary, null, 2), "utf8");
16275
+ seoFiles.push(imagesJsonl, imagesSummary);
16276
+ }
16277
+ if (seo.branding) {
16278
+ const brandingFile = (0, import_node_path7.join)(dir, "branding.json");
16279
+ (0, import_node_fs5.writeFileSync)(brandingFile, JSON.stringify(seo.branding, null, 2), "utf8");
16280
+ seoFiles.push(brandingFile);
16281
+ }
16282
+ }
16283
+ return { dir, indexFile, fileCount: pages.length, seoFiles };
16284
+ } catch {
16285
+ return null;
16286
+ }
16287
+ }
16288
+ function saveUrlInventory(siteUrl, urls) {
16289
+ if (!reportSavingActive()) return null;
16290
+ try {
16291
+ const outDir = outputBaseDir2();
16292
+ (0, import_node_fs5.mkdirSync)(outDir, { recursive: true });
14816
16293
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
14817
- const file = (0, import_node_path6.join)(outDir, `${stamp}-${slugifyReportName(reportTitle(full))}.md`);
14818
- (0, import_node_fs4.writeFileSync)(file, full, "utf8");
16294
+ const file = (0, import_node_path7.join)(outDir, `${stamp}-urlmap-${slugifyReportName(siteUrl.replace(/^https?:\/\//, ""))}.csv`);
16295
+ const csv = (v) => /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
16296
+ const rows = ["url,status", ...urls.map((u) => `${csv(u.url)},${u.status ?? ""}`)];
16297
+ (0, import_node_fs5.writeFileSync)(file, rows.join("\n"), "utf8");
14819
16298
  return file;
14820
16299
  } catch {
14821
16300
  return null;
@@ -14824,19 +16303,19 @@ function saveFullReport(full) {
14824
16303
  function persistScreenshotLocally(base64, url) {
14825
16304
  if (!reportSavingEnabled || process.env.MCP_SCRAPER_SAVE_REPORTS === "false") return null;
14826
16305
  try {
14827
- const dir = (0, import_node_path6.join)(outputBaseDir2(), "screenshots");
14828
- (0, import_node_fs4.mkdirSync)(dir, { recursive: true });
16306
+ const dir = (0, import_node_path7.join)(outputBaseDir2(), "screenshots");
16307
+ (0, import_node_fs5.mkdirSync)(dir, { recursive: true });
14829
16308
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
14830
16309
  const slug = url.replace(/^https?:\/\//, "").replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").slice(0, 60);
14831
- const filePath = (0, import_node_path6.join)(dir, `${stamp}-${slug}.png`);
14832
- (0, import_node_fs4.writeFileSync)(filePath, Buffer.from(base64, "base64"));
16310
+ const filePath = (0, import_node_path7.join)(dir, `${stamp}-${slug}.png`);
16311
+ (0, import_node_fs5.writeFileSync)(filePath, Buffer.from(base64, "base64"));
14833
16312
  return filePath;
14834
16313
  } catch {
14835
16314
  return null;
14836
16315
  }
14837
16316
  }
14838
- function oneBlock(content) {
14839
- const filePath = saveFullReport(content);
16317
+ function oneBlock(content, diskContent) {
16318
+ const filePath = saveFullReport(diskContent ?? content);
14840
16319
  const text = filePath ? `${content}
14841
16320
 
14842
16321
  \u{1F4C4} Saved: \`${filePath}\`` : content;
@@ -15105,7 +16584,12 @@ ${[h1Lines, h2Lines].filter(Boolean).join("\n")}` : "";
15105
16584
  ].filter(Boolean).join("\n") : "";
15106
16585
  const bodySection = bodyMd ? `
15107
16586
  ## Page Content
15108
- ${bodyMd.slice(0, 3e3)}${bodyMd.length > 3e3 ? "\n\n*(truncated)*" : ""}` : "";
16587
+ ${bodyMd.slice(0, 3e3)}${bodyMd.length > 3e3 ? `
16588
+
16589
+ *(truncated to 3,000 of ${bodyMd.length.toLocaleString()} chars \u2014 full content in the saved report)*` : ""}` : "";
16590
+ const bodySectionFull = bodyMd ? `
16591
+ ## Page Content
16592
+ ${bodyMd}` : "";
15109
16593
  const screenshotSection = screenshotMeta ? `
15110
16594
  ## Screenshot
15111
16595
  - **File:** ${screenshotPath ?? "(returned inline only \u2014 disk write unavailable in this environment)"}
@@ -15136,7 +16620,10 @@ ${bodyMd.slice(0, 3e3)}${bodyMd.length > 3e3 ? "\n\n*(truncated)*" : ""}` : "";
15136
16620
  const full = `# URL Extract: ${url}
15137
16621
  **${title}**
15138
16622
  ${headingSection}${kpoSection}${brandingSection}${bodySection}${screenshotSection}${mediaSection}${tips}`;
15139
- const textResult = oneBlock(full);
16623
+ const diskReport = `# URL Extract: ${url}
16624
+ **${title}**
16625
+ ${headingSection}${kpoSection}${brandingSection}${bodySectionFull}${screenshotSection}${mediaSection}${tips}`;
16626
+ const textResult = oneBlock(full, diskReport);
15140
16627
  const structuredContent = {
15141
16628
  url,
15142
16629
  title: d.title ?? null,
@@ -15169,7 +16656,16 @@ function formatMapSiteUrls(raw, input) {
15169
16656
  const ok = urls.filter((u) => (u.status ?? 0) >= 200 && (u.status ?? 0) < 300);
15170
16657
  const broken = urls.filter((u) => u.status !== null && u.status >= 400);
15171
16658
  const redirects = urls.filter((u) => u.status !== null && u.status >= 300 && u.status < 400);
15172
- const urlRows = urls.slice(0, 200).map((u, i) => `| ${i + 1} | ${u.url} | ${u.status ?? "\u2014"} |`).join("\n");
16659
+ const isBulk = urls.length > BULK_URL_THRESHOLD;
16660
+ const inventoryFile = isBulk ? saveUrlInventory(input.url, urls.map((u) => ({ url: u.url, status: u.status ?? null }))) : null;
16661
+ const inlineCount = isBulk ? BULK_URL_THRESHOLD : 200;
16662
+ const urlRows = urls.slice(0, inlineCount).map((u, i) => `| ${i + 1} | ${u.url} | ${u.status ?? "\u2014"} |`).join("\n");
16663
+ const inventoryNote = isBulk ? inventoryFile ? `
16664
+ ## \u{1F4C1} Full inventory saved
16665
+ - **File:** \`${inventoryFile}\` (CSV: url, status \u2014 all ${urls.length} URLs)
16666
+ Showing the first ${inlineCount} below; read the file for the complete list.` : `
16667
+ ## \u26A0\uFE0F Full inventory not saved
16668
+ 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.` : "";
15173
16669
  const full = [
15174
16670
  `# URL Map: ${input.url}`,
15175
16671
  `**${d.totalFound} URLs** \xB7 ${(d.durationMs / 1e3).toFixed(1)}s${d.truncated ? " \xB7 *truncated*" : ""}`,
@@ -15178,12 +16674,13 @@ function formatMapSiteUrls(raw, input) {
15178
16674
  - \u2705 2xx: ${ok.length}
15179
16675
  - \u{1F500} 3xx: ${redirects.length}
15180
16676
  - \u274C 4xx+: ${broken.length}`,
16677
+ inventoryNote,
15181
16678
  `
15182
- ## URL Inventory
16679
+ ## URL Inventory${isBulk ? ` (first ${inlineCount} of ${urls.length})` : ""}
15183
16680
  | # | URL | Status |
15184
16681
  |---|-----|--------|
15185
16682
  ${urlRows}`,
15186
- broken.length ? `
16683
+ !isBulk && broken.length ? `
15187
16684
  ## Broken URLs
15188
16685
  ${broken.map((u) => `- ${u.url} (${u.status})`).join("\n")}` : "",
15189
16686
  `
@@ -15201,47 +16698,164 @@ ${broken.map((u) => `- ${u.url} (${u.status})`).join("\n")}` : "",
15201
16698
  okCount: ok.length,
15202
16699
  redirectCount: redirects.length,
15203
16700
  brokenCount: broken.length,
16701
+ inventoryFile: inventoryFile ?? null,
15204
16702
  urls: urls.map((u) => ({ url: u.url, status: u.status ?? null })),
15205
16703
  durationMs: d.durationMs ?? 0
15206
16704
  }
15207
16705
  };
15208
16706
  }
16707
+ function buildSeoExport(siteUrl, pages, branding) {
16708
+ const { edges, metrics } = buildLinkGraph(pages, siteUrl);
16709
+ const pageRows = pages.map((p) => {
16710
+ const { bodyMarkdown: _b, schema: _s, outlinks: _o, ...rest } = p;
16711
+ const m = metrics.get(p.url);
16712
+ return { ...rest, inlinks: m?.inlinks ?? 0, crawlDepth: m?.crawlDepth ?? null, orphan: m?.orphan ?? false };
16713
+ });
16714
+ const issues = computeIssues(pages, metrics);
16715
+ return {
16716
+ pageRows,
16717
+ edges,
16718
+ metrics: [...metrics.values()],
16719
+ issues,
16720
+ reportMd: renderIssueReport(siteUrl, pages, issues, metrics),
16721
+ branding: branding ?? void 0
16722
+ };
16723
+ }
15209
16724
  function formatExtractSite(raw, input) {
15210
16725
  const parsed = parseData(raw);
15211
16726
  if ("error" in parsed) return { content: [{ type: "text", text: parsed.error }], isError: true };
15212
16727
  const d = parsed.data;
15213
16728
  const pages = d.pages ?? [];
15214
- const pageRows = pages.map((p, i) => {
15215
- const schemaInfo = p.kpo?.type?.join(", ") ?? (Array.isArray(p.schema) && p.schema.length ? `${p.schema.length} block(s)` : "\u2014");
16729
+ const schemaTypesOf = (p) => p.kpo?.type ?? (Array.isArray(p.schema) && p.schema.length ? [`${p.schema.length} block(s)`] : []);
16730
+ const pageRow = (p, i) => {
16731
+ const schemaInfo = schemaTypesOf(p).join(", ") || "\u2014";
15216
16732
  return `| ${i + 1} | ${cell(p.title ?? "Untitled")} | ${p.url} | ${schemaInfo} |`;
15217
- }).join("\n");
15218
- const full = [
16733
+ };
16734
+ const tips = `
16735
+ ---
16736
+ \u{1F4A1} **Tips**
16737
+ - Map URLs first: use \`map_site_urls\`
16738
+ - Inspect a single page: use \`extract_url\``;
16739
+ const durationLine = `**${pages.length} pages** \xB7 ${((d.durationMs ?? 0) / 1e3).toFixed(1)}s`;
16740
+ const structuredContent = {
16741
+ url: input.url,
16742
+ pageCount: pages.length,
16743
+ pages: pages.map((p) => ({
16744
+ url: String(p.url ?? ""),
16745
+ title: p.title ?? null,
16746
+ schemaTypes: schemaTypesOf(p)
16747
+ })),
16748
+ durationMs: d.durationMs ?? 0
16749
+ };
16750
+ if (pages.length > BULK_PAGE_THRESHOLD) {
16751
+ const bulk = saveBulkSite(input.url, pages.map((p) => ({
16752
+ url: String(p.url ?? ""),
16753
+ title: p.title ?? null,
16754
+ bodyMarkdown: p.bodyMarkdown ?? null,
16755
+ metaDescription: p.metaDescription ?? null,
16756
+ schemaTypes: schemaTypesOf(p)
16757
+ })));
16758
+ const preview = pages.slice(0, BULK_PAGE_THRESHOLD).map(pageRow).join("\n");
16759
+ const location2 = bulk ? `
16760
+ ## \u{1F4C1} Bulk scrape saved
16761
+ - **Folder:** \`${bulk.dir}\` \u2190 all scraped page content is here
16762
+ - **Start here:** \`${bulk.indexFile}\` \u2014 lists every page and its file
16763
+ - **Page content:** ${bulk.fileCount} Markdown files under \`pages/\` (one per page)
16764
+
16765
+ 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/\`.
16766
+
16767
+ \u{1F4A1} Want the full technical SEO audit (headings, link graph, indexability, image sizes/formats, issues)? Run \`audit_site\` on this URL instead.` : `
16768
+ ## \u26A0\uFE0F Bulk scrape not saved
16769
+ 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.`;
16770
+ const full2 = [
16771
+ `# Site Extract: ${input.url}`,
16772
+ durationLine,
16773
+ location2,
16774
+ `
16775
+ ## Pages (first ${Math.min(BULK_PAGE_THRESHOLD, pages.length)} of ${pages.length})
16776
+ | # | Title | URL | Schema |
16777
+ |---|-------|-----|--------|
16778
+ ${preview}`,
16779
+ tips
16780
+ ].join("\n");
16781
+ return { content: [{ type: "text", text: full2 }], structuredContent: { ...structuredContent, bulkFolder: bulk?.dir ?? null } };
16782
+ }
16783
+ const header = [
15219
16784
  `# Site Extract: ${input.url}`,
15220
- `**${pages.length} pages** \xB7 ${((d.durationMs ?? 0) / 1e3).toFixed(1)}s`,
16785
+ durationLine,
15221
16786
  `
15222
16787
  ## Pages
15223
16788
  | # | Title | URL | Schema |
15224
16789
  |---|-------|-----|--------|
15225
- ${pageRows}`,
15226
- `
15227
- ---
15228
- \u{1F4A1} **Tips**
15229
- - Map URLs first: use \`map_site_urls\`
15230
- - Inspect a single page: use \`extract_url\``
16790
+ ${pages.map(pageRow).join("\n")}`
15231
16791
  ].join("\n");
15232
- return {
15233
- ...oneBlock(full),
15234
- structuredContent: {
15235
- url: input.url,
15236
- pageCount: pages.length,
15237
- pages: pages.map((p) => ({
15238
- url: String(p.url ?? ""),
15239
- title: p.title ?? null,
15240
- schemaTypes: p.kpo?.type ?? []
15241
- })),
15242
- durationMs: d.durationMs ?? 0
15243
- }
16792
+ const full = `${header}${tips}`;
16793
+ const pageDetails = pages.map((p, i) => {
16794
+ const body = (p.bodyMarkdown ?? "").trim();
16795
+ return [
16796
+ `
16797
+ ## ${i + 1}. ${p.title ?? "Untitled"}`,
16798
+ `- **URL:** ${p.url}`,
16799
+ p.metaDescription ? `- **Description:** ${p.metaDescription}` : "",
16800
+ body ? `
16801
+ ${body}` : "_(no content extracted)_"
16802
+ ].filter(Boolean).join("\n");
16803
+ }).join("\n");
16804
+ const diskReport = `${header}
16805
+
16806
+ ---
16807
+ # Full Page Content
16808
+ ${pageDetails}${tips}`;
16809
+ return { ...oneBlock(full, diskReport), structuredContent };
16810
+ }
16811
+ async function formatAuditSite(raw, input) {
16812
+ const parsed = parseData(raw);
16813
+ if ("error" in parsed) return { content: [{ type: "text", text: parsed.error }], isError: true };
16814
+ const d = parsed.data;
16815
+ const pages = d.pages ?? [];
16816
+ const schemaTypesOf = (p) => p.kpo?.type ?? (Array.isArray(p.schema) && p.schema.length ? [`${p.schema.length} block(s)`] : []);
16817
+ const seo = buildSeoExport(input.url, pages, d.branding);
16818
+ const imageAudit = await auditImages(pages, { concurrency: 12 });
16819
+ const bulk = saveBulkSite(input.url, pages.map((p) => ({
16820
+ url: String(p.url ?? ""),
16821
+ title: p.title ?? null,
16822
+ bodyMarkdown: p.bodyMarkdown ?? null,
16823
+ metaDescription: p.metaDescription ?? null,
16824
+ schemaTypes: schemaTypesOf(p)
16825
+ })), seo, imageAudit);
16826
+ 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");
16827
+ const img = imageAudit.summary;
16828
+ const imgLine = `${img.unique} images \xB7 ${img.totalSize} total \xB7 ${img.over100kb} over 100 KB \xB7 ${img.legacyFormat} legacy format`;
16829
+ const durationLine = `**${pages.length} pages** \xB7 ${((d.durationMs ?? 0) / 1e3).toFixed(1)}s`;
16830
+ const structuredContent = {
16831
+ url: input.url,
16832
+ pageCount: pages.length,
16833
+ durationMs: d.durationMs ?? 0,
16834
+ bulkFolder: bulk?.dir ?? null,
16835
+ issues: Object.fromEntries(Object.entries(seo.issues).map(([k, v]) => [k, v.count])),
16836
+ images: { unique: img.unique, totalBytes: img.totalBytes, over100kb: img.over100kb, legacyFormat: img.legacyFormat }
15244
16837
  };
16838
+ const location2 = bulk ? `
16839
+ ## \u{1F4C1} Technical audit saved
16840
+ - **Folder:** \`${bulk.dir}\` \u2190 full audit + page content here
16841
+ - **Start here:** \`${bulk.indexFile}\` \u2014 maps every file in the folder
16842
+ - **Data:** \`report.md\` (issues + image audit), \`issues.json\`, \`pages.jsonl\` (per-page fields), \`links.jsonl\`, \`link-metrics.jsonl\`, \`images.jsonl\`
16843
+
16844
+ 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.` : `
16845
+ ## \u26A0\uFE0F Audit not saved
16846
+ Report saving is disabled in this environment. Enable \`MCP_SCRAPER_SAVE_REPORTS\` to capture the technical audit.`;
16847
+ const full = [
16848
+ `# Technical SEO Audit: ${input.url}`,
16849
+ durationLine,
16850
+ location2,
16851
+ `
16852
+ ## Top issues
16853
+ ${topIssues || "_none found_"}`,
16854
+ `
16855
+ ## Images
16856
+ ${imgLine}`
16857
+ ].join("\n");
16858
+ return { content: [{ type: "text", text: full }], structuredContent };
15245
16859
  }
15246
16860
  function formatYoutubeHarvest(raw, input) {
15247
16861
  const parsed = parseData(raw);
@@ -16417,16 +18031,21 @@ ${rows}` : ""
16417
18031
  }
16418
18032
  };
16419
18033
  }
16420
- var import_node_fs4, import_node_os4, import_node_path6, reportSavingEnabled;
18034
+ var import_node_fs5, import_node_os5, import_node_path7, reportSavingEnabled, BULK_PAGE_THRESHOLD, BULK_URL_THRESHOLD;
16421
18035
  var init_mcp_response_formatter = __esm({
16422
18036
  "src/mcp/mcp-response-formatter.ts"() {
16423
18037
  "use strict";
16424
- import_node_fs4 = require("fs");
16425
- import_node_os4 = require("os");
16426
- import_node_path6 = require("path");
18038
+ import_node_fs5 = require("fs");
18039
+ import_node_os5 = require("os");
18040
+ import_node_path7 = require("path");
16427
18041
  init_errors();
16428
18042
  init_workflow_catalog();
18043
+ init_seo_link_graph();
18044
+ init_seo_issues();
18045
+ init_image_audit();
16429
18046
  reportSavingEnabled = true;
18047
+ BULK_PAGE_THRESHOLD = 25;
18048
+ BULK_URL_THRESHOLD = 500;
16430
18049
  }
16431
18050
  });
16432
18051
 
@@ -16819,11 +18438,11 @@ function csvRowsFor(result) {
16819
18438
  return rows;
16820
18439
  }
16821
18440
  async function saveDirectoryCsv(result) {
16822
- const outDir = (0, import_node_path7.join)(outputBaseDir2(), "directory-workflows");
18441
+ const outDir = (0, import_node_path8.join)(outputBaseDir2(), "directory-workflows");
16823
18442
  await (0, import_promises6.mkdir)(outDir, { recursive: true });
16824
18443
  const stamp = result.extractedAt.replace(/[:.]/g, "-");
16825
18444
  const slug = `${result.state}-${result.query}`.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
16826
- const path6 = (0, import_node_path7.join)(outDir, `${stamp}-${slug}-directory-workflow.csv`);
18445
+ const path6 = (0, import_node_path8.join)(outDir, `${stamp}-${slug}-directory-workflow.csv`);
16827
18446
  const headers = [
16828
18447
  "source_query",
16829
18448
  "source_location",
@@ -16881,12 +18500,12 @@ async function runDirectoryWorkflowFromPlan(options, plan) {
16881
18500
  const csvPath = options.saveCsv ? await saveDirectoryCsv(base) : null;
16882
18501
  return { ...base, csvPath };
16883
18502
  }
16884
- var import_promises6, import_node_path7, import_zod19, DirectoryWorkflowOptionsSchema;
18503
+ var import_promises6, import_node_path8, import_zod19, DirectoryWorkflowOptionsSchema;
16885
18504
  var init_directory_workflow = __esm({
16886
18505
  "src/directory/directory-workflow.ts"() {
16887
18506
  "use strict";
16888
18507
  import_promises6 = require("fs/promises");
16889
- import_node_path7 = require("path");
18508
+ import_node_path8 = require("path");
16890
18509
  import_zod19 = require("zod");
16891
18510
  init_mcp_response_formatter();
16892
18511
  init_browser_service_env();
@@ -17013,7 +18632,7 @@ var init_slugify = __esm({
17013
18632
 
17014
18633
  // src/workflows/artifact-writer.ts
17015
18634
  function workflowOutputBaseDir(outputDir) {
17016
- return outputDir?.trim() || process.env.MCP_SCRAPER_OUTPUT_DIR?.trim() || (0, import_node_path8.join)((0, import_node_os5.homedir)(), "Downloads", "mcp-scraper");
18635
+ return outputDir?.trim() || process.env.MCP_SCRAPER_OUTPUT_DIR?.trim() || (0, import_node_path9.join)((0, import_node_os6.homedir)(), "Downloads", "mcp-scraper");
17017
18636
  }
17018
18637
  function timestamp() {
17019
18638
  return (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
@@ -17022,11 +18641,11 @@ function safeSlug(value) {
17022
18641
  return slugify(value).replace(/^-+|-+$/g, "").slice(0, 80) || "run";
17023
18642
  }
17024
18643
  function indexPath(baseDir = workflowOutputBaseDir()) {
17025
- return (0, import_node_path8.join)(baseDir, "workflows", "index.json");
18644
+ return (0, import_node_path9.join)(baseDir, "workflows", "index.json");
17026
18645
  }
17027
18646
  async function readIndex(baseDir) {
17028
18647
  const path6 = indexPath(baseDir);
17029
- if (!(0, import_node_fs5.existsSync)(path6)) return { runs: [] };
18648
+ if (!(0, import_node_fs6.existsSync)(path6)) return { runs: [] };
17030
18649
  try {
17031
18650
  return JSON.parse(await (0, import_promises7.readFile)(path6, "utf8"));
17032
18651
  } catch {
@@ -17048,17 +18667,17 @@ async function updateWorkflowIndex(manifest, manifestPath, baseDir) {
17048
18667
  summary: `${manifest.title} (${manifest.status})`
17049
18668
  };
17050
18669
  index.runs = [entry, ...index.runs.filter((r) => r.runId !== manifest.runId)].slice(0, 200);
17051
- await (0, import_promises7.mkdir)((0, import_node_path8.dirname)(path6), { recursive: true });
18670
+ await (0, import_promises7.mkdir)((0, import_node_path9.dirname)(path6), { recursive: true });
17052
18671
  await (0, import_promises7.writeFile)(path6, JSON.stringify(index, null, 2), "utf8");
17053
18672
  }
17054
- var import_promises7, import_node_fs5, import_node_os5, import_node_path8, import_node_child_process3, ArtifactWriter;
18673
+ var import_promises7, import_node_fs6, import_node_os6, import_node_path9, import_node_child_process3, ArtifactWriter;
17055
18674
  var init_artifact_writer = __esm({
17056
18675
  "src/workflows/artifact-writer.ts"() {
17057
18676
  "use strict";
17058
18677
  import_promises7 = require("fs/promises");
17059
- import_node_fs5 = require("fs");
17060
- import_node_os5 = require("os");
17061
- import_node_path8 = require("path");
18678
+ import_node_fs6 = require("fs");
18679
+ import_node_os6 = require("os");
18680
+ import_node_path9 = require("path");
17062
18681
  import_node_child_process3 = require("child_process");
17063
18682
  init_csv();
17064
18683
  init_slugify();
@@ -17085,7 +18704,7 @@ var init_artifact_writer = __esm({
17085
18704
  const runId = forcedRunId?.trim() || `${timestamp()}-${safeSlug(workflowId)}-${Math.random().toString(36).slice(2, 8)}`;
17086
18705
  const nameSource = String(input.keyword ?? input.query ?? input.domain ?? input.state ?? workflowId);
17087
18706
  const baseDir = workflowOutputBaseDir(outputDir);
17088
- const runDir = (0, import_node_path8.join)(baseDir, "workflows", workflowId, `${timestamp()}-${safeSlug(nameSource)}-${safeSlug(runId).slice(0, 20)}`);
18707
+ const runDir = (0, import_node_path9.join)(baseDir, "workflows", workflowId, `${timestamp()}-${safeSlug(nameSource)}-${safeSlug(runId).slice(0, 20)}`);
17089
18708
  await (0, import_promises7.mkdir)(runDir, { recursive: true });
17090
18709
  return new _ArtifactWriter(workflowId, title, runId, baseDir, runDir, startedAt, input);
17091
18710
  }
@@ -17095,20 +18714,20 @@ var init_artifact_writer = __esm({
17095
18714
  return path6;
17096
18715
  }
17097
18716
  async writeJson(label, relativePath, data) {
17098
- const path6 = (0, import_node_path8.join)(this.runDir, relativePath);
17099
- await (0, import_promises7.mkdir)((0, import_node_path8.dirname)(path6), { recursive: true });
18717
+ const path6 = (0, import_node_path9.join)(this.runDir, relativePath);
18718
+ await (0, import_promises7.mkdir)((0, import_node_path9.dirname)(path6), { recursive: true });
17100
18719
  await (0, import_promises7.writeFile)(path6, JSON.stringify(data, null, 2), "utf8");
17101
18720
  return this.remember("json", label, path6);
17102
18721
  }
17103
18722
  async writeText(label, relativePath, text, kind = "markdown") {
17104
- const path6 = (0, import_node_path8.join)(this.runDir, relativePath);
17105
- await (0, import_promises7.mkdir)((0, import_node_path8.dirname)(path6), { recursive: true });
18723
+ const path6 = (0, import_node_path9.join)(this.runDir, relativePath);
18724
+ await (0, import_promises7.mkdir)((0, import_node_path9.dirname)(path6), { recursive: true });
17106
18725
  await (0, import_promises7.writeFile)(path6, text, "utf8");
17107
18726
  return this.remember(kind, label, path6);
17108
18727
  }
17109
18728
  async writeCsv(label, relativePath, headers, rows) {
17110
- const path6 = (0, import_node_path8.join)(this.runDir, relativePath);
17111
- await (0, import_promises7.mkdir)((0, import_node_path8.dirname)(path6), { recursive: true });
18729
+ const path6 = (0, import_node_path9.join)(this.runDir, relativePath);
18730
+ await (0, import_promises7.mkdir)((0, import_node_path9.dirname)(path6), { recursive: true });
17112
18731
  await (0, import_promises7.writeFile)(path6, rowsToCsv(headers, rows), "utf8");
17113
18732
  return this.remember("csv", label, path6, rows.length);
17114
18733
  }
@@ -17129,7 +18748,7 @@ var init_artifact_writer = __esm({
17129
18748
  errors,
17130
18749
  counts
17131
18750
  };
17132
- const path6 = (0, import_node_path8.join)(this.runDir, "manifest.json");
18751
+ const path6 = (0, import_node_path9.join)(this.runDir, "manifest.json");
17133
18752
  await (0, import_promises7.writeFile)(path6, JSON.stringify(manifest, null, 2), "utf8");
17134
18753
  await updateWorkflowIndex(manifest, path6, this.baseDir);
17135
18754
  return path6;
@@ -18709,7 +20328,7 @@ async function readManifestFromSummary(summary) {
18709
20328
  function webhookSignature(body, timestamp2) {
18710
20329
  const secret2 = process.env.MCP_SCRAPER_WEBHOOK_SECRET?.trim();
18711
20330
  if (!secret2) return null;
18712
- return (0, import_node_crypto3.createHmac)("sha256", secret2).update(`${timestamp2}.${body}`).digest("hex");
20331
+ return (0, import_node_crypto4.createHmac)("sha256", secret2).update(`${timestamp2}.${body}`).digest("hex");
18713
20332
  }
18714
20333
  async function deliverWorkflowWebhook(input) {
18715
20334
  if (!input.webhookUrl) return;
@@ -18916,11 +20535,11 @@ async function dispatchDueWorkflowSchedules(apiUrl, limit = 3) {
18916
20535
  }
18917
20536
  return { dispatched: results.length, results };
18918
20537
  }
18919
- var import_node_crypto3, import_promises9, import_hono8, import_zod25, workflowApp, WorkflowInputSchema, WorkflowIdSchema, CadenceSchema, ScheduleStatusSchema, RunBodySchema, ScheduleCreateSchema, SchedulePatchSchema, TERMINAL_RUN_STATUSES;
20538
+ var import_node_crypto4, import_promises9, import_hono8, import_zod25, workflowApp, WorkflowInputSchema, WorkflowIdSchema, CadenceSchema, ScheduleStatusSchema, RunBodySchema, ScheduleCreateSchema, SchedulePatchSchema, TERMINAL_RUN_STATUSES;
18920
20539
  var init_workflow_routes = __esm({
18921
20540
  "src/api/workflow-routes.ts"() {
18922
20541
  "use strict";
18923
- import_node_crypto3 = require("crypto");
20542
+ import_node_crypto4 = require("crypto");
18924
20543
  import_promises9 = require("fs/promises");
18925
20544
  import_hono8 = require("hono");
18926
20545
  import_zod25 = require("zod");
@@ -19201,7 +20820,7 @@ var init_workflow_routes = __esm({
19201
20820
  // src/serp-intelligence/page-snapshot-extractor.ts
19202
20821
  function sha256(value) {
19203
20822
  if (!value) return null;
19204
- return (0, import_node_crypto4.createHash)("sha256").update(value).digest("hex");
20823
+ return (0, import_node_crypto5.createHash)("sha256").update(value).digest("hex");
19205
20824
  }
19206
20825
  function countWords(markdown) {
19207
20826
  const matches = markdown.trim().match(/\b[\p{L}\p{N}][\p{L}\p{N}'-]*\b/gu);
@@ -19250,7 +20869,7 @@ function countLinks(html, baseUrl) {
19250
20869
  totalLinkCount: internalLinkCount + externalLinkCount
19251
20870
  };
19252
20871
  }
19253
- function withTimeout2(operation, timeoutMs) {
20872
+ function withTimeout3(operation, timeoutMs) {
19254
20873
  return new Promise((resolve, reject) => {
19255
20874
  const timer = setTimeout(() => reject(new Error(`Page snapshot timed out after ${timeoutMs}ms`)), timeoutMs);
19256
20875
  operation.then(
@@ -19367,7 +20986,7 @@ async function capturePageSnapshot(target, options = {}) {
19367
20986
  });
19368
20987
  }
19369
20988
  try {
19370
- const extraction = await withTimeout2(
20989
+ const extraction = await withTimeout3(
19371
20990
  extractKpo({ url: checked.parsed.href, kernelApiKey }),
19372
20991
  timeoutMs
19373
20992
  );
@@ -19501,11 +21120,11 @@ async function capturePageSnapshots(targets, options = {}) {
19501
21120
  }
19502
21121
  };
19503
21122
  }
19504
- var import_node_crypto4, import_p_limit3, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_CONCURRENCY, DEFAULT_MAX_CONTENT_CHARS;
21123
+ var import_node_crypto5, import_p_limit3, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_CONCURRENCY, DEFAULT_MAX_CONTENT_CHARS;
19505
21124
  var init_page_snapshot_extractor = __esm({
19506
21125
  "src/serp-intelligence/page-snapshot-extractor.ts"() {
19507
21126
  "use strict";
19508
- import_node_crypto4 = require("crypto");
21127
+ import_node_crypto5 = require("crypto");
19509
21128
  import_p_limit3 = __toESM(require("p-limit"), 1);
19510
21129
  init_kpo_extractor();
19511
21130
  init_url_utils();
@@ -20750,52 +22369,52 @@ var init_PAAExtractor = __esm({
20750
22369
  });
20751
22370
 
20752
22371
  // src/output/OutputSerializer.ts
20753
- var import_node_fs6, import_node_path9, import_papaparse2, OutputSerializer;
22372
+ var import_node_fs7, import_node_path10, import_papaparse2, OutputSerializer;
20754
22373
  var init_OutputSerializer = __esm({
20755
22374
  "src/output/OutputSerializer.ts"() {
20756
22375
  "use strict";
20757
- import_node_fs6 = require("fs");
20758
- import_node_path9 = __toESM(require("path"), 1);
22376
+ import_node_fs7 = require("fs");
22377
+ import_node_path10 = __toESM(require("path"), 1);
20759
22378
  import_papaparse2 = __toESM(require("papaparse"), 1);
20760
22379
  OutputSerializer = class {
20761
22380
  async writeJSON(result, outputDir) {
20762
- await import_node_fs6.promises.mkdir(outputDir, { recursive: true });
22381
+ await import_node_fs7.promises.mkdir(outputDir, { recursive: true });
20763
22382
  const slug = result.seed.toLowerCase().replace(/\W+/g, "-").slice(0, 40);
20764
22383
  const filename = `${slug}-${Date.now()}.json`;
20765
- const fullPath = import_node_path9.default.join(outputDir, filename);
20766
- await import_node_fs6.promises.writeFile(fullPath, JSON.stringify(result, null, 2), "utf8");
22384
+ const fullPath = import_node_path10.default.join(outputDir, filename);
22385
+ await import_node_fs7.promises.writeFile(fullPath, JSON.stringify(result, null, 2), "utf8");
20767
22386
  return fullPath;
20768
22387
  }
20769
22388
  async writeCSV(rows, outputDir) {
20770
- await import_node_fs6.promises.mkdir(outputDir, { recursive: true });
22389
+ await import_node_fs7.promises.mkdir(outputDir, { recursive: true });
20771
22390
  const seedRaw = rows[0]?.seed_query ?? "paa";
20772
22391
  const slug = seedRaw.toLowerCase().replace(/\W+/g, "-").slice(0, 40);
20773
22392
  const csv = import_papaparse2.default.unparse(rows, { header: true });
20774
22393
  const filename = `${slug}-${Date.now()}.csv`;
20775
- const fullPath = import_node_path9.default.join(outputDir, filename);
20776
- await import_node_fs6.promises.writeFile(fullPath, csv, "utf8");
22394
+ const fullPath = import_node_path10.default.join(outputDir, filename);
22395
+ await import_node_fs7.promises.writeFile(fullPath, csv, "utf8");
20777
22396
  return fullPath;
20778
22397
  }
20779
22398
  async writeVideoCSV(videos, seed, outputDir) {
20780
- await import_node_fs6.promises.mkdir(outputDir, { recursive: true });
22399
+ await import_node_fs7.promises.mkdir(outputDir, { recursive: true });
20781
22400
  const slug = seed.toLowerCase().replace(/\W+/g, "-").slice(0, 40);
20782
22401
  const csv = import_papaparse2.default.unparse(videos, { header: true });
20783
22402
  const filename = `${slug}-videos-${Date.now()}.csv`;
20784
- const fullPath = import_node_path9.default.join(outputDir, filename);
20785
- await import_node_fs6.promises.writeFile(fullPath, csv, "utf8");
22403
+ const fullPath = import_node_path10.default.join(outputDir, filename);
22404
+ await import_node_fs7.promises.writeFile(fullPath, csv, "utf8");
20786
22405
  return fullPath;
20787
22406
  }
20788
22407
  async writeForumCSV(forums, seed, outputDir) {
20789
- await import_node_fs6.promises.mkdir(outputDir, { recursive: true });
22408
+ await import_node_fs7.promises.mkdir(outputDir, { recursive: true });
20790
22409
  const slug = seed.toLowerCase().replace(/\W+/g, "-").slice(0, 40);
20791
22410
  const csv = import_papaparse2.default.unparse(forums, { header: true });
20792
22411
  const filename = `${slug}-forums-${Date.now()}.csv`;
20793
- const fullPath = import_node_path9.default.join(outputDir, filename);
20794
- await import_node_fs6.promises.writeFile(fullPath, csv, "utf8");
22412
+ const fullPath = import_node_path10.default.join(outputDir, filename);
22413
+ await import_node_fs7.promises.writeFile(fullPath, csv, "utf8");
20795
22414
  return fullPath;
20796
22415
  }
20797
22416
  async writeAIOverviewCSV(citations, text, seed, outputDir) {
20798
- await import_node_fs6.promises.mkdir(outputDir, { recursive: true });
22417
+ await import_node_fs7.promises.mkdir(outputDir, { recursive: true });
20799
22418
  const slug = seed.toLowerCase().replace(/\W+/g, "-").slice(0, 40);
20800
22419
  const rows = citations.map((c, i) => ({
20801
22420
  seed_query: seed,
@@ -20805,12 +22424,12 @@ var init_OutputSerializer = __esm({
20805
22424
  }));
20806
22425
  const csv = import_papaparse2.default.unparse(rows, { header: true });
20807
22426
  const filename = `${slug}-ai-overview-${Date.now()}.csv`;
20808
- const fullPath = import_node_path9.default.join(outputDir, filename);
20809
- await import_node_fs6.promises.writeFile(fullPath, csv, "utf8");
22427
+ const fullPath = import_node_path10.default.join(outputDir, filename);
22428
+ await import_node_fs7.promises.writeFile(fullPath, csv, "utf8");
20810
22429
  return fullPath;
20811
22430
  }
20812
22431
  async writeAIModeCSV(citations, text, seed, outputDir) {
20813
- await import_node_fs6.promises.mkdir(outputDir, { recursive: true });
22432
+ await import_node_fs7.promises.mkdir(outputDir, { recursive: true });
20814
22433
  const slug = seed.toLowerCase().replace(/\W+/g, "-").slice(0, 40);
20815
22434
  const rows = citations.map((c, i) => ({
20816
22435
  seed_query: seed,
@@ -20820,18 +22439,18 @@ var init_OutputSerializer = __esm({
20820
22439
  }));
20821
22440
  const csv = import_papaparse2.default.unparse(rows, { header: true });
20822
22441
  const filename = `${slug}-ai-mode-${Date.now()}.csv`;
20823
- const fullPath = import_node_path9.default.join(outputDir, filename);
20824
- await import_node_fs6.promises.writeFile(fullPath, csv, "utf8");
22442
+ const fullPath = import_node_path10.default.join(outputDir, filename);
22443
+ await import_node_fs7.promises.writeFile(fullPath, csv, "utf8");
20825
22444
  return fullPath;
20826
22445
  }
20827
22446
  async writeWhatPeopleSayingCSV(cards, seed, outputDir) {
20828
- await import_node_fs6.promises.mkdir(outputDir, { recursive: true });
22447
+ await import_node_fs7.promises.mkdir(outputDir, { recursive: true });
20829
22448
  const slug = seed.toLowerCase().replace(/\W+/g, "-").slice(0, 40);
20830
22449
  const rows = cards.map((c) => ({ seed_query: seed, ...c }));
20831
22450
  const csv = import_papaparse2.default.unparse(rows, { header: true });
20832
22451
  const filename = `${slug}-what-people-saying-${Date.now()}.csv`;
20833
- const fullPath = import_node_path9.default.join(outputDir, filename);
20834
- await import_node_fs6.promises.writeFile(fullPath, csv, "utf8");
22452
+ const fullPath = import_node_path10.default.join(outputDir, filename);
22453
+ await import_node_fs7.promises.writeFile(fullPath, csv, "utf8");
20835
22454
  return fullPath;
20836
22455
  }
20837
22456
  };
@@ -21958,12 +23577,45 @@ var PACKAGE_VERSION;
21958
23577
  var init_version = __esm({
21959
23578
  "src/version.ts"() {
21960
23579
  "use strict";
21961
- PACKAGE_VERSION = "0.3.14";
23580
+ PACKAGE_VERSION = "0.3.16";
23581
+ }
23582
+ });
23583
+
23584
+ // src/mcp/server-instructions.ts
23585
+ var SERVER_INSTRUCTIONS;
23586
+ var init_server_instructions = __esm({
23587
+ "src/mcp/server-instructions.ts"() {
23588
+ "use strict";
23589
+ SERVER_INSTRUCTIONS = `
23590
+ This server scrapes and analyzes web, social, search, maps, and site data. Pick a tool by NAME using
23591
+ this routing map, then load its schema before calling.
23592
+
23593
+ ROUTING
23594
+ - One web page -> extract_url. Whole site (crawl + SEO report) -> extract_site. Just the URL list -> map_site_urls.
23595
+ - Web search (organic SERP) -> search_serp. Full SERP + People-Also-Ask / AI-Overview detail -> harvest_paa.
23596
+ - Google Maps: find places -> maps_search; one place deep-dive + reviews -> maps_place_intel.
23597
+ - YouTube: find or list videos -> youtube_harvest; transcribe one video -> youtube_transcribe.
23598
+ - Facebook: find ads -> facebook_ad_search; transcribe an ad -> facebook_ad_transcribe; transcribe a
23599
+ video -> facebook_video_transcribe; page profile/intel -> facebook_page_intel.
23600
+ - Instagram: profile inventory -> instagram_profile_content; one post or reel -> instagram_media_download.
23601
+ - Workflows (multi-step): local directory build -> directory_workflow; rank blueprint -> rank_tracker_workflow;
23602
+ AI-answer citation fan-out (AEO) -> query_fanout_workflow; deep research -> deep_research_workflow.
23603
+ - Anything without a dedicated tool (e.g. Reddit, arbitrary logged-in sites) -> the browser_* agent
23604
+ (browser_open, then navigate/read); save/reuse logins via browser_profile_*.
23605
+
23606
+ NOTES
23607
+ - Bulk / full-site crawls: call extract_site with rotateProxies:true for blocked or rate-limited sites.
23608
+ It discovers URLs and returns a saved folder/artifact plus a summary, not the full content inline.
23609
+ - Tools that open a browser session or transcribe media cost credits and take longer. Prefer the
23610
+ cheapest tool that answers the question (plain search/extract before browser agents).
23611
+ - Large results are saved to disk or an artifact and returned as a summary plus a path; read the path
23612
+ for full detail rather than expecting the whole payload inline.
23613
+ `.trim();
21962
23614
  }
21963
23615
  });
21964
23616
 
21965
23617
  // src/mcp/mcp-tool-schemas.ts
21966
- var import_zod27, HarvestPaaInputSchema, ExtractUrlInputSchema, MapSiteUrlsInputSchema, ExtractSiteInputSchema, YoutubeHarvestInputSchema, YoutubeTranscribeInputSchema, FacebookPageIntelInputSchema, FacebookAdSearchInputSchema, FacebookAdTranscribeInputSchema, FacebookVideoTranscribeInputSchema, InstagramProfileContentInputSchema, InstagramMediaDownloadInputSchema, MapsPlaceIntelInputSchema, MapsSearchInputSchema, DirectoryWorkflowInputSchema, RankTrackerModeSchema, RankTrackerBlueprintInputSchema, NullableString, MapsSearchAttemptOutput, MapsSearchOutputSchema, DirectoryMapsBusinessOutput, DirectoryWorkflowOutputSchema, RankTrackerToolPlanOutput, RankTrackerTableOutput, RankTrackerCronJobOutput, RankTrackerBlueprintOutputSchema, OrganicResultOutput, AiOverviewOutput, EntityIdsOutput, HarvestPaaOutputSchema, SearchSerpOutputSchema, ExtractUrlOutputSchema, ExtractSiteOutputSchema, MapsPlaceIntelOutputSchema, CreditsInfoOutputSchema, MapSiteUrlsOutputSchema, YoutubeHarvestOutputSchema, FacebookAdSearchOutputSchema, FacebookPageIntelOutputSchema, FacebookVideoTranscribeOutputSchema, TranscriptChunkOutput, InstagramBrowserOutput, InstagramPaginationOutput, InstagramProfileContentOutputSchema, InstagramMediaTrackOutput, InstagramDownloadOutput, InstagramMediaDownloadOutputSchema, YoutubeTranscribeOutputSchema, FacebookAdTranscribeOutputSchema, CaptureSerpSnapshotOutputSchema, CaptureSerpPageSnapshotsOutputSchema, CreditsInfoInputSchema, WorkflowIdSchema2, WorkflowListInputSchema, WorkflowSuggestInputSchema, WorkflowRunInputSchema, WorkflowStepInputSchema, WorkflowStatusInputSchema, WorkflowArtifactReadInputSchema, WorkflowRecipeOutput, WorkflowDefinitionOutput, WorkflowArtifactOutput, WorkflowListOutputSchema, WorkflowSuggestOutputSchema, WorkflowRunOutputSchema, WorkflowStepOutputSchema, WorkflowStatusOutputSchema, WorkflowArtifactReadOutputSchema, SearchSerpInputSchema, CaptureSerpSnapshotInputSchema, ScreenshotInputSchema, CaptureSerpPageSnapshotsInputSchema;
23618
+ var import_zod27, HarvestPaaInputSchema, ExtractUrlInputSchema, MapSiteUrlsInputSchema, ExtractSiteInputSchema, AuditSiteInputSchema, YoutubeHarvestInputSchema, YoutubeTranscribeInputSchema, FacebookPageIntelInputSchema, FacebookAdSearchInputSchema, FacebookAdTranscribeInputSchema, FacebookVideoTranscribeInputSchema, InstagramProfileContentInputSchema, InstagramMediaDownloadInputSchema, MapsPlaceIntelInputSchema, MapsSearchInputSchema, DirectoryWorkflowInputSchema, RankTrackerModeSchema, RankTrackerBlueprintInputSchema, NullableString, MapsSearchAttemptOutput, MapsSearchOutputSchema, DirectoryMapsBusinessOutput, DirectoryWorkflowOutputSchema, RankTrackerToolPlanOutput, RankTrackerTableOutput, RankTrackerCronJobOutput, RankTrackerBlueprintOutputSchema, OrganicResultOutput, AiOverviewOutput, EntityIdsOutput, HarvestPaaOutputSchema, SearchSerpOutputSchema, ExtractUrlOutputSchema, ExtractSiteOutputSchema, AuditSiteOutputSchema, MapsPlaceIntelOutputSchema, CreditsInfoOutputSchema, MapSiteUrlsOutputSchema, YoutubeHarvestOutputSchema, FacebookAdSearchOutputSchema, FacebookPageIntelOutputSchema, FacebookVideoTranscribeOutputSchema, TranscriptChunkOutput, InstagramBrowserOutput, InstagramPaginationOutput, InstagramProfileContentOutputSchema, InstagramMediaTrackOutput, InstagramDownloadOutput, InstagramMediaDownloadOutputSchema, YoutubeTranscribeOutputSchema, FacebookAdTranscribeOutputSchema, CaptureSerpSnapshotOutputSchema, CaptureSerpPageSnapshotsOutputSchema, CreditsInfoInputSchema, WorkflowIdSchema2, WorkflowListInputSchema, WorkflowSuggestInputSchema, WorkflowRunInputSchema, WorkflowStepInputSchema, WorkflowStatusInputSchema, WorkflowArtifactReadInputSchema, WorkflowRecipeOutput, WorkflowDefinitionOutput, WorkflowArtifactOutput, WorkflowListOutputSchema, WorkflowSuggestOutputSchema, WorkflowRunOutputSchema, WorkflowStepOutputSchema, WorkflowStatusOutputSchema, WorkflowArtifactReadOutputSchema, SearchSerpInputSchema, CaptureSerpSnapshotInputSchema, ScreenshotInputSchema, CaptureSerpPageSnapshotsInputSchema;
21967
23619
  var init_mcp_tool_schemas = __esm({
21968
23620
  "src/mcp/mcp-tool-schemas.ts"() {
21969
23621
  "use strict";
@@ -21991,11 +23643,20 @@ var init_mcp_tool_schemas = __esm({
21991
23643
  };
21992
23644
  MapSiteUrlsInputSchema = {
21993
23645
  url: import_zod27.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."),
21994
- maxUrls: import_zod27.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.")
23646
+ maxUrls: import_zod27.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.")
21995
23647
  };
21996
23648
  ExtractSiteInputSchema = {
21997
- url: import_zod27.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."),
21998
- maxPages: import_zod27.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.")
23649
+ url: import_zod27.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."),
23650
+ maxPages: import_zod27.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."),
23651
+ rotateProxies: import_zod27.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."),
23652
+ rotateProxyEvery: import_zod27.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."),
23653
+ formats: import_zod27.z.array(import_zod27.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.")
23654
+ };
23655
+ AuditSiteInputSchema = {
23656
+ url: import_zod27.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."),
23657
+ maxPages: import_zod27.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."),
23658
+ rotateProxies: import_zod27.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."),
23659
+ rotateProxyEvery: import_zod27.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.")
21999
23660
  };
22000
23661
  YoutubeHarvestInputSchema = {
22001
23662
  mode: import_zod27.z.enum(["search", "channel"]).describe("Use search for topic/keyword requests. Use channel when the user provides @handle, channel ID, or channel URL."),
@@ -22297,6 +23958,19 @@ var init_mcp_tool_schemas = __esm({
22297
23958
  })),
22298
23959
  durationMs: import_zod27.z.number().min(0)
22299
23960
  };
23961
+ AuditSiteOutputSchema = {
23962
+ url: import_zod27.z.string(),
23963
+ pageCount: import_zod27.z.number().int().min(0),
23964
+ durationMs: import_zod27.z.number().min(0),
23965
+ bulkFolder: import_zod27.z.string().nullable(),
23966
+ issues: import_zod27.z.record(import_zod27.z.string(), import_zod27.z.number()),
23967
+ images: import_zod27.z.object({
23968
+ unique: import_zod27.z.number().int().min(0),
23969
+ totalBytes: import_zod27.z.number().min(0),
23970
+ over100kb: import_zod27.z.number().int().min(0),
23971
+ legacyFormat: import_zod27.z.number().int().min(0)
23972
+ })
23973
+ };
22300
23974
  MapsPlaceIntelOutputSchema = {
22301
23975
  name: import_zod27.z.string(),
22302
23976
  rating: NullableString,
@@ -23076,7 +24750,7 @@ function localPlanningToolAnnotations(title) {
23076
24750
  function listSavedReports() {
23077
24751
  try {
23078
24752
  const dir = outputBaseDir2();
23079
- return (0, import_node_fs7.readdirSync)(dir).filter((f) => f.endsWith(".md")).map((f) => ({ filename: f, mtimeMs: (0, import_node_fs7.statSync)((0, import_node_path10.join)(dir, f)).mtimeMs })).sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, 100);
24753
+ return (0, import_node_fs8.readdirSync)(dir).filter((f) => f.endsWith(".md")).map((f) => ({ filename: f, mtimeMs: (0, import_node_fs8.statSync)((0, import_node_path11.join)(dir, f)).mtimeMs })).sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, 100);
23080
24754
  } catch {
23081
24755
  return [];
23082
24756
  }
@@ -23100,15 +24774,15 @@ function registerSavedReportResources(server) {
23100
24774
  },
23101
24775
  async (uri, variables) => {
23102
24776
  const requested = Array.isArray(variables.filename) ? variables.filename[0] : variables.filename;
23103
- const filename = (0, import_node_path10.basename)(decodeURIComponent(String(requested ?? "")));
24777
+ const filename = (0, import_node_path11.basename)(decodeURIComponent(String(requested ?? "")));
23104
24778
  if (!filename.endsWith(".md")) throw new Error("Only saved .md reports can be read");
23105
- const text = (0, import_node_fs7.readFileSync)((0, import_node_path10.join)(outputBaseDir2(), filename), "utf8");
24779
+ const text = (0, import_node_fs8.readFileSync)((0, import_node_path11.join)(outputBaseDir2(), filename), "utf8");
23106
24780
  return { contents: [{ uri: uri.href, mimeType: "text/markdown", text }] };
23107
24781
  }
23108
24782
  );
23109
24783
  }
23110
24784
  function buildPaaExtractorMcpServer(executor, options = {}) {
23111
- const server = new import_mcp.McpServer({ name: "mcp-scraper", version: PACKAGE_VERSION });
24785
+ const server = new import_mcp.McpServer({ name: "mcp-scraper", version: PACKAGE_VERSION }, { instructions: SERVER_INSTRUCTIONS });
23112
24786
  registerPaaExtractorMcpTools(server, executor, options);
23113
24787
  return server;
23114
24788
  }
@@ -23140,18 +24814,25 @@ function registerPaaExtractorMcpTools(server, executor, options = {}) {
23140
24814
  }, async (input) => formatExtractUrl(await executor.extractUrl(input), input));
23141
24815
  server.registerTool("map_site_urls", {
23142
24816
  title: "Site URL Map",
23143
- 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."),
24817
+ 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."),
23144
24818
  inputSchema: MapSiteUrlsInputSchema,
23145
24819
  outputSchema: MapSiteUrlsOutputSchema,
23146
24820
  annotations: liveWebToolAnnotations("Site URL Map")
23147
24821
  }, async (input) => formatMapSiteUrls(await executor.mapSiteUrls(input), input));
23148
24822
  server.registerTool("extract_site", {
23149
- title: "Multi-Page Site Extract",
23150
- 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."),
24823
+ title: "Multi-Page Site Content Crawl",
24824
+ 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."),
23151
24825
  inputSchema: ExtractSiteInputSchema,
23152
24826
  outputSchema: ExtractSiteOutputSchema,
23153
- annotations: liveWebToolAnnotations("Multi-Page Site Extract")
24827
+ annotations: liveWebToolAnnotations("Multi-Page Site Content Crawl")
23154
24828
  }, async (input) => formatExtractSite(await executor.extractSite(input), input));
24829
+ server.registerTool("audit_site", {
24830
+ title: "Technical SEO Audit",
24831
+ 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."),
24832
+ inputSchema: AuditSiteInputSchema,
24833
+ outputSchema: AuditSiteOutputSchema,
24834
+ annotations: liveWebToolAnnotations("Technical SEO Audit")
24835
+ }, async (input) => formatAuditSite(await executor.auditSite(input), input));
23155
24836
  server.registerTool("youtube_harvest", {
23156
24837
  title: "YouTube Video Harvest",
23157
24838
  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.'),
@@ -23271,7 +24952,7 @@ function registerPaaExtractorMcpTools(server, executor, options = {}) {
23271
24952
  outputSchema: WorkflowArtifactReadOutputSchema,
23272
24953
  annotations: liveWebToolAnnotations("Read Workflow Artifact")
23273
24954
  }, async (input) => formatWorkflowArtifactRead(await executor.workflowArtifactRead(input), input));
23274
- server.registerTool("rank_tracker_blueprint", {
24955
+ server.registerTool("rank_tracker_workflow", {
23275
24956
  title: "Rank Tracker Blueprint Builder",
23276
24957
  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.",
23277
24958
  inputSchema: RankTrackerBlueprintInputSchema,
@@ -23292,15 +24973,16 @@ function registerPaaExtractorMcpTools(server, executor, options = {}) {
23292
24973
  }
23293
24974
  }, async (input) => formatCreditsInfo(await executor.creditsInfo(input), input));
23294
24975
  }
23295
- var import_mcp, import_node_fs7, import_node_path10;
24976
+ var import_mcp, import_node_fs8, import_node_path11;
23296
24977
  var init_paa_mcp_server = __esm({
23297
24978
  "src/mcp/paa-mcp-server.ts"() {
23298
24979
  "use strict";
23299
24980
  import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
23300
- import_node_fs7 = require("fs");
23301
- import_node_path10 = require("path");
24981
+ import_node_fs8 = require("fs");
24982
+ import_node_path11 = require("path");
23302
24983
  init_version();
23303
24984
  init_mcp_response_formatter();
24985
+ init_server_instructions();
23304
24986
  init_mcp_tool_schemas();
23305
24987
  init_rank_tracker_blueprint();
23306
24988
  init_mcp_response_formatter();
@@ -23488,6 +25170,9 @@ var init_http_mcp_tool_executor = __esm({
23488
25170
  extractSite(input) {
23489
25171
  return this.call("/extract-site", input);
23490
25172
  }
25173
+ auditSite(input) {
25174
+ return this.call("/extract-site", input);
25175
+ }
23491
25176
  youtubeHarvest(input) {
23492
25177
  return this.call("/youtube/harvest", input);
23493
25178
  }
@@ -23709,7 +25394,7 @@ async function migrateBrowserAgent() {
23709
25394
  }
23710
25395
  async function createSessionRow(input) {
23711
25396
  const db = getDb();
23712
- const id = `bas_${(0, import_node_crypto5.randomUUID)().replace(/-/g, "").slice(0, 20)}`;
25397
+ const id = `bas_${(0, import_node_crypto6.randomUUID)().replace(/-/g, "").slice(0, 20)}`;
23713
25398
  await db.execute({
23714
25399
  sql: `INSERT INTO browser_agent_sessions (id, runtime_session_id, live_view_url, cdp_ws_url, status, label, user_id, concurrency_lock_id, last_action_at)
23715
25400
  VALUES (?, ?, ?, ?, 'open', ?, ?, ?, datetime('now'))`,
@@ -23770,7 +25455,7 @@ async function recordAction(input) {
23770
25455
  sql: `INSERT INTO browser_agent_actions (id, session_id, type, params_json, ok, error)
23771
25456
  VALUES (?, ?, ?, ?, ?, ?)`,
23772
25457
  args: [
23773
- `baa_${(0, import_node_crypto5.randomUUID)().replace(/-/g, "").slice(0, 20)}`,
25458
+ `baa_${(0, import_node_crypto6.randomUUID)().replace(/-/g, "").slice(0, 20)}`,
23774
25459
  input.sessionId,
23775
25460
  input.type,
23776
25461
  input.params == null ? null : JSON.stringify(input.params),
@@ -23810,11 +25495,11 @@ async function listReplayRows(sessionId) {
23810
25495
  });
23811
25496
  return res.rows;
23812
25497
  }
23813
- var import_node_crypto5, _ready;
25498
+ var import_node_crypto6, _ready;
23814
25499
  var init_browser_agent_db = __esm({
23815
25500
  "src/api/browser-agent-db.ts"() {
23816
25501
  "use strict";
23817
- import_node_crypto5 = require("crypto");
25502
+ import_node_crypto6 = require("crypto");
23818
25503
  init_db();
23819
25504
  _ready = false;
23820
25505
  }
@@ -24419,7 +26104,7 @@ function buildFanoutResult(ctx) {
24419
26104
  interceptorReady: ready,
24420
26105
  rawBytes,
24421
26106
  unmappedKeys: parsed.unmappedKeys,
24422
- note: ready ? "Capture hook is live but no fan-out was seen yet. Run a NEW prompt that triggers web search in this session, then call browser_capture_fanout again. Fan-out is only captured as it streams; past answers from before the session opened cannot be recovered." : "Capture hook not detected on this page. Open a direct/no-proxy hosted browser session, navigate to chatgpt.com or claude.ai after opening, run a new prompt in that session, then call browser_capture_fanout again."
26107
+ note: ready ? "Capture hook is live but no fan-out was seen yet. Run a NEW prompt that triggers web search in this session, then call query_fanout_workflow again. Fan-out is only captured as it streams; past answers from before the session opened cannot be recovered." : "Capture hook not detected on this page. Open a direct/no-proxy hosted browser session, navigate to chatgpt.com or claude.ai after opening, run a new prompt in that session, then call query_fanout_workflow again."
24423
26108
  };
24424
26109
  }
24425
26110
  return result;
@@ -24496,20 +26181,20 @@ var init_classify = __esm({
24496
26181
 
24497
26182
  // src/services/fanout/export.ts
24498
26183
  function outputBaseDir3() {
24499
- return process.env.MCP_SCRAPER_OUTPUT_DIR?.trim() || (0, import_node_path11.join)((0, import_node_os6.homedir)(), "Downloads", "mcp-scraper");
26184
+ return process.env.MCP_SCRAPER_OUTPUT_DIR?.trim() || (0, import_node_path12.join)((0, import_node_os7.homedir)(), "Downloads", "mcp-scraper");
24500
26185
  }
24501
26186
  function safe(value) {
24502
26187
  return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "capture";
24503
26188
  }
24504
26189
  function writeTable(path6, rows, delimiter) {
24505
- (0, import_node_fs8.writeFileSync)(path6, import_papaparse3.default.unparse(rows.length ? rows : [{}], { delimiter }));
26190
+ (0, import_node_fs9.writeFileSync)(path6, import_papaparse3.default.unparse(rows.length ? rows : [{}], { delimiter }));
24506
26191
  }
24507
26192
  function exportFanout(enriched) {
24508
26193
  const stamp = safe(enriched.capturedAt.replace(/[:.]/g, "-"));
24509
26194
  const outputDir = outputBaseDir3();
24510
- const relativeDir = (0, import_node_path11.join)("fanout", `${stamp}-${safe(enriched.platform)}`);
24511
- const dir = (0, import_node_path11.join)(outputDir, relativeDir);
24512
- (0, import_node_fs8.mkdirSync)(dir, { recursive: true });
26195
+ const relativeDir = (0, import_node_path12.join)("fanout", `${stamp}-${safe(enriched.platform)}`);
26196
+ const dir = (0, import_node_path12.join)(outputDir, relativeDir);
26197
+ (0, import_node_fs9.mkdirSync)(dir, { recursive: true });
24513
26198
  const queryRows = enriched.queries.map((q, i) => ({ index: i + 1, query: q }));
24514
26199
  const citationRows = enriched.aggregates.citationOrder.map((c) => {
24515
26200
  const src = enriched.citedUrls.find((s) => s.url === c.url);
@@ -24522,25 +26207,25 @@ function exportFanout(enriched) {
24522
26207
  const relativePaths = {
24523
26208
  relativeTo: "MCP_SCRAPER_OUTPUT_DIR or ~/Downloads/mcp-scraper",
24524
26209
  dir: relativeDir,
24525
- json: (0, import_node_path11.join)(relativeDir, "fanout.json"),
24526
- queriesCsv: (0, import_node_path11.join)(relativeDir, "queries.csv"),
24527
- queriesTsv: (0, import_node_path11.join)(relativeDir, "queries.tsv"),
24528
- citationsCsv: (0, import_node_path11.join)(relativeDir, "citations.csv"),
24529
- sourcesCsv: (0, import_node_path11.join)(relativeDir, "sources.csv"),
24530
- browsedOnlyCsv: (0, import_node_path11.join)(relativeDir, "browsed-only.csv"),
24531
- snippetsCsv: (0, import_node_path11.join)(relativeDir, "snippets.csv"),
24532
- domainsCsv: (0, import_node_path11.join)(relativeDir, "domains.csv"),
24533
- report: (0, import_node_path11.join)(relativeDir, "report.html")
26210
+ json: (0, import_node_path12.join)(relativeDir, "fanout.json"),
26211
+ queriesCsv: (0, import_node_path12.join)(relativeDir, "queries.csv"),
26212
+ queriesTsv: (0, import_node_path12.join)(relativeDir, "queries.tsv"),
26213
+ citationsCsv: (0, import_node_path12.join)(relativeDir, "citations.csv"),
26214
+ sourcesCsv: (0, import_node_path12.join)(relativeDir, "sources.csv"),
26215
+ browsedOnlyCsv: (0, import_node_path12.join)(relativeDir, "browsed-only.csv"),
26216
+ snippetsCsv: (0, import_node_path12.join)(relativeDir, "snippets.csv"),
26217
+ domainsCsv: (0, import_node_path12.join)(relativeDir, "domains.csv"),
26218
+ report: (0, import_node_path12.join)(relativeDir, "report.html")
24534
26219
  };
24535
- (0, import_node_fs8.writeFileSync)((0, import_node_path11.join)(outputDir, relativePaths.json), JSON.stringify(enriched, null, 2));
24536
- writeTable((0, import_node_path11.join)(outputDir, relativePaths.queriesCsv), queryRows, ",");
24537
- writeTable((0, import_node_path11.join)(outputDir, relativePaths.queriesTsv), queryRows, " ");
24538
- writeTable((0, import_node_path11.join)(outputDir, relativePaths.citationsCsv), citationRows, ",");
24539
- writeTable((0, import_node_path11.join)(outputDir, relativePaths.sourcesCsv), sourceRows2, ",");
24540
- writeTable((0, import_node_path11.join)(outputDir, relativePaths.browsedOnlyCsv), browsedOnlyRows, ",");
24541
- writeTable((0, import_node_path11.join)(outputDir, relativePaths.snippetsCsv), snippetRows, ",");
24542
- writeTable((0, import_node_path11.join)(outputDir, relativePaths.domainsCsv), domainRows, ",");
24543
- (0, import_node_fs8.writeFileSync)((0, import_node_path11.join)(outputDir, relativePaths.report), renderReportHtml(enriched));
26220
+ (0, import_node_fs9.writeFileSync)((0, import_node_path12.join)(outputDir, relativePaths.json), JSON.stringify(enriched, null, 2));
26221
+ writeTable((0, import_node_path12.join)(outputDir, relativePaths.queriesCsv), queryRows, ",");
26222
+ writeTable((0, import_node_path12.join)(outputDir, relativePaths.queriesTsv), queryRows, " ");
26223
+ writeTable((0, import_node_path12.join)(outputDir, relativePaths.citationsCsv), citationRows, ",");
26224
+ writeTable((0, import_node_path12.join)(outputDir, relativePaths.sourcesCsv), sourceRows2, ",");
26225
+ writeTable((0, import_node_path12.join)(outputDir, relativePaths.browsedOnlyCsv), browsedOnlyRows, ",");
26226
+ writeTable((0, import_node_path12.join)(outputDir, relativePaths.snippetsCsv), snippetRows, ",");
26227
+ writeTable((0, import_node_path12.join)(outputDir, relativePaths.domainsCsv), domainRows, ",");
26228
+ (0, import_node_fs9.writeFileSync)((0, import_node_path12.join)(outputDir, relativePaths.report), renderReportHtml(enriched));
24544
26229
  return relativePaths;
24545
26230
  }
24546
26231
  function esc(s) {
@@ -24618,13 +26303,13 @@ render();
24618
26303
  </script>
24619
26304
  </body></html>`;
24620
26305
  }
24621
- var import_node_fs8, import_node_os6, import_node_path11, import_papaparse3;
26306
+ var import_node_fs9, import_node_os7, import_node_path12, import_papaparse3;
24622
26307
  var init_export = __esm({
24623
26308
  "src/services/fanout/export.ts"() {
24624
26309
  "use strict";
24625
- import_node_fs8 = require("fs");
24626
- import_node_os6 = require("os");
24627
- import_node_path11 = require("path");
26310
+ import_node_fs9 = require("fs");
26311
+ import_node_os7 = require("os");
26312
+ import_node_path12 = require("path");
24628
26313
  import_papaparse3 = __toESM(require("papaparse"), 1);
24629
26314
  }
24630
26315
  });
@@ -24672,7 +26357,7 @@ async function runFanoutCapture(page, input) {
24672
26357
  }
24673
26358
  const adapter = adapterForHost(hostname);
24674
26359
  if (!adapter) {
24675
- throw new Error(`browser_capture_fanout supports chatgpt.com and claude.ai only; the session is on ${hostname || "an unknown page"}. Navigate there first with browser_goto.`);
26360
+ throw new Error(`query_fanout_workflow supports chatgpt.com and claude.ai only; the session is on ${hostname || "an unknown page"}. Navigate there first with browser_goto.`);
24676
26361
  }
24677
26362
  const text = cap.bestAnswerText();
24678
26363
  const parsed = adapter.parse(text, input.prompt || "");
@@ -24710,7 +26395,7 @@ function keepHostedBrowserAlive(_browser) {
24710
26395
  function client() {
24711
26396
  const apiKey = browserServiceApiKey();
24712
26397
  if (!apiKey) throw new Error("Browser backend API key is required");
24713
- return new import_sdk6.default({ apiKey });
26398
+ return new import_sdk7.default({ apiKey });
24714
26399
  }
24715
26400
  function isProfileConflict(err) {
24716
26401
  const message = err instanceof Error ? err.message : String(err);
@@ -24862,7 +26547,7 @@ async function pressKeys(runtimeSessionId, keys) {
24862
26547
  await k.browsers.computer.pressKey(runtimeSessionId, { keys });
24863
26548
  }
24864
26549
  async function goto(cdpWsUrl, url) {
24865
- const browser = await import_playwright4.chromium.connectOverCDP(cdpWsUrl);
26550
+ const browser = await import_playwright5.chromium.connectOverCDP(cdpWsUrl);
24866
26551
  try {
24867
26552
  const context = browser.contexts()[0] ?? await browser.newContext();
24868
26553
  const page = context.pages()[0] ?? await context.newPage();
@@ -24873,7 +26558,7 @@ async function goto(cdpWsUrl, url) {
24873
26558
  }
24874
26559
  }
24875
26560
  async function captureFanout(cdpWsUrl, input) {
24876
- const browser = await import_playwright4.chromium.connectOverCDP(cdpWsUrl);
26561
+ const browser = await import_playwright5.chromium.connectOverCDP(cdpWsUrl);
24877
26562
  try {
24878
26563
  const context = browser.contexts()[0] ?? await browser.newContext();
24879
26564
  const page = context.pages()[0] ?? await context.newPage();
@@ -24883,7 +26568,7 @@ async function captureFanout(cdpWsUrl, input) {
24883
26568
  }
24884
26569
  }
24885
26570
  async function readPage(cdpWsUrl) {
24886
- const browser = await import_playwright4.chromium.connectOverCDP(cdpWsUrl);
26571
+ const browser = await import_playwright5.chromium.connectOverCDP(cdpWsUrl);
24887
26572
  try {
24888
26573
  const context = browser.contexts()[0] ?? await browser.newContext();
24889
26574
  const page = context.pages()[0] ?? await context.newPage();
@@ -24938,7 +26623,7 @@ async function readPage(cdpWsUrl) {
24938
26623
  }
24939
26624
  }
24940
26625
  async function locatePageTargets(cdpWsUrl, targets) {
24941
- const browser = await import_playwright4.chromium.connectOverCDP(cdpWsUrl);
26626
+ const browser = await import_playwright5.chromium.connectOverCDP(cdpWsUrl);
24942
26627
  try {
24943
26628
  const context = browser.contexts()[0] ?? await browser.newContext();
24944
26629
  const page = context.pages()[0] ?? await context.newPage();
@@ -24950,16 +26635,16 @@ async function locatePageTargets(cdpWsUrl, targets) {
24950
26635
  }).catch(() => {
24951
26636
  });
24952
26637
  const data = await page.evaluate((rawTargets) => {
24953
- const normalize2 = (value) => value.replace(/\s+/g, " ").trim();
26638
+ const normalize3 = (value) => value.replace(/\s+/g, " ").trim();
24954
26639
  const isVisibleRect = (r) => r.width >= 1 && r.height >= 1 && r.bottom >= 0 && r.right >= 0 && r.top <= window.innerHeight && r.left <= window.innerWidth;
24955
26640
  const roleOf = (el2) => el2.getAttribute("role") || el2.tagName.toLowerCase();
24956
26641
  const nameOf = (el2) => {
24957
26642
  const e = el2;
24958
- return normalize2(
26643
+ return normalize3(
24959
26644
  e.getAttribute("aria-label") || e.placeholder || e.innerText || e.getAttribute("value") || e.getAttribute("title") || ""
24960
26645
  ).slice(0, 160);
24961
26646
  };
24962
- const textOf = (el2) => normalize2(el2.innerText || el2.textContent || "").slice(0, 500);
26647
+ const textOf = (el2) => normalize3(el2.innerText || el2.textContent || "").slice(0, 500);
24963
26648
  const unionRects = (rects) => {
24964
26649
  const visible = rects.filter(isVisibleRect);
24965
26650
  if (!visible.length) return null;
@@ -24998,7 +26683,7 @@ async function locatePageTargets(cdpWsUrl, targets) {
24998
26683
  return nodes.map((el2) => ({ el: el2, rect: el2.getBoundingClientRect() })).filter((item) => isVisibleRect(item.rect));
24999
26684
  };
25000
26685
  const textMatches = (needle, match) => {
25001
- const wanted = normalize2(needle);
26686
+ const wanted = normalize3(needle);
25002
26687
  const wantedLower = wanted.toLowerCase();
25003
26688
  if (!wantedLower) return [];
25004
26689
  const matches = [];
@@ -25006,7 +26691,7 @@ async function locatePageTargets(cdpWsUrl, targets) {
25006
26691
  let node = walker.nextNode();
25007
26692
  while (node) {
25008
26693
  const raw = node.textContent || "";
25009
- const normalizedRaw = normalize2(raw);
26694
+ const normalizedRaw = normalize3(raw);
25010
26695
  const rawLower = raw.toLowerCase();
25011
26696
  const normalizedLower = normalizedRaw.toLowerCase();
25012
26697
  const ok = match === "exact" ? normalizedLower === wantedLower : normalizedLower.includes(wantedLower);
@@ -25102,12 +26787,12 @@ async function replayList(runtimeSessionId) {
25102
26787
  finishedAt: r.finished_at ?? null
25103
26788
  }));
25104
26789
  }
25105
- var import_sdk6, import_playwright4, DEFAULT_TIMEOUT_SECONDS;
26790
+ var import_sdk7, import_playwright5, DEFAULT_TIMEOUT_SECONDS;
25106
26791
  var init_browser_agent_service = __esm({
25107
26792
  "src/services/browser-agent/browser-agent-service.ts"() {
25108
26793
  "use strict";
25109
- import_sdk6 = __toESM(require("@onkernel/sdk"), 1);
25110
- import_playwright4 = require("playwright");
26794
+ import_sdk7 = __toESM(require("@onkernel/sdk"), 1);
26795
+ import_playwright5 = require("playwright");
25111
26796
  init_browser_service_env();
25112
26797
  init_run_capture();
25113
26798
  DEFAULT_TIMEOUT_SECONDS = 600;
@@ -26108,14 +27793,14 @@ function getSessionSecret() {
26108
27793
  function safeEqualHex(a, b) {
26109
27794
  if (a.length !== b.length) return false;
26110
27795
  try {
26111
- return (0, import_node_crypto6.timingSafeEqual)(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
27796
+ return (0, import_node_crypto7.timingSafeEqual)(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
26112
27797
  } catch {
26113
27798
  return false;
26114
27799
  }
26115
27800
  }
26116
27801
  function signSession(userId) {
26117
27802
  const payload = String(userId);
26118
- const sig = (0, import_node_crypto6.createHmac)("sha256", secret()).update(payload).digest("hex");
27803
+ const sig = (0, import_node_crypto7.createHmac)("sha256", secret()).update(payload).digest("hex");
26119
27804
  return `${payload}.${sig}`;
26120
27805
  }
26121
27806
  function verifySession(token) {
@@ -26123,16 +27808,16 @@ function verifySession(token) {
26123
27808
  if (dot === -1) return null;
26124
27809
  const payload = token.slice(0, dot);
26125
27810
  const sig = token.slice(dot + 1);
26126
- const expected = (0, import_node_crypto6.createHmac)("sha256", secret()).update(payload).digest("hex");
27811
+ const expected = (0, import_node_crypto7.createHmac)("sha256", secret()).update(payload).digest("hex");
26127
27812
  if (!safeEqualHex(sig, expected)) return null;
26128
27813
  const id = parseInt(payload);
26129
27814
  return isNaN(id) ? null : id;
26130
27815
  }
26131
- var import_node_crypto6, isProduction, secret;
27816
+ var import_node_crypto7, isProduction, secret;
26132
27817
  var init_session = __esm({
26133
27818
  "src/api/session.ts"() {
26134
27819
  "use strict";
26135
- import_node_crypto6 = require("crypto");
27820
+ import_node_crypto7 = require("crypto");
26136
27821
  isProduction = () => process.env.NODE_ENV === "production" || process.env.VERCEL === "1";
26137
27822
  secret = () => getSessionSecret();
26138
27823
  }
@@ -26379,6 +28064,9 @@ var init_server = __esm({
26379
28064
  import_hono14 = require("inngest/hono");
26380
28065
  init_client();
26381
28066
  init_site_audit();
28067
+ init_site_extract();
28068
+ init_site_extract_repository();
28069
+ init_catalog_seed();
26382
28070
  init_site_audit_routes();
26383
28071
  init_youtube_routes();
26384
28072
  init_screenshot_routes();
@@ -26563,6 +28251,10 @@ var init_server = __esm({
26563
28251
  const taken = await countActiveUsers();
26564
28252
  return c.json({ taken, open: true });
26565
28253
  });
28254
+ app.get("/catalog", (c) => {
28255
+ c.header("Cache-Control", "public, max-age=60");
28256
+ return c.json(CATALOG);
28257
+ });
26566
28258
  app.get("/me", async (c) => {
26567
28259
  const token = (0, import_cookie.getCookie)(c, "session");
26568
28260
  if (!token) return c.json({ authenticated: false });
@@ -26797,7 +28489,7 @@ var init_server = __esm({
26797
28489
  const raw = await c.req.json().catch(() => ({}));
26798
28490
  const bodyResult = ExtractUrlBodySchema.safeParse(raw);
26799
28491
  if (!bodyResult.success) return c.json({ error: bodyResult.error.issues[0]?.message ?? "Invalid request" }, 400);
26800
- const { url, screenshot: screenshot2, screenshotDevice, extractBranding, downloadMedia, mediaTypes, allowLocal } = bodyResult.data;
28492
+ const { url, screenshot: screenshot2, screenshotDevice, extractBranding: extractBranding2, downloadMedia, mediaTypes, allowLocal } = bodyResult.data;
26801
28493
  if (!allowLocal) {
26802
28494
  const checked = await validatePublicHttpUrl(url, { field: "URL" });
26803
28495
  if (checked.error || !checked.parsed) return c.json({ error: checked.error ?? "Invalid URL" }, 400);
@@ -26830,7 +28522,7 @@ var init_server = __esm({
26830
28522
  const device = screenshotDevice === "mobile" ? "mobile" : "desktop";
26831
28523
  const [result, pageData] = await Promise.all([
26832
28524
  extractKpo({ url: canonicalUrl, kernelApiKey }),
26833
- screenshot2 || extractBranding ? capturePageData(canonicalUrl, { kernelApiKey, device, screenshot: !!screenshot2, branding: !!extractBranding }).catch((err) => {
28525
+ screenshot2 || extractBranding2 ? capturePageData(canonicalUrl, { kernelApiKey, device, screenshot: !!screenshot2, branding: !!extractBranding2 }).catch((err) => {
26834
28526
  console.error("[extract-url] capturePageData failed:", err instanceof Error ? err.message : err);
26835
28527
  return null;
26836
28528
  }) : null
@@ -26874,7 +28566,7 @@ var init_server = __esm({
26874
28566
  debited = true;
26875
28567
  const result = await spiderSite({
26876
28568
  startUrl: parsed.href,
26877
- maxUrls: Math.min(2e3, Math.max(1, body.maxUrls ?? 500)),
28569
+ maxUrls: Math.min(1e4, Math.max(1, body.maxUrls ?? 500)),
26878
28570
  concurrency: Math.min(20, Math.max(1, body.concurrency ?? 12)),
26879
28571
  kernelApiKey: body.browserFallback ?? body.kernelFallback ? browserServiceApiKey() : void 0
26880
28572
  });
@@ -26911,7 +28603,43 @@ var init_server = __esm({
26911
28603
  if (checked.error || !checked.parsed) return c.json({ error: checked.error ?? "Invalid URL" }, 400);
26912
28604
  const parsed = checked.parsed;
26913
28605
  const user = c.get("user");
26914
- const siteHoldMc = MC_COSTS.page_scrape * 10;
28606
+ if (body.background === true) {
28607
+ const affordablePages = Math.floor(user.balance_mc / MC_COSTS.page_scrape);
28608
+ if (affordablePages < 1) return c.json(insufficientBalanceResponse(user.balance_mc, MC_COSTS.page_scrape), 402);
28609
+ const maxPages = Math.min(Math.min(1e4, Math.max(1, body.maxPages ?? 1e4)), affordablePages);
28610
+ const heldMc = maxPages * MC_COSTS.page_scrape;
28611
+ const { ok: holdOk, balance_mc: holdBal } = await debitMc(user.id, heldMc, LedgerOperation.EXTRACT_SITE_HOLD, parsed.hostname);
28612
+ if (!holdOk) return c.json(insufficientBalanceResponse(holdBal, heldMc), 402);
28613
+ const jobId = `ext_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
28614
+ let jobCreated = false;
28615
+ try {
28616
+ await createExtractJob(jobId, user.id, parsed.href, {
28617
+ maxPages,
28618
+ concurrency: concurrencyLimitForUser(user),
28619
+ urlsPerBrowser: body.rotateProxyEvery ?? 10,
28620
+ formats: body.formats,
28621
+ heldMc
28622
+ });
28623
+ jobCreated = true;
28624
+ await inngest.send({ name: "mcp-scraper/extract.requested", data: { jobId } });
28625
+ } catch (err) {
28626
+ if (jobCreated) {
28627
+ await settleExtractJob(jobId, user.id, heldMc, 0, "enqueue failed").catch(() => {
28628
+ });
28629
+ } else {
28630
+ await creditMc(user.id, heldMc, LedgerOperation.EXTRACT_SITE_REFUND, "enqueue failed").catch(() => {
28631
+ });
28632
+ }
28633
+ await failExtractJob(jobId, err instanceof Error ? err.message : "enqueue failed").catch(() => {
28634
+ });
28635
+ return c.json({ error: "Failed to enqueue background crawl" }, 503);
28636
+ }
28637
+ return c.json({ jobId, status: "pending", statusUrl: `/extract-site/status/${jobId}` });
28638
+ }
28639
+ const affordablePagesSync = Math.floor(user.balance_mc / MC_COSTS.page_scrape);
28640
+ if (affordablePagesSync < 1) return c.json(insufficientBalanceResponse(user.balance_mc, MC_COSTS.page_scrape), 402);
28641
+ const siteMaxPages = Math.min(Math.min(1e4, Math.max(1, body.maxPages ?? 100)), affordablePagesSync);
28642
+ const siteHoldMc = siteMaxPages * MC_COSTS.page_scrape;
26915
28643
  const gate = await acquireConcurrencyGate(user, "extract_site", {
26916
28644
  reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
26917
28645
  metadata: { url: parsed.href }
@@ -26922,10 +28650,17 @@ var init_server = __esm({
26922
28650
  const { ok: siteOk, balance_mc: siteBal } = await debitMc(user.id, siteHoldMc, LedgerOperation.EXTRACT_SITE_HOLD, parsed.hostname);
26923
28651
  if (!siteOk) return c.json(insufficientBalanceResponse(siteBal, siteHoldMc), 402);
26924
28652
  debited = true;
28653
+ const rotateProxies = body.rotateProxies === true;
28654
+ const wantsBranding = body.formats?.includes("branding") === true;
26925
28655
  const result = await extractSite({
26926
28656
  startUrl: parsed.href,
26927
- maxPages: Math.min(200, Math.max(1, body.maxPages ?? 100)),
26928
- kernelApiKey: body.browserFallback ?? body.kernelFallback ? browserServiceApiKey() : void 0
28657
+ maxPages: siteMaxPages,
28658
+ kernelApiKey: rotateProxies || wantsBranding || body.browserFallback || body.kernelFallback ? browserServiceApiKey() : void 0,
28659
+ formats: body.formats,
28660
+ ...rotateProxies ? {
28661
+ rotateProxyEvery: body.rotateProxyEvery ?? 30,
28662
+ parallelism: concurrencyLimitForUser(user)
28663
+ } : {}
26929
28664
  });
26930
28665
  const pageCount = result.pages?.length ?? 1;
26931
28666
  const actualSiteMc = pageCount * MC_COSTS.page_scrape;
@@ -26956,6 +28691,21 @@ var init_server = __esm({
26956
28691
  await releaseConcurrencyGate(gate.lockId);
26957
28692
  }
26958
28693
  });
28694
+ app.get("/extract-site/status/:id", auth2, async (c) => {
28695
+ const user = c.get("user");
28696
+ const job = await getExtractJob(c.req.param("id"));
28697
+ if (!job || job.userId !== user.id) return c.json({ error: "Job not found" }, 404);
28698
+ return c.json({
28699
+ jobId: job.id,
28700
+ status: job.status,
28701
+ startUrl: job.startUrl,
28702
+ totalUrls: job.totalUrls,
28703
+ doneUrls: job.doneUrls,
28704
+ artifacts: job.artifacts ?? [],
28705
+ error: job.error,
28706
+ updatedAt: job.updatedAt
28707
+ });
28708
+ });
26959
28709
  app.post("/billing/checkout", requireAllowedOrigin, sessionAuth, async (c) => {
26960
28710
  try {
26961
28711
  const user = c.get("sessionUser");
@@ -27144,7 +28894,7 @@ var init_server = __esm({
27144
28894
  ]);
27145
28895
  return c.json({ drained: results.length, results, sweepResult, workflowDispatch: workflowDispatchResult });
27146
28896
  });
27147
- app.on(["GET", "POST", "PUT"], "/api/inngest", (0, import_hono14.serve)({ client: inngest, functions: [siteAuditFn] }));
28897
+ app.on(["GET", "POST", "PUT"], "/api/inngest", (0, import_hono14.serve)({ client: inngest, functions: [siteAuditFn, siteExtractFn] }));
27148
28898
  app.route("/api/internal/site-architecture-auditor", siteAuditApp);
27149
28899
  app.route("/youtube", youtubeApp);
27150
28900
  app.route("/screenshot", screenshotApp);
@@ -27266,10 +29016,10 @@ ${ATTRIBUTION_PIXEL_SCRIPT}
27266
29016
  });
27267
29017
 
27268
29018
  // bin/api-server.ts
27269
- var import_node_fs9 = require("fs");
29019
+ var import_node_fs10 = require("fs");
27270
29020
  function loadDotEnv() {
27271
29021
  try {
27272
- for (const line of (0, import_node_fs9.readFileSync)(".env", "utf8").split("\n")) {
29022
+ for (const line of (0, import_node_fs10.readFileSync)(".env", "utf8").split("\n")) {
27273
29023
  const eq = line.indexOf("=");
27274
29024
  if (eq < 1 || line.trimStart().startsWith("#")) continue;
27275
29025
  const k = line.slice(0, eq).trim();