mcp-scraper 0.3.14 → 0.3.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/bin/api-server.cjs +1991 -508
  2. package/dist/bin/api-server.cjs.map +1 -1
  3. package/dist/bin/api-server.js +3 -3
  4. package/dist/bin/browser-agent-stdio-server.cjs +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 +425 -44
  11. package/dist/bin/mcp-scraper-combined-stdio-server.cjs.map +1 -1
  12. package/dist/bin/mcp-scraper-combined-stdio-server.js +6 -5
  13. package/dist/bin/mcp-scraper-combined-stdio-server.js.map +1 -1
  14. package/dist/bin/mcp-scraper-install.cjs +2 -2
  15. package/dist/bin/mcp-scraper-install.cjs.map +1 -1
  16. package/dist/bin/mcp-scraper-install.js +2 -2
  17. package/dist/bin/mcp-stdio-server.cjs +419 -38
  18. package/dist/bin/mcp-stdio-server.cjs.map +1 -1
  19. package/dist/bin/mcp-stdio-server.js +2 -2
  20. package/dist/chunk-2MX5WOII.js +7 -0
  21. package/dist/chunk-2MX5WOII.js.map +1 -0
  22. package/dist/{chunk-DBQDG7EH.js → chunk-5H22TOXQ.js} +28 -1
  23. package/dist/chunk-5H22TOXQ.js.map +1 -0
  24. package/dist/{chunk-KPXMPAJ3.js → chunk-7Y6JDBML.js} +2 -2
  25. package/dist/chunk-7Y6JDBML.js.map +1 -0
  26. package/dist/{chunk-AX7UBYLG.js → chunk-WJ2LWHPA.js} +7 -7
  27. package/dist/chunk-WJ2LWHPA.js.map +1 -0
  28. package/dist/{chunk-HM6FHV5U.js → chunk-WQAIOY2D.js} +424 -39
  29. package/dist/chunk-WQAIOY2D.js.map +1 -0
  30. package/dist/{chunk-AZ5PKU4F.js → chunk-ZAUMSBV3.js} +2 -2
  31. package/dist/{db-BE4JVB3V.js → db-P76GVIFN.js} +2 -2
  32. package/dist/{server-ILIVSPNY.js → server-UOQ2JIUQ.js} +1106 -93
  33. package/dist/server-UOQ2JIUQ.js.map +1 -0
  34. package/dist/{worker-FG7ZWEGA.js → worker-2WVKKCC7.js} +3 -3
  35. package/docs/final-tooling-spec.md +206 -0
  36. package/docs/mcp-tool-design-guide.md +225 -0
  37. package/docs/mcp-tool-manifest.generated.json +13 -13
  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,257 @@ 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/blob-store.ts
9172
+ function byteLength(data) {
9173
+ return Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data);
9174
+ }
9175
+ function localBaseDir() {
9176
+ return process.env.MCP_SCRAPER_OUTPUT_DIR?.trim() || (0, import_node_path2.join)((0, import_node_os2.homedir)(), "Downloads", "mcp-scraper");
9177
+ }
9178
+ function getBlobStore() {
9179
+ if (cached) return cached;
9180
+ if (process.env.BLOB_READ_WRITE_TOKEN) {
9181
+ cached = new VercelBlobStore(process.env.BLOB_READ_WRITE_TOKEN);
9182
+ } else {
9183
+ cached = new LocalBlobStore(localBaseDir());
9184
+ }
9185
+ return cached;
9186
+ }
9187
+ var import_node_fs2, import_node_os2, import_node_path2, LocalBlobStore, cached, VercelBlobStore;
9188
+ var init_blob_store = __esm({
9189
+ "src/api/blob-store.ts"() {
9190
+ "use strict";
9191
+ import_node_fs2 = require("fs");
9192
+ import_node_os2 = require("os");
9193
+ import_node_path2 = require("path");
9194
+ LocalBlobStore = class {
9195
+ constructor(baseDir) {
9196
+ this.baseDir = baseDir;
8612
9197
  }
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);
9198
+ baseDir;
9199
+ kind = "local";
9200
+ async put(key, data, contentType = "application/octet-stream") {
9201
+ const path6 = (0, import_node_path2.join)(this.baseDir, "blobs", key);
9202
+ (0, import_node_fs2.mkdirSync)((0, import_node_path2.dirname)(path6), { recursive: true });
9203
+ (0, import_node_fs2.writeFileSync)(path6, data);
9204
+ return { key, url: `file://${path6}`, bytes: byteLength(data), contentType };
8623
9205
  }
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
- }
9206
+ };
9207
+ cached = null;
9208
+ VercelBlobStore = class {
9209
+ constructor(token) {
9210
+ this.token = token;
9211
+ }
9212
+ token;
9213
+ kind = "vercel-blob";
9214
+ async put(key, data, contentType = "application/octet-stream") {
9215
+ const { put } = await import("@vercel/blob");
9216
+ const body = Buffer.isBuffer(data) ? data : Buffer.from(data);
9217
+ const result = await put(key, body, {
9218
+ access: "public",
9219
+ token: this.token,
9220
+ contentType,
9221
+ addRandomSuffix: true
9222
+ });
9223
+ return { key, url: result.url, bytes: byteLength(data), contentType };
8640
9224
  }
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
- });
9225
+ };
8777
9226
  }
8778
9227
  });
8779
9228
 
@@ -9023,6 +9472,766 @@ var init_rates = __esm({
9023
9472
  }
9024
9473
  });
9025
9474
 
9475
+ // src/api/site-extract-repository.ts
9476
+ function rowToJob(r) {
9477
+ return {
9478
+ id: String(r.id),
9479
+ userId: r.user_id != null ? Number(r.user_id) : null,
9480
+ status: r.status != null ? String(r.status) : "pending",
9481
+ startUrl: String(r.start_url ?? ""),
9482
+ options: r.options ? JSON.parse(String(r.options)) : {},
9483
+ totalUrls: Number(r.total_urls ?? 0),
9484
+ doneUrls: Number(r.done_urls ?? 0),
9485
+ artifacts: r.artifacts ? JSON.parse(String(r.artifacts)) : null,
9486
+ error: r.error != null ? String(r.error) : null,
9487
+ billedMc: r.billed_mc != null ? Number(r.billed_mc) : null,
9488
+ createdAt: String(r.created_at ?? ""),
9489
+ updatedAt: String(r.updated_at ?? "")
9490
+ };
9491
+ }
9492
+ async function createExtractJob(jobId, userId, startUrl, options) {
9493
+ const db = getDb();
9494
+ await db.execute({
9495
+ sql: `INSERT INTO site_extract_jobs (id, user_id, status, start_url, options, created_at, updated_at)
9496
+ VALUES (?, ?, 'pending', ?, ?, datetime('now'), datetime('now'))`,
9497
+ args: [jobId, userId, startUrl, JSON.stringify(options)]
9498
+ });
9499
+ }
9500
+ async function getExtractJob(jobId) {
9501
+ const db = getDb();
9502
+ const res = await db.execute({ sql: `SELECT * FROM site_extract_jobs WHERE id = ?`, args: [jobId] });
9503
+ return res.rows[0] ? rowToJob(res.rows[0]) : null;
9504
+ }
9505
+ async function setExtractJobTotal(jobId, totalUrls) {
9506
+ const db = getDb();
9507
+ await db.execute({
9508
+ sql: `UPDATE site_extract_jobs SET status = 'running', total_urls = ?, updated_at = datetime('now') WHERE id = ?`,
9509
+ args: [totalUrls, jobId]
9510
+ });
9511
+ }
9512
+ async function saveExtractPages(jobId, pages) {
9513
+ if (pages.length === 0) return;
9514
+ const db = getDb();
9515
+ await db.batch(
9516
+ pages.map((p) => ({
9517
+ sql: `INSERT OR REPLACE INTO site_extract_pages (job_id, url, page) VALUES (?, ?, ?)`,
9518
+ args: [jobId, p.url, JSON.stringify({ ...p, bodyMarkdown: "", schema: [] })]
9519
+ }))
9520
+ );
9521
+ await db.execute({
9522
+ sql: `UPDATE site_extract_jobs
9523
+ SET done_urls = (SELECT COUNT(*) FROM site_extract_pages WHERE job_id = ?), updated_at = datetime('now')
9524
+ WHERE id = ?`,
9525
+ args: [jobId, jobId]
9526
+ });
9527
+ }
9528
+ async function getExtractedPages(jobId) {
9529
+ const db = getDb();
9530
+ const res = await db.execute({ sql: `SELECT page FROM site_extract_pages WHERE job_id = ?`, args: [jobId] });
9531
+ return res.rows.map((r) => JSON.parse(String(r.page)));
9532
+ }
9533
+ async function countSuccessfulPages(jobId) {
9534
+ const db = getDb();
9535
+ const res = await db.execute({
9536
+ sql: `SELECT COUNT(*) AS n FROM site_extract_pages
9537
+ WHERE job_id = ? AND json_extract(page, '$.status') = 200 AND json_extract(page, '$.wordCount') > 0`,
9538
+ args: [jobId]
9539
+ });
9540
+ return Number(res.rows[0]?.n ?? 0);
9541
+ }
9542
+ async function completeExtractJob(jobId, artifacts) {
9543
+ const db = getDb();
9544
+ await db.execute({
9545
+ sql: `UPDATE site_extract_jobs SET status = 'complete', artifacts = ?, updated_at = datetime('now') WHERE id = ?`,
9546
+ args: [JSON.stringify(artifacts ?? []), jobId]
9547
+ });
9548
+ }
9549
+ async function failExtractJob(jobId, error) {
9550
+ const db = getDb();
9551
+ await db.execute({
9552
+ sql: `UPDATE site_extract_jobs SET status = 'failed', error = ?, updated_at = datetime('now') WHERE id = ?`,
9553
+ args: [sanitizeVendorName(error).slice(0, 2e3), jobId]
9554
+ });
9555
+ }
9556
+ async function settleExtractJob(jobId, userId, refundMc, netChargeMc, reference) {
9557
+ const db = getDb();
9558
+ const job = await getExtractJob(jobId);
9559
+ if (!job || job.billedMc != null) return;
9560
+ if (refundMc <= 0) {
9561
+ 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] });
9562
+ return;
9563
+ }
9564
+ await db.batch([
9565
+ { sql: "UPDATE users SET balance_mc = balance_mc + ? WHERE id = ?", args: [refundMc, userId] },
9566
+ { sql: "INSERT INTO ledger (user_id, amount_mc, operation, description) VALUES (?, ?, ?, ?)", args: [userId, refundMc, LedgerOperation.EXTRACT_SITE_REFUND, reference] },
9567
+ { 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] }
9568
+ ]);
9569
+ }
9570
+ var init_site_extract_repository = __esm({
9571
+ "src/api/site-extract-repository.ts"() {
9572
+ "use strict";
9573
+ init_db();
9574
+ init_errors();
9575
+ init_rates();
9576
+ }
9577
+ });
9578
+
9579
+ // src/inngest/functions/site-extract.ts
9580
+ function chunk(items, size) {
9581
+ const out = [];
9582
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
9583
+ return out;
9584
+ }
9585
+ function buildArtifacts(job, pages, branding) {
9586
+ const { edges, metrics } = buildLinkGraph(pages, job.startUrl);
9587
+ const issues = computeIssues(pages, metrics);
9588
+ const reportMd = renderIssueReport(job.startUrl, pages, issues, metrics);
9589
+ const pageRows = pages.map((p) => {
9590
+ const { bodyMarkdown: _b, schema: _s, outlinks: _o, ...rest } = p;
9591
+ const m = metrics.get(p.url);
9592
+ return { ...rest, inlinks: m?.inlinks ?? 0, crawlDepth: m?.crawlDepth ?? null, orphan: m?.orphan ?? false };
9593
+ });
9594
+ const jsonl = (rows) => rows.map((r) => JSON.stringify(r)).join("\n");
9595
+ const files = [
9596
+ { name: "report.md", body: reportMd, type: "text/markdown" },
9597
+ { name: "issues.json", body: JSON.stringify(issues, null, 2), type: "application/json" },
9598
+ { name: "pages.jsonl", body: jsonl(pageRows), type: "application/x-ndjson" },
9599
+ { name: "links.jsonl", body: jsonl(edges), type: "application/x-ndjson" },
9600
+ { name: "link-metrics.jsonl", body: jsonl([...metrics.values()]), type: "application/x-ndjson" }
9601
+ ];
9602
+ if (branding) files.push({ name: "branding.json", body: JSON.stringify(branding, null, 2), type: "application/json" });
9603
+ return files;
9604
+ }
9605
+ var BATCH, siteExtractFn;
9606
+ var init_site_extract = __esm({
9607
+ "src/inngest/functions/site-extract.ts"() {
9608
+ "use strict";
9609
+ init_client();
9610
+ init_site_extractor();
9611
+ init_seo_link_graph();
9612
+ init_seo_issues();
9613
+ init_blob_store();
9614
+ init_screenshot();
9615
+ init_browser_service_env();
9616
+ init_rates();
9617
+ init_site_extract_repository();
9618
+ BATCH = 30;
9619
+ siteExtractFn = inngest.createFunction(
9620
+ {
9621
+ id: "site-extract",
9622
+ retries: 2,
9623
+ triggers: [{ event: "mcp-scraper/extract.requested" }],
9624
+ onFailure: async ({ event }) => {
9625
+ const jobId = event?.data?.event?.data?.jobId;
9626
+ if (!jobId) return;
9627
+ const current = await getExtractJob(jobId);
9628
+ if (current && current.userId != null && current.billedMc == null) {
9629
+ const heldMc = Number(current.options.heldMc ?? 0);
9630
+ await settleExtractJob(jobId, current.userId, heldMc, 0, "crawl failed refund").catch(() => {
9631
+ });
9632
+ }
9633
+ await failExtractJob(jobId, String(event?.data?.error?.message ?? "crawl failed")).catch(() => {
9634
+ });
9635
+ }
9636
+ },
9637
+ async ({ event, step }) => {
9638
+ const jobId = event.data.jobId;
9639
+ const job = await step.run("load-job", () => getExtractJob(jobId));
9640
+ if (!job) return { jobId, status: "missing" };
9641
+ const key = browserServiceApiKey();
9642
+ if (!key) throw new Error("browser service key not configured");
9643
+ const maxPages = Number(job.options.maxPages ?? 1e4);
9644
+ const concurrency = Number(job.options.concurrency ?? 1);
9645
+ const urlsPerBrowser = Number(job.options.urlsPerBrowser ?? job.options.rotateProxyEvery ?? 10);
9646
+ const urls = await step.run("discover", async () => {
9647
+ const discovered = await discoverUrls(job.startUrl, maxPages, key);
9648
+ await setExtractJobTotal(jobId, discovered.length);
9649
+ return discovered;
9650
+ });
9651
+ const batches = chunk(urls, BATCH);
9652
+ for (let i = 0; i < batches.length; i++) {
9653
+ await step.run(`crawl-batch-${i}`, async () => {
9654
+ const pages = await extractPagesRotating(batches[i], { kernelApiKey: key, concurrency, urlsPerBrowser });
9655
+ await saveExtractPages(jobId, pages);
9656
+ return pages.length;
9657
+ });
9658
+ }
9659
+ const branding = Array.isArray(job.options.formats) && job.options.formats.includes("branding") ? await step.run("branding", () => extractBranding(job.startUrl, key).catch(() => null)) : null;
9660
+ const artifacts = await step.run("finalize", async () => {
9661
+ const pages = await getExtractedPages(jobId);
9662
+ const store = getBlobStore();
9663
+ const files = buildArtifacts(job, pages, branding);
9664
+ const stored = [];
9665
+ for (const f of files) stored.push(await store.put(`extract/${jobId}/${f.name}`, f.body, f.type));
9666
+ await completeExtractJob(jobId, stored);
9667
+ return stored;
9668
+ });
9669
+ await step.run("settle", async () => {
9670
+ const current = await getExtractJob(jobId);
9671
+ if (!current || current.billedMc != null || current.userId == null) return;
9672
+ const heldMc = Number(current.options.heldMc ?? 0);
9673
+ const successful = await countSuccessfulPages(jobId);
9674
+ const usedMc = Math.min(successful * MC_COSTS.page_scrape, heldMc);
9675
+ await settleExtractJob(jobId, current.userId, heldMc - usedMc, usedMc, new URL(job.startUrl).hostname);
9676
+ });
9677
+ return { jobId, status: "complete", artifacts };
9678
+ }
9679
+ );
9680
+ }
9681
+ });
9682
+
9683
+ // src/api/catalog-seed.ts
9684
+ var URL_PARAM, FORMATS_PARAM, CATALOG;
9685
+ var init_catalog_seed = __esm({
9686
+ "src/api/catalog-seed.ts"() {
9687
+ "use strict";
9688
+ URL_PARAM = { key: "url", label: "URL", type: "url", required: true, placeholder: "https://example.com" };
9689
+ FORMATS_PARAM = {
9690
+ key: "formats",
9691
+ label: "Formats",
9692
+ type: "multi_enum",
9693
+ default: ["markdown", "links"],
9694
+ enumValues: [
9695
+ { value: "markdown", label: "Markdown" },
9696
+ { value: "links", label: "Links" },
9697
+ { value: "json", label: "JSON / schema" },
9698
+ { value: "images", label: "Image links" },
9699
+ { value: "branding", label: "Branding" }
9700
+ ]
9701
+ };
9702
+ CATALOG = {
9703
+ groups: [
9704
+ {
9705
+ key: "youtube",
9706
+ label: "YouTube",
9707
+ icon: "youtube",
9708
+ tools: [
9709
+ {
9710
+ key: "youtube_harvest",
9711
+ label: "Search",
9712
+ kind: "simple",
9713
+ endpoint: "/youtube/harvest",
9714
+ mcpName: "youtube_harvest",
9715
+ tagline: "Find or list videos by topic or channel",
9716
+ params: [
9717
+ { key: "mode", label: "Mode", type: "enum", required: true, default: "search", enumValues: [{ value: "search", label: "Search" }, { value: "channel", label: "Channel" }] },
9718
+ { key: "query", label: "Query", type: "string", placeholder: "best local SEO 2026" },
9719
+ { key: "channelHandle", label: "Channel handle", type: "string", placeholder: "@mkbhd" },
9720
+ { key: "maxVideos", label: "Max videos", type: "number", min: 1, max: 500, default: 50, advanced: true }
9721
+ ],
9722
+ examples: ["Find YouTube videos about local SEO", "Harvest videos from @mkbhd"]
9723
+ },
9724
+ {
9725
+ key: "youtube_transcribe",
9726
+ label: "Transcribe",
9727
+ kind: "simple",
9728
+ endpoint: "/youtube/transcribe",
9729
+ mcpName: "youtube_transcribe",
9730
+ tagline: "Transcribe one video to text",
9731
+ params: [{ key: "url", label: "Video URL", type: "url", required: true, placeholder: "https://www.youtube.com/watch?v=\u2026" }],
9732
+ examples: ["Transcribe this YouTube URL"]
9733
+ }
9734
+ ]
9735
+ },
9736
+ {
9737
+ key: "facebook",
9738
+ label: "Facebook",
9739
+ icon: "facebook",
9740
+ tools: [
9741
+ {
9742
+ key: "facebook_ad_search",
9743
+ label: "Ad Search",
9744
+ kind: "simple",
9745
+ endpoint: "/facebook/search",
9746
+ mcpName: "facebook_ad_search",
9747
+ tagline: "Find ads in the Ad Library",
9748
+ params: [
9749
+ { key: "query", label: "Advertiser / query", type: "string", required: true },
9750
+ { key: "country", label: "Country", type: "string", default: "US" },
9751
+ { key: "maxResults", label: "Max results", type: "number", min: 1, max: 20, default: 10, advanced: true }
9752
+ ],
9753
+ examples: ["Find Facebook ads for this brand"]
9754
+ },
9755
+ {
9756
+ key: "facebook_video_transcribe",
9757
+ label: "Video Transcribe",
9758
+ kind: "simple",
9759
+ endpoint: "/facebook/video-transcribe",
9760
+ mcpName: "facebook_video_transcribe",
9761
+ tagline: "Transcribe a Facebook video/reel",
9762
+ params: [{ ...URL_PARAM, label: "Video URL", placeholder: "https://www.facebook.com/share/v/\u2026" }]
9763
+ },
9764
+ {
9765
+ key: "facebook_ad_transcribe",
9766
+ label: "Ad Transcribe",
9767
+ kind: "simple",
9768
+ endpoint: "/facebook/transcribe",
9769
+ mcpName: "facebook_ad_transcribe",
9770
+ tagline: "Transcribe an ad creative",
9771
+ params: [{ ...URL_PARAM, label: "Ad video URL" }]
9772
+ },
9773
+ {
9774
+ key: "facebook_page_intel",
9775
+ label: "Page Intel",
9776
+ kind: "simple",
9777
+ endpoint: "/facebook/page-intel",
9778
+ mcpName: "facebook_page_intel",
9779
+ tagline: "Advertiser page profile + ads",
9780
+ params: [{ key: "libraryId", label: "Page / Library ID or URL", type: "string", required: true }]
9781
+ }
9782
+ ]
9783
+ },
9784
+ {
9785
+ key: "instagram",
9786
+ label: "Instagram",
9787
+ icon: "instagram",
9788
+ tools: [
9789
+ {
9790
+ key: "instagram_profile_content",
9791
+ label: "Profile",
9792
+ kind: "simple",
9793
+ endpoint: "/instagram/profile-content",
9794
+ mcpName: "instagram_profile_content",
9795
+ tagline: "Inventory a profile grid",
9796
+ params: [
9797
+ { key: "url", label: "Profile URL or handle", type: "string", required: true, placeholder: "https://instagram.com/nasa" },
9798
+ { key: "maxItems", label: "Max items", type: "number", min: 1, max: 2e3, default: 50, advanced: true },
9799
+ { key: "maxScrolls", label: "Max scrolls", type: "number", min: 0, max: 250, default: 10, advanced: true }
9800
+ ]
9801
+ },
9802
+ {
9803
+ key: "instagram_media_download",
9804
+ label: "Post / Reel",
9805
+ kind: "simple",
9806
+ endpoint: "/instagram/media-download",
9807
+ mcpName: "instagram_media_download",
9808
+ tagline: "Download a post or reel (+ transcript)",
9809
+ params: [
9810
+ { ...URL_PARAM, label: "Post / reel URL", placeholder: "https://www.instagram.com/reel/\u2026" },
9811
+ { key: "includeTranscript", label: "Include transcript", type: "boolean", default: true }
9812
+ ]
9813
+ }
9814
+ ]
9815
+ },
9816
+ {
9817
+ key: "reddit",
9818
+ label: "Reddit",
9819
+ icon: "message-circle",
9820
+ tools: [
9821
+ {
9822
+ key: "reddit_browser",
9823
+ label: "Research (Browser)",
9824
+ kind: "simple",
9825
+ endpoint: "/agent",
9826
+ mcpName: "browser_open",
9827
+ tagline: "No dedicated tool yet \u2014 driven by the Browser agent",
9828
+ params: [{ key: "query", label: "What to research", type: "string", required: true, placeholder: "ICP pain points in [niche]" }]
9829
+ }
9830
+ ]
9831
+ },
9832
+ {
9833
+ key: "google_search",
9834
+ label: "Google Search",
9835
+ icon: "search",
9836
+ tools: [
9837
+ {
9838
+ key: "search_serp",
9839
+ label: "SERP",
9840
+ kind: "simple",
9841
+ endpoint: "/harvest/sync",
9842
+ mcpName: "search_serp",
9843
+ tagline: "Organic results only",
9844
+ params: [
9845
+ { key: "query", label: "Query", type: "string", required: true },
9846
+ { key: "location", label: "Location", type: "string", placeholder: "Denver, CO" }
9847
+ ],
9848
+ examples: ["Top restaurants in San Francisco"]
9849
+ },
9850
+ {
9851
+ key: "harvest_paa",
9852
+ label: "PAA + SERP Detail",
9853
+ kind: "simple",
9854
+ endpoint: "/harvest/sync",
9855
+ mcpName: "harvest_paa",
9856
+ tagline: "People-Also-Ask, AI Overview, full SERP features",
9857
+ params: [
9858
+ { key: "query", label: "Query", type: "string", required: true },
9859
+ { key: "maxQuestions", label: "Max questions", type: "number", min: 1, max: 200, default: 30, advanced: true },
9860
+ { key: "location", label: "Location", type: "string" }
9861
+ ]
9862
+ }
9863
+ ]
9864
+ },
9865
+ {
9866
+ key: "google_maps",
9867
+ label: "Google Maps",
9868
+ icon: "map-pin",
9869
+ tools: [
9870
+ {
9871
+ key: "maps_search",
9872
+ label: "Search",
9873
+ kind: "simple",
9874
+ endpoint: "/maps/search",
9875
+ mcpName: "maps_search",
9876
+ tagline: "Find local businesses",
9877
+ params: [
9878
+ { key: "query", label: "Query", type: "string", required: true, placeholder: "roofers" },
9879
+ { key: "location", label: "Location", type: "string", required: true, placeholder: "Denver, CO" },
9880
+ { key: "maxResults", label: "Max results", type: "number", min: 1, max: 20, default: 10, advanced: true }
9881
+ ]
9882
+ },
9883
+ {
9884
+ key: "maps_place_intel",
9885
+ label: "Place Intel",
9886
+ kind: "simple",
9887
+ endpoint: "/maps/place",
9888
+ mcpName: "maps_place_intel",
9889
+ tagline: "One place: details + reviews",
9890
+ params: [
9891
+ { key: "businessName", label: "Business name", type: "string", required: true },
9892
+ { key: "location", label: "Location", type: "string", required: true },
9893
+ { key: "includeReviews", label: "Include reviews", type: "boolean", default: false },
9894
+ { key: "maxReviews", label: "Max reviews", type: "number", min: 1, max: 500, default: 50, advanced: true }
9895
+ ]
9896
+ }
9897
+ ]
9898
+ },
9899
+ {
9900
+ key: "websites",
9901
+ label: "Websites",
9902
+ icon: "globe",
9903
+ tools: [
9904
+ {
9905
+ key: "scrape",
9906
+ label: "Scrape",
9907
+ kind: "modal",
9908
+ endpoint: "/extract-url",
9909
+ mcpName: "extract_url",
9910
+ tagline: "Headless single or bulk page extraction",
9911
+ modes: [
9912
+ { groupKey: "scope", groupLabel: "Scope", optionKey: "single", optionLabel: "Headless Single", isDefault: true, endpointOverride: "/extract-url", mcpNameOverride: "extract_url" },
9913
+ { groupKey: "scope", groupLabel: "Scope", optionKey: "bulk", optionLabel: "Headless Bulk", endpointOverride: "/extract-site", mcpNameOverride: "extract_site", paramOverrides: { rotateProxies: true } }
9914
+ ],
9915
+ params: [
9916
+ URL_PARAM,
9917
+ FORMATS_PARAM,
9918
+ { key: "maxPages", label: "Max pages", type: "number", min: 1, max: 1e4, default: 100, advanced: true, sourceKey: "scope:bulk" },
9919
+ { key: "screenshot", label: "Screenshot", type: "boolean", advanced: true }
9920
+ ],
9921
+ examples: ["https://steenshoney.com/"]
9922
+ },
9923
+ {
9924
+ key: "map_site_urls",
9925
+ label: "Map",
9926
+ kind: "simple",
9927
+ endpoint: "/map-urls",
9928
+ mcpName: "map_site_urls",
9929
+ tagline: "URL inventory only",
9930
+ params: [URL_PARAM, { key: "maxUrls", label: "Max URLs", type: "number", min: 1, max: 1e4, default: 100, advanced: true }]
9931
+ },
9932
+ {
9933
+ key: "extract_site",
9934
+ label: "Crawl",
9935
+ kind: "simple",
9936
+ endpoint: "/extract-site",
9937
+ mcpName: "extract_site",
9938
+ tagline: "Map + bulk scrape + SEO report",
9939
+ params: [
9940
+ URL_PARAM,
9941
+ { key: "maxPages", label: "Max pages", type: "number", min: 1, max: 1e4, default: 100 },
9942
+ FORMATS_PARAM,
9943
+ { key: "rotateProxies", label: "Rotate proxies (blocked sites)", type: "boolean", default: true },
9944
+ { key: "background", label: "Run in background", type: "boolean", default: true, advanced: true }
9945
+ ],
9946
+ examples: ["https://steenshoney.com/ (full-site SEO crawl)"]
9947
+ }
9948
+ ]
9949
+ },
9950
+ {
9951
+ key: "browser",
9952
+ label: "Browser",
9953
+ icon: "monitor",
9954
+ tools: [
9955
+ {
9956
+ key: "browser_agent",
9957
+ label: "Browser Agent",
9958
+ kind: "simple",
9959
+ endpoint: "/agent",
9960
+ mcpName: "browser_open",
9961
+ tagline: "Drive a live browser (open, navigate, read, click)",
9962
+ params: [
9963
+ { key: "url", label: "Start URL", type: "url" },
9964
+ { key: "profile", label: "Use Chrome profile", type: "string", placeholder: "saved profile name", advanced: true }
9965
+ ]
9966
+ },
9967
+ {
9968
+ key: "query_fanout_workflow",
9969
+ label: "AI Visibility (Fan-out)",
9970
+ kind: "simple",
9971
+ mcpName: "query_fanout_workflow",
9972
+ tagline: "Capture what ChatGPT/Claude search & cite (AEO)",
9973
+ params: [
9974
+ { key: "prompt", label: "Prompt", type: "string" },
9975
+ { key: "first_party_domain", label: "Your domain", type: "string", advanced: true },
9976
+ { key: "export", label: "Export artifacts", type: "boolean", advanced: true }
9977
+ ]
9978
+ }
9979
+ ]
9980
+ },
9981
+ {
9982
+ key: "workflows",
9983
+ label: "Workflows",
9984
+ icon: "workflow",
9985
+ tools: [
9986
+ {
9987
+ key: "directory_workflow",
9988
+ label: "Directory",
9989
+ kind: "simple",
9990
+ endpoint: "/directory/run",
9991
+ mcpName: "directory_workflow",
9992
+ isSpecial: true,
9993
+ tagline: "Build a local business directory",
9994
+ params: [
9995
+ { key: "query", label: "Business type", type: "string", required: true, placeholder: "roofers" },
9996
+ { key: "state", label: "State", type: "string", required: true, placeholder: "TN" },
9997
+ { key: "minPopulation", label: "Min city population", type: "number", default: 1e5, advanced: true }
9998
+ ]
9999
+ },
10000
+ {
10001
+ key: "rank_tracker_workflow",
10002
+ label: "Rank Tracker",
10003
+ kind: "simple",
10004
+ mcpName: "rank_tracker_workflow",
10005
+ isSpecial: true,
10006
+ tagline: "Generate a rank-tracker blueprint",
10007
+ params: [{ key: "domain", label: "Domain", type: "string", required: true }]
10008
+ },
10009
+ {
10010
+ key: "deep_research_workflow",
10011
+ label: "Deep Research",
10012
+ kind: "simple",
10013
+ endpoint: "/workflows/run",
10014
+ mcpName: "workflow_run",
10015
+ isSpecial: true,
10016
+ tagline: "Multi-source, fact-checked research report",
10017
+ params: [{ key: "question", label: "Question", type: "string", required: true }]
10018
+ }
10019
+ ]
10020
+ }
10021
+ ]
10022
+ };
10023
+ }
10024
+ });
10025
+
10026
+ // src/api/site-audit-middleware.ts
10027
+ var import_factory2, siteAuditAuth;
10028
+ var init_site_audit_middleware = __esm({
10029
+ "src/api/site-audit-middleware.ts"() {
10030
+ "use strict";
10031
+ import_factory2 = require("hono/factory");
10032
+ init_db();
10033
+ siteAuditAuth = (0, import_factory2.createMiddleware)(async (c, next) => {
10034
+ const apiKey = c.req.header("x-api-key");
10035
+ if (apiKey) {
10036
+ const user = await getUserByApiKey(apiKey);
10037
+ if (!user) return c.json({ error: "Unauthorized" }, 401);
10038
+ c.set("siteAuditUserId", user.id);
10039
+ return next();
10040
+ }
10041
+ return c.json({ error: "Unauthorized" }, 401);
10042
+ });
10043
+ }
10044
+ });
10045
+
10046
+ // src/api/site-audit-routes.ts
10047
+ function isPathInside(root, target) {
10048
+ const relative = import_node_path3.default.relative(root, target);
10049
+ return relative === "" || !relative.startsWith("..") && !import_node_path3.default.isAbsolute(relative);
10050
+ }
10051
+ var import_node_path3, import_node_os3, import_hono, import_zod11, siteAuditApp;
10052
+ var init_site_audit_routes = __esm({
10053
+ "src/api/site-audit-routes.ts"() {
10054
+ "use strict";
10055
+ import_node_path3 = __toESM(require("path"), 1);
10056
+ import_node_os3 = __toESM(require("os"), 1);
10057
+ import_hono = require("hono");
10058
+ import_zod11 = require("zod");
10059
+ init_site_audit_middleware();
10060
+ init_factory();
10061
+ init_phases();
10062
+ init_schemas();
10063
+ siteAuditApp = new import_hono.Hono();
10064
+ siteAuditApp.onError((err, c) => {
10065
+ if (err instanceof import_zod11.ZodError) {
10066
+ return c.json({ error: "Invalid request body", issues: err.issues }, 400);
10067
+ }
10068
+ const msg = err instanceof Error ? err.message : String(err);
10069
+ return c.json({ error: msg }, 500);
10070
+ });
10071
+ siteAuditApp.use("*", siteAuditAuth);
10072
+ siteAuditApp.post("/audit", async (c) => {
10073
+ const body = SiteAuditStartRequestSchema.parse(await c.req.json());
10074
+ const allowedRoot = import_node_path3.default.resolve(process.env["SESSION_ROOT"] ?? import_node_os3.default.tmpdir());
10075
+ const sessionPath = import_node_path3.default.resolve(body.sessionPath);
10076
+ if (!isPathInside(allowedRoot, sessionPath)) {
10077
+ return c.json({ error: "sessionPath must be inside the configured session root" }, 400);
10078
+ }
10079
+ const pathFields = [
10080
+ body.sessionPath,
10081
+ body.sfInternalPath,
10082
+ body.sfInlinksPath,
10083
+ body.sfOutlinksPath,
10084
+ body.sitemapPath,
10085
+ body.competitorDomainsCsvPath,
10086
+ body.visualSitemapsPath,
10087
+ body.gscPath,
10088
+ body.ga4Path,
10089
+ body.ahrefsPath
10090
+ ];
10091
+ for (const filePath of pathFields) {
10092
+ if (filePath != null && !isPathInside(allowedRoot, import_node_path3.default.resolve(filePath))) {
10093
+ return c.json({ error: "Path traversal attempt" }, 400);
10094
+ }
10095
+ }
10096
+ const userId = c.get("siteAuditUserId");
10097
+ const service = makeSiteAuditService();
10098
+ const jobId = await service.startAudit(userId, body);
10099
+ void dispatchSiteAuditPhase(service, "phase1-ingest", { jobId, request: body }).catch(
10100
+ (err) => {
10101
+ console.error(
10102
+ "[site-audit-routes] phase1-ingest dispatch failed for job",
10103
+ jobId,
10104
+ err instanceof Error ? err.message : String(err)
10105
+ );
10106
+ }
10107
+ );
10108
+ return c.json({ jobId }, 202);
10109
+ });
10110
+ siteAuditApp.post("/ingest", async (c) => {
10111
+ const body = SiteAuditIngestRequestSchema.parse(await c.req.json());
10112
+ const service = makeSiteAuditService();
10113
+ const job = await service.getJob(body.jobId);
10114
+ if (!job) return c.json({ error: "Not found" }, 404);
10115
+ if (job.user_id !== c.get("siteAuditUserId")) return c.json({ error: "Not found" }, 404);
10116
+ void dispatchSiteAuditPhase(service, "phase1-ingest", {
10117
+ jobId: body.jobId,
10118
+ request: JSON.parse(job.request)
10119
+ }).catch((err) => {
10120
+ console.error(
10121
+ "[site-audit-routes] phase1-ingest re-dispatch failed for job",
10122
+ body.jobId,
10123
+ err instanceof Error ? err.message : String(err)
10124
+ );
10125
+ });
10126
+ return c.json({ jobId: body.jobId }, 202);
10127
+ });
10128
+ siteAuditApp.post("/build-graph", async (c) => {
10129
+ const body = SiteAuditBuildGraphRequestSchema.parse(await c.req.json());
10130
+ const service = makeSiteAuditService();
10131
+ const job = await service.getJob(body.jobId);
10132
+ if (!job) return c.json({ error: "Not found" }, 404);
10133
+ if (job.user_id !== c.get("siteAuditUserId")) return c.json({ error: "Not found" }, 404);
10134
+ void dispatchSiteAuditPhase(service, "phase2-build-graph", { jobId: body.jobId }).catch(
10135
+ (err) => {
10136
+ console.error(
10137
+ "[site-audit-routes] phase2-build-graph failed for job",
10138
+ body.jobId,
10139
+ err instanceof Error ? err.message : String(err)
10140
+ );
10141
+ }
10142
+ );
10143
+ return c.json({ jobId: body.jobId }, 202);
10144
+ });
10145
+ siteAuditApp.post("/classify", async (c) => {
10146
+ const body = SiteAuditClassifyRequestSchema.parse(await c.req.json());
10147
+ const service = makeSiteAuditService();
10148
+ const job = await service.getJob(body.jobId);
10149
+ if (!job) return c.json({ error: "Not found" }, 404);
10150
+ if (job.user_id !== c.get("siteAuditUserId")) return c.json({ error: "Not found" }, 404);
10151
+ void dispatchSiteAuditPhase(service, "phase3-classify", { jobId: body.jobId }).catch(
10152
+ (err) => {
10153
+ console.error(
10154
+ "[site-audit-routes] phase3-classify failed for job",
10155
+ body.jobId,
10156
+ err instanceof Error ? err.message : String(err)
10157
+ );
10158
+ }
10159
+ );
10160
+ return c.json({ jobId: body.jobId }, 202);
10161
+ });
10162
+ siteAuditApp.post("/compare", async (c) => {
10163
+ const body = SiteAuditCompareRequestSchema.parse(await c.req.json());
10164
+ const service = makeSiteAuditService();
10165
+ const job = await service.getJob(body.jobId);
10166
+ if (!job) return c.json({ error: "Not found" }, 404);
10167
+ if (job.user_id !== c.get("siteAuditUserId")) return c.json({ error: "Not found" }, 404);
10168
+ void dispatchSiteAuditPhase(service, "phase4-compare", { jobId: body.jobId }).catch(
10169
+ (err) => {
10170
+ console.error(
10171
+ "[site-audit-routes] phase4-compare failed for job",
10172
+ body.jobId,
10173
+ err instanceof Error ? err.message : String(err)
10174
+ );
10175
+ }
10176
+ );
10177
+ return c.json({ jobId: body.jobId }, 202);
10178
+ });
10179
+ siteAuditApp.post("/score", async (c) => {
10180
+ const body = SiteAuditScoreRequestSchema.parse(await c.req.json());
10181
+ const service = makeSiteAuditService();
10182
+ const job = await service.getJob(body.jobId);
10183
+ if (!job) return c.json({ error: "Not found" }, 404);
10184
+ if (job.user_id !== c.get("siteAuditUserId")) return c.json({ error: "Not found" }, 404);
10185
+ void dispatchSiteAuditPhase(service, "phase5-synthesize", {
10186
+ jobId: body.jobId,
10187
+ scoringOnly: true,
10188
+ reportOnly: false
10189
+ }).catch((err) => {
10190
+ console.error(
10191
+ "[site-audit-routes] phase5-synthesize(scoringOnly) failed for job",
10192
+ body.jobId,
10193
+ err instanceof Error ? err.message : String(err)
10194
+ );
10195
+ });
10196
+ return c.json({ jobId: body.jobId }, 202);
10197
+ });
10198
+ siteAuditApp.post("/report", async (c) => {
10199
+ const body = SiteAuditReportRequestSchema.parse(await c.req.json());
10200
+ const service = makeSiteAuditService();
10201
+ const job = await service.getJob(body.jobId);
10202
+ if (!job) return c.json({ error: "Not found" }, 404);
10203
+ if (job.user_id !== c.get("siteAuditUserId")) return c.json({ error: "Not found" }, 404);
10204
+ void dispatchSiteAuditPhase(service, "phase5-synthesize", {
10205
+ jobId: body.jobId,
10206
+ scoringOnly: false,
10207
+ reportOnly: true
10208
+ }).catch((err) => {
10209
+ console.error(
10210
+ "[site-audit-routes] phase5-synthesize(reportOnly) failed for job",
10211
+ body.jobId,
10212
+ err instanceof Error ? err.message : String(err)
10213
+ );
10214
+ });
10215
+ return c.json({ jobId: body.jobId }, 202);
10216
+ });
10217
+ siteAuditApp.get("/jobs/:id", async (c) => {
10218
+ const jobId = c.req.param("id");
10219
+ const userId = c.get("siteAuditUserId");
10220
+ const service = makeSiteAuditService();
10221
+ const job = await service.getJob(jobId);
10222
+ if (!job) return c.json({ error: "Not found" }, 404);
10223
+ if (job.user_id !== userId) return c.json({ error: "Not found" }, 404);
10224
+ return c.json(job);
10225
+ });
10226
+ siteAuditApp.get("/jobs", async (c) => {
10227
+ const userId = c.get("siteAuditUserId");
10228
+ const service = makeSiteAuditService();
10229
+ const jobs = await service.getJobsForUser(userId);
10230
+ return c.json(jobs);
10231
+ });
10232
+ }
10233
+ });
10234
+
9026
10235
  // src/youtube/schemas.ts
9027
10236
  var import_zod12, YouTubeHarvestOptionsSchema;
9028
10237
  var init_schemas2 = __esm({
@@ -9184,7 +10393,7 @@ function rankCheckContextOptions(config) {
9184
10393
  ...config.hasTouch !== void 0 ? { hasTouch: config.hasTouch } : {}
9185
10394
  };
9186
10395
  }
9187
- async function withTimeout(promise, timeoutMs, label) {
10396
+ async function withTimeout2(promise, timeoutMs, label) {
9188
10397
  let timeout;
9189
10398
  try {
9190
10399
  return await Promise.race([
@@ -9224,14 +10433,14 @@ function buildYouTubeChannelVideosUrl(channelInput) {
9224
10433
  }
9225
10434
  return `https://www.youtube.com/${handle}/videos`;
9226
10435
  }
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;
10436
+ 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
10437
  var init_BrowserDriver = __esm({
9229
10438
  "src/driver/BrowserDriver.ts"() {
9230
10439
  "use strict";
9231
10440
  import_playwright_extra = require("playwright-extra");
9232
10441
  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);
10442
+ import_playwright4 = require("playwright");
10443
+ import_sdk5 = __toESM(require("@onkernel/sdk"), 1);
9235
10444
  init_selectors();
9236
10445
  init_errors();
9237
10446
  import_playwright_extra.chromium.use((0, import_puppeteer_extra_plugin_stealth.default)());
@@ -9274,7 +10483,7 @@ var init_BrowserDriver = __esm({
9274
10483
  serpNavigation: null
9275
10484
  };
9276
10485
  if (config.kernelApiKey) {
9277
- this.kernelClient = new import_sdk4.default({ apiKey: config.kernelApiKey });
10486
+ this.kernelClient = new import_sdk5.default({ apiKey: config.kernelApiKey });
9278
10487
  const timeoutSeconds = positiveIntFromEnv("KERNEL_BROWSER_TIMEOUT_SECONDS", DEFAULT_KERNEL_BROWSER_TIMEOUT_SECONDS);
9279
10488
  if (config.kernelProfileName && config.kernelProfileSaveChanges === true) {
9280
10489
  await ensureKernelProfile(this.kernelClient, config.kernelProfileName);
@@ -9295,7 +10504,7 @@ var init_BrowserDriver = __esm({
9295
10504
  let defaultProxyDisableError = null;
9296
10505
  if (proxyMode === "none") {
9297
10506
  try {
9298
- await withTimeout(
10507
+ await withTimeout2(
9299
10508
  this.kernelClient.browsers.update(this.kernelSessionId, { disable_default_proxy: true }),
9300
10509
  5e3,
9301
10510
  `Kernel session ${this.kernelSessionId} disable default proxy`
@@ -9338,7 +10547,7 @@ var init_BrowserDriver = __esm({
9338
10547
  if (this.debugEnabled) {
9339
10548
  await this.populateKernelRetrieveDebug(kernelDebug, config.kernelProxyId);
9340
10549
  }
9341
- this.browser = await import_playwright3.chromium.connectOverCDP(kernelBrowser.cdp_ws_url);
10550
+ this.browser = await import_playwright4.chromium.connectOverCDP(kernelBrowser.cdp_ws_url);
9342
10551
  this.context = await this.browser.newContext(rankCheckContextOptions(config));
9343
10552
  await this.installEsbuildHelperShims(this.context);
9344
10553
  this.page = await this.context.newPage();
@@ -9375,7 +10584,7 @@ var init_BrowserDriver = __esm({
9375
10584
  async populateKernelRetrieveDebug(kernelDebug, requestedProxyId) {
9376
10585
  if (!this.kernelClient || !this.kernelSessionId) return;
9377
10586
  try {
9378
- const retrieved = await withTimeout(
10587
+ const retrieved = await withTimeout2(
9379
10588
  this.kernelClient.browsers.retrieve(this.kernelSessionId),
9380
10589
  5e3,
9381
10590
  `Kernel session ${this.kernelSessionId} retrieve`
@@ -9433,7 +10642,7 @@ var init_BrowserDriver = __esm({
9433
10642
  error: null
9434
10643
  };
9435
10644
  }
9436
- await withTimeout(
10645
+ await withTimeout2(
9437
10646
  debugPage.goto("https://ipapi.co/json/", { waitUntil: "domcontentloaded", timeout: 7e3 }),
9438
10647
  8e3,
9439
10648
  "browser network location navigation"
@@ -9459,7 +10668,7 @@ var init_BrowserDriver = __esm({
9459
10668
  }
9460
10669
  async loadJsonInDebugPage(debugPage, url) {
9461
10670
  try {
9462
- await withTimeout(
10671
+ await withTimeout2(
9463
10672
  debugPage.goto(url, { waitUntil: "domcontentloaded", timeout: 7e3 }),
9464
10673
  8e3,
9465
10674
  `browser network location navigation ${url}`
@@ -9654,12 +10863,12 @@ var init_BrowserDriver = __esm({
9654
10863
  event: "kernel_browser_delete_started",
9655
10864
  kernel_session_id: sessionId
9656
10865
  }));
9657
- const deleteSession = withTimeout(
10866
+ const deleteSession = withTimeout2(
9658
10867
  client2.browsers.deleteByID(sessionId),
9659
10868
  KERNEL_SESSION_DELETE_TIMEOUT_MS,
9660
10869
  `Kernel session ${sessionId} delete`
9661
10870
  );
9662
- const closeBrowser = withTimeout(
10871
+ const closeBrowser = withTimeout2(
9663
10872
  b.close(),
9664
10873
  KERNEL_BROWSER_CLOSE_TIMEOUT_MS,
9665
10874
  `Kernel browser ${sessionId} close`
@@ -10060,14 +11269,14 @@ var init_YouTubeExtractor = __esm({
10060
11269
  });
10061
11270
 
10062
11271
  // src/youtube/MP3Downloader.ts
10063
- var import_node_child_process, import_node_util, import_promises3, import_node_path3, execFileAsync, MP3Downloader;
11272
+ var import_node_child_process, import_node_util, import_promises3, import_node_path4, execFileAsync, MP3Downloader;
10064
11273
  var init_MP3Downloader = __esm({
10065
11274
  "src/youtube/MP3Downloader.ts"() {
10066
11275
  "use strict";
10067
11276
  import_node_child_process = require("child_process");
10068
11277
  import_node_util = require("util");
10069
11278
  import_promises3 = require("fs/promises");
10070
- import_node_path3 = __toESM(require("path"), 1);
11279
+ import_node_path4 = __toESM(require("path"), 1);
10071
11280
  execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
10072
11281
  MP3Downloader = class {
10073
11282
  constructor(outputDir, reporter, concurrency = 3) {
@@ -10089,7 +11298,7 @@ var init_MP3Downloader = __esm({
10089
11298
  }
10090
11299
  async downloadOne(video) {
10091
11300
  const url = video.url;
10092
- const outputTemplate = import_node_path3.default.join(this.outputDir, "%(title)s.%(ext)s");
11301
+ const outputTemplate = import_node_path4.default.join(this.outputDir, "%(title)s.%(ext)s");
10093
11302
  try {
10094
11303
  const { stdout } = await execFileAsync("yt-dlp", [
10095
11304
  "--extract-audio",
@@ -10167,24 +11376,24 @@ async function extractOnce(options) {
10167
11376
  return extractor.extract(options);
10168
11377
  }
10169
11378
  async function writeOutputs(result, outputDir) {
10170
- await import_node_fs2.promises.mkdir(outputDir, { recursive: true });
11379
+ await import_node_fs3.promises.mkdir(outputDir, { recursive: true });
10171
11380
  const slug = result.target.toLowerCase().replace(/\W+/g, "-").slice(0, 40);
10172
11381
  const ts = Date.now();
10173
- await import_node_fs2.promises.writeFile(
10174
- import_node_path4.default.join(outputDir, `${slug}-${ts}.json`),
11382
+ await import_node_fs3.promises.writeFile(
11383
+ import_node_path5.default.join(outputDir, `${slug}-${ts}.json`),
10175
11384
  JSON.stringify(result, null, 2),
10176
11385
  "utf8"
10177
11386
  );
10178
11387
  if (result.videos.length > 0) {
10179
- await import_node_fs2.promises.writeFile(
10180
- import_node_path4.default.join(outputDir, `${slug}-videos-${ts}.csv`),
11388
+ await import_node_fs3.promises.writeFile(
11389
+ import_node_path5.default.join(outputDir, `${slug}-videos-${ts}.csv`),
10181
11390
  import_papaparse.default.unparse(result.videos, { header: true }),
10182
11391
  "utf8"
10183
11392
  );
10184
11393
  }
10185
11394
  if (result.downloads.length > 0) {
10186
- await import_node_fs2.promises.writeFile(
10187
- import_node_path4.default.join(outputDir, `${slug}-downloads-${ts}.csv`),
11395
+ await import_node_fs3.promises.writeFile(
11396
+ import_node_path5.default.join(outputDir, `${slug}-downloads-${ts}.csv`),
10188
11397
  import_papaparse.default.unparse(result.downloads, { header: true }),
10189
11398
  "utf8"
10190
11399
  );
@@ -10224,13 +11433,13 @@ async function ytHarvest(rawOptions) {
10224
11433
  `YouTube blocked all ${MAX_ATTEMPTS} attempts. Try again in a few minutes.`
10225
11434
  );
10226
11435
  }
10227
- var import_node_fs2, import_node_path4, import_papaparse, MAX_ATTEMPTS, RETRY_DELAY_MS;
11436
+ var import_node_fs3, import_node_path5, import_papaparse, MAX_ATTEMPTS, RETRY_DELAY_MS;
10228
11437
  var init_youtube_harvest = __esm({
10229
11438
  "src/youtube/youtube-harvest.ts"() {
10230
11439
  "use strict";
10231
- import_node_fs2 = require("fs");
11440
+ import_node_fs3 = require("fs");
10232
11441
  init_browser_service_env();
10233
- import_node_path4 = __toESM(require("path"), 1);
11442
+ import_node_path5 = __toESM(require("path"), 1);
10234
11443
  import_papaparse = __toESM(require("papaparse"), 1);
10235
11444
  init_schemas2();
10236
11445
  init_BrowserDriver();
@@ -10428,11 +11637,11 @@ async function attemptKernelWhisper(videoId, kernelApiKey, kernelProxyId, falKey
10428
11637
  const audioInfo = await captureAudioViaMediaRecorder(page);
10429
11638
  await driver.close();
10430
11639
  if (!audioInfo || audioInfo.bytes.length < 1e4) return null;
10431
- import_client2.fal.config({ credentials: falKey });
11640
+ import_client3.fal.config({ credentials: falKey });
10432
11641
  const ext = audioInfo.mimeType.includes("ogg") ? "ogg" : "webm";
10433
11642
  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", {
11643
+ const uploadedUrl = await import_client3.fal.storage.upload(audioFile);
11644
+ const result = await import_client3.fal.subscribe("fal-ai/wizper", {
10436
11645
  input: { audio_url: uploadedUrl, task: "transcribe", language: "en" },
10437
11646
  logs: false,
10438
11647
  pollInterval: 3e3
@@ -10487,13 +11696,13 @@ async function fetchCaptions(videoId) {
10487
11696
  if (whisper) return finalizeCaptions(whisper);
10488
11697
  throw new Error(`No captions available for ${videoId} \u2014 all three tiers failed`);
10489
11698
  }
10490
- var import_client2, WHISPER_RECORD_SECONDS;
11699
+ var import_client3, WHISPER_RECORD_SECONDS;
10491
11700
  var init_CaptionFetcher = __esm({
10492
11701
  "src/youtube/CaptionFetcher.ts"() {
10493
11702
  "use strict";
10494
11703
  init_BrowserDriver();
10495
11704
  init_browser_service_env();
10496
- import_client2 = require("@fal-ai/client");
11705
+ import_client3 = require("@fal-ai/client");
10497
11706
  WHISPER_RECORD_SECONDS = 90;
10498
11707
  }
10499
11708
  });
@@ -10557,9 +11766,13 @@ var init_server_schemas = __esm({
10557
11766
  });
10558
11767
  ExtractSiteBodySchema = import_zod13.z.object({
10559
11768
  url: import_zod13.z.string().min(1, "url is required"),
10560
- maxPages: import_zod13.z.number().int().min(1).max(200).optional(),
11769
+ maxPages: import_zod13.z.number().int().min(1).max(1e4).optional(),
10561
11770
  browserFallback: import_zod13.z.boolean().optional(),
10562
- kernelFallback: import_zod13.z.boolean().optional()
11771
+ kernelFallback: import_zod13.z.boolean().optional(),
11772
+ rotateProxies: import_zod13.z.boolean().optional(),
11773
+ rotateProxyEvery: import_zod13.z.number().int().min(1).max(100).optional(),
11774
+ background: import_zod13.z.boolean().optional(),
11775
+ formats: import_zod13.z.array(import_zod13.z.enum(["markdown", "links", "json", "images", "branding"])).optional()
10563
11776
  });
10564
11777
  YoutubeHarvestBodySchema = import_zod13.z.object({
10565
11778
  mode: import_zod13.z.enum(["search", "channel"]),
@@ -10686,7 +11899,7 @@ async function acquireConcurrencyGate(user, operation, options = {}) {
10686
11899
  return { ok: true, lockId: null, active: await countActiveUsageForUser(user.id), limit, operation, reused: true };
10687
11900
  }
10688
11901
  await expireConcurrencyLocksForUser(user.id);
10689
- const lockId = `cl_${(0, import_node_crypto2.randomUUID)().replace(/-/g, "").slice(0, 24)}`;
11902
+ const lockId = `cl_${(0, import_node_crypto3.randomUUID)().replace(/-/g, "").slice(0, 24)}`;
10690
11903
  const res = await getDb().execute({
10691
11904
  sql: `INSERT INTO concurrency_locks (id, user_id, operation, status, expires_at, metadata)
10692
11905
  SELECT ?, ?, ?, 'active', datetime('now', ?), ?
@@ -10740,11 +11953,11 @@ async function extendConcurrencyGate(lockId, ttlSeconds = DEFAULT_LOCK_TTL_SECON
10740
11953
  args: [lockTtlModifier(ttlSeconds), lockId]
10741
11954
  });
10742
11955
  }
10743
- var import_node_crypto2, DEFAULT_LOCK_TTL_SECONDS, DEFAULT_RETRY_AFTER_SECONDS, MAX_LOCK_TTL_SECONDS, schemaReady;
11956
+ var import_node_crypto3, DEFAULT_LOCK_TTL_SECONDS, DEFAULT_RETRY_AFTER_SECONDS, MAX_LOCK_TTL_SECONDS, schemaReady;
10744
11957
  var init_concurrency_gates = __esm({
10745
11958
  "src/api/concurrency-gates.ts"() {
10746
11959
  "use strict";
10747
- import_node_crypto2 = require("crypto");
11960
+ import_node_crypto3 = require("crypto");
10748
11961
  init_db();
10749
11962
  init_rates();
10750
11963
  DEFAULT_LOCK_TTL_SECONDS = 15 * 60;
@@ -10768,10 +11981,10 @@ function buildTranscriptMarkdown(result) {
10768
11981
  if (result.chunks?.length) {
10769
11982
  lines.push("## Timestamped Segments");
10770
11983
  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()}`);
11984
+ for (const chunk2 of result.chunks) {
11985
+ const start = fmtTs(chunk2.timestamp[0]);
11986
+ const end = fmtTs(chunk2.timestamp[1]);
11987
+ lines.push(`**[${start} \u2192 ${end}]** ${chunk2.text.trim()}`);
10775
11988
  lines.push("");
10776
11989
  }
10777
11990
  }
@@ -11409,20 +12622,20 @@ var init_FacebookAdExtractor = __esm({
11409
12622
  if (splitRe.lastIndex === m.index) splitRe.lastIndex++;
11410
12623
  }
11411
12624
  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);
12625
+ const ads = adChunks.filter((c) => /Library ID[:\s]+\d{10,20}/i.test(c)).slice(0, maxAds).map((chunk2) => {
12626
+ const lidM = chunk2.match(/Library ID[:\s]+([0-9]{10,20})/i);
11414
12627
  const libraryId = lidM ? lidM[1] : null;
11415
- const statusM = chunk.match(/^(Active|Inactive)/i);
12628
+ const statusM = chunk2.match(/^(Active|Inactive)/i);
11416
12629
  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);
12630
+ const startM = chunk2.match(/Started running on\s+([A-Za-z]+\.?\s+\d{1,2},?\s*\d{4})/i);
11418
12631
  const started = startM ? startM[1].replace(/\s+/g, " ").trim() : null;
11419
12632
  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);
12633
+ const hasVideoText = /0:00\s*\/\s*0:00|\d+:\d+\s*\/\s*\d+:\d+/.test(chunk2);
11421
12634
  const creativeType = visual.videoSrc || hasVideoText ? "video" : visual.imageSrc ? "image" : "unknown";
11422
12635
  let domain = null;
11423
12636
  const domainRe = /\b([A-Z0-9]{2,}(?:\.[A-Z]{2,})+)\b/g;
11424
12637
  let dm;
11425
- while ((dm = domainRe.exec(chunk)) !== null) {
12638
+ while ((dm = domainRe.exec(chunk2)) !== null) {
11426
12639
  const c = dm[1];
11427
12640
  if (c.length >= 6 && c !== "LIBRARY") {
11428
12641
  domain = c;
@@ -11431,9 +12644,9 @@ var init_FacebookAdExtractor = __esm({
11431
12644
  }
11432
12645
  let primaryText = null;
11433
12646
  let headline = null;
11434
- const sponsoredIdx = chunk.search(/\bSponsored\b/i);
12647
+ const sponsoredIdx = chunk2.search(/\bSponsored\b/i);
11435
12648
  if (sponsoredIdx >= 0) {
11436
- let afterSponsored = chunk.slice(sponsoredIdx + "Sponsored".length).trim();
12649
+ let afterSponsored = chunk2.slice(sponsoredIdx + "Sponsored".length).trim();
11437
12650
  afterSponsored = afterSponsored.replace(/\s*\d*:?\d+\s*\/\s*\d*:?\d+.*$/, "");
11438
12651
  if (domain) {
11439
12652
  const di = afterSponsored.indexOf(domain);
@@ -11444,18 +12657,18 @@ var init_FacebookAdExtractor = __esm({
11444
12657
  }
11445
12658
  const ctaRe2 = new RegExp("\\b(" + CTA_LABELS.map((l) => l.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|") + ")\\b", "i");
11446
12659
  if (domain) {
11447
- const di = chunk.indexOf(domain);
12660
+ const di = chunk2.indexOf(domain);
11448
12661
  if (di >= 0) {
11449
- let afterDomain = chunk.slice(di + domain.length).trim();
12662
+ let afterDomain = chunk2.slice(di + domain.length).trim();
11450
12663
  const ctaMatch = afterDomain.search(ctaRe2);
11451
12664
  if (ctaMatch >= 0) afterDomain = afterDomain.slice(0, ctaMatch).trim();
11452
12665
  if (afterDomain.length > 3 && afterDomain.length < 120) headline = afterDomain;
11453
12666
  }
11454
12667
  }
11455
12668
  if (!headline) {
11456
- const ctaIdx = chunk.search(ctaRe2);
12669
+ const ctaIdx = chunk2.search(ctaRe2);
11457
12670
  if (ctaIdx > 0) {
11458
- const beforeCta = chunk.slice(0, ctaIdx).trimEnd();
12671
+ const beforeCta = chunk2.slice(0, ctaIdx).trimEnd();
11459
12672
  const sentenceEnd = Math.max(beforeCta.lastIndexOf(". "), beforeCta.lastIndexOf("! "), beforeCta.lastIndexOf("? "));
11460
12673
  if (sentenceEnd >= 0) {
11461
12674
  const candidate = beforeCta.slice(sentenceEnd + 2).trim();
@@ -11467,12 +12680,12 @@ var init_FacebookAdExtractor = __esm({
11467
12680
  }
11468
12681
  let cta = null;
11469
12682
  for (const lbl of CTA_LABELS) {
11470
- if (chunk.toLowerCase().includes(lbl.toLowerCase())) {
12683
+ if (chunk2.toLowerCase().includes(lbl.toLowerCase())) {
11471
12684
  cta = lbl;
11472
12685
  break;
11473
12686
  }
11474
12687
  }
11475
- const multiM = chunk.match(/(\d+)\s+ad[s]?\s+use[s]?\s+this/i);
12688
+ const multiM = chunk2.match(/(\d+)\s+ad[s]?\s+use[s]?\s+this/i);
11476
12689
  const clusterCount = multiM ? parseInt(multiM[1]) : null;
11477
12690
  return {
11478
12691
  libraryId,
@@ -11495,8 +12708,8 @@ var init_FacebookAdExtractor = __esm({
11495
12708
  const unknownCreativeCount = ads.filter((a) => a.creativeType === "unknown").length;
11496
12709
  const advertiserPageId = new URL(page.url()).searchParams.get("view_all_page_id") ?? null;
11497
12710
  const nameFreq = /* @__PURE__ */ new Map();
11498
- for (const chunk of adChunks) {
11499
- const detailsParts = chunk.split("See ad details ");
12711
+ for (const chunk2 of adChunks) {
12712
+ const detailsParts = chunk2.split("See ad details ");
11500
12713
  if (detailsParts.length < 2) continue;
11501
12714
  const sponsoredIdx = detailsParts[1].indexOf(" Sponsored");
11502
12715
  if (sponsoredIdx < 0) continue;
@@ -12221,7 +13434,7 @@ async function createFreshLocationProxy(kernel, options, target) {
12221
13434
  }
12222
13435
  async function deleteKernelProxyId(kernelApiKey, proxyId) {
12223
13436
  if (!kernelApiKey || !proxyId) return;
12224
- const kernel = new import_sdk5.default({ apiKey: kernelApiKey });
13437
+ const kernel = new import_sdk6.default({ apiKey: kernelApiKey });
12225
13438
  await kernel.proxies.delete(proxyId);
12226
13439
  }
12227
13440
  async function resolveKernelProxyId(options) {
@@ -12235,7 +13448,7 @@ async function resolveKernelProxyId(options) {
12235
13448
  if (!target || !options.kernelApiKey) {
12236
13449
  return resolution("configured_fallback", options.proxyMode, options.configuredKernelProxyId, target, target ? null : "location could not be normalized to a US city/state proxy target");
12237
13450
  }
12238
- const kernel = new import_sdk5.default({ apiKey: options.kernelApiKey });
13451
+ const kernel = new import_sdk6.default({ apiKey: options.kernelApiKey });
12239
13452
  try {
12240
13453
  const attemptIndex = options.attemptIndex ?? 0;
12241
13454
  if (options.fresh) {
@@ -12341,11 +13554,11 @@ async function resolveKernelProxyId(options) {
12341
13554
  return resolution("configured_fallback", options.proxyMode, options.configuredKernelProxyId, target, errorText2(err));
12342
13555
  }
12343
13556
  }
12344
- var import_sdk5, US_STATE_CODES, US_CITY_CENTER_ZIPS;
13557
+ var import_sdk6, US_STATE_CODES, US_CITY_CENTER_ZIPS;
12345
13558
  var init_kernel_proxy_resolver = __esm({
12346
13559
  "src/kernel-proxy-resolver.ts"() {
12347
13560
  "use strict";
12348
- import_sdk5 = __toESM(require("@onkernel/sdk"), 1);
13561
+ import_sdk6 = __toESM(require("@onkernel/sdk"), 1);
12349
13562
  init_uule();
12350
13563
  US_STATE_CODES = {
12351
13564
  alabama: "AL",
@@ -12561,7 +13774,7 @@ function transcriptMarkdown(title, text, chunks, durationMs) {
12561
13774
  }
12562
13775
  async function transcribeMediaUrl(mediaUrl, markdownTitle = "# Media Transcript") {
12563
13776
  const startMs = Date.now();
12564
- const result = await import_client3.fal.subscribe("fal-ai/wizper", {
13777
+ const result = await import_client4.fal.subscribe("fal-ai/wizper", {
12565
13778
  input: { audio_url: mediaUrl, task: "transcribe", language: "en" },
12566
13779
  logs: false,
12567
13780
  pollInterval: 3e3
@@ -12577,11 +13790,11 @@ async function transcribeMediaUrl(mediaUrl, markdownTitle = "# Media Transcript"
12577
13790
  markdown: transcriptMarkdown(markdownTitle, text, chunks, durationMs)
12578
13791
  };
12579
13792
  }
12580
- var import_client3;
13793
+ var import_client4;
12581
13794
  var init_media_transcription = __esm({
12582
13795
  "src/services/media-transcription.ts"() {
12583
13796
  "use strict";
12584
- import_client3 = require("@fal-ai/client");
13797
+ import_client4 = require("@fal-ai/client");
12585
13798
  }
12586
13799
  });
12587
13800
 
@@ -12624,7 +13837,7 @@ async function kernelLaunchOptsResidential() {
12624
13837
  }
12625
13838
  return { headless: true, kernelApiKey: browserServiceApiKey(), kernelProxyId: proxyId, viewport: { width: 1280, height: 900 }, locale: "en-US" };
12626
13839
  }
12627
- var import_hono4, import_zod16, import_client4, FacebookAdBodySchema, FacebookPageIntelBodySchema, FacebookTranscribeBodySchema, FacebookVideoTranscribeBodySchema, FacebookSearchBodySchema, FacebookMediaBodySchema, facebookAdApp, ALLOWED_MEDIA_HOSTS;
13840
+ var import_hono4, import_zod16, import_client5, FacebookAdBodySchema, FacebookPageIntelBodySchema, FacebookTranscribeBodySchema, FacebookVideoTranscribeBodySchema, FacebookSearchBodySchema, FacebookMediaBodySchema, facebookAdApp, ALLOWED_MEDIA_HOSTS;
12628
13841
  var init_facebook_ad_routes = __esm({
12629
13842
  "src/api/facebook-ad-routes.ts"() {
12630
13843
  "use strict";
@@ -12639,7 +13852,7 @@ var init_facebook_ad_routes = __esm({
12639
13852
  init_FacebookOrganicVideoExtractor();
12640
13853
  init_kernel_proxy_resolver();
12641
13854
  init_schemas3();
12642
- import_client4 = require("@fal-ai/client");
13855
+ import_client5 = require("@fal-ai/client");
12643
13856
  init_api_auth();
12644
13857
  init_url_utils();
12645
13858
  init_concurrency_gates();
@@ -12782,7 +13995,7 @@ var init_facebook_ad_routes = __esm({
12782
13995
  metadata: { videoUrl }
12783
13996
  });
12784
13997
  if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
12785
- import_client4.fal.config({ credentials: process.env.FAL_KEY });
13998
+ import_client5.fal.config({ credentials: process.env.FAL_KEY });
12786
13999
  let debited = false;
12787
14000
  try {
12788
14001
  const { ok, balance_mc } = await debitMc(fbUser.id, MC_COSTS.fb_transcribe, LedgerOperation.FB_TRANSCRIBE, videoUrl);
@@ -12821,7 +14034,7 @@ var init_facebook_ad_routes = __esm({
12821
14034
  metadata: { url: sourceUrl.href }
12822
14035
  });
12823
14036
  if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
12824
- import_client4.fal.config({ credentials: process.env.FAL_KEY });
14037
+ import_client5.fal.config({ credentials: process.env.FAL_KEY });
12825
14038
  const driver = new BrowserDriver();
12826
14039
  let debited = false;
12827
14040
  try {
@@ -12915,11 +14128,11 @@ var init_facebook_ad_routes = __esm({
12915
14128
  }
12916
14129
  if (last < bodyText.length) adChunks.push(bodyText.slice(last));
12917
14130
  const advertiserMap = /* @__PURE__ */ new Map();
12918
- for (const chunk of adChunks) {
12919
- const lidM = chunk.match(/Library ID[:\s]+([0-9]{10,20})/i);
14131
+ for (const chunk2 of adChunks) {
14132
+ const lidM = chunk2.match(/Library ID[:\s]+([0-9]{10,20})/i);
12920
14133
  if (!lidM) continue;
12921
14134
  const libraryId = lidM[1];
12922
- const detailsParts = chunk.split("See ad details ");
14135
+ const detailsParts = chunk2.split("See ad details ");
12923
14136
  if (detailsParts.length < 2) continue;
12924
14137
  const afterDetails = detailsParts[1];
12925
14138
  const sponsoredIdx = afterDetails.indexOf(" Sponsored");
@@ -13396,7 +14609,7 @@ async function resolveInstagramLaunch(body) {
13396
14609
  };
13397
14610
  }
13398
14611
  function outputBaseDir() {
13399
- return process.env.MCP_SCRAPER_OUTPUT_DIR?.trim() || (0, import_node_path5.join)((0, import_node_os3.homedir)(), "Downloads", "mcp-scraper");
14612
+ return process.env.MCP_SCRAPER_OUTPUT_DIR?.trim() || (0, import_node_path6.join)((0, import_node_os4.homedir)(), "Downloads", "mcp-scraper");
13400
14613
  }
13401
14614
  function safeFilePart(input) {
13402
14615
  return input.replace(/^https?:\/\//, "").replace(/[^a-z0-9._-]+/gi, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "instagram";
@@ -13404,8 +14617,8 @@ function safeFilePart(input) {
13404
14617
  function mediaOutputDir(shortcode, sourceUrl) {
13405
14618
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
13406
14619
  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 });
14620
+ const dir = (0, import_node_path6.join)(outputBaseDir(), "instagram", `${stamp}-${slug}`);
14621
+ (0, import_node_fs4.mkdirSync)(dir, { recursive: true });
13409
14622
  return dir;
13410
14623
  }
13411
14624
  async function downloadToFile(url, destPath, referer) {
@@ -13418,10 +14631,10 @@ async function downloadToFile(url, destPath, referer) {
13418
14631
  });
13419
14632
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
13420
14633
  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));
14634
+ await (0, import_promises4.pipeline)(import_node_stream2.Readable.fromWeb(res.body), (0, import_node_fs4.createWriteStream)(destPath));
13422
14635
  return {
13423
14636
  savedPath: destPath,
13424
- sizeBytes: (0, import_node_fs3.statSync)(destPath).size,
14637
+ sizeBytes: (0, import_node_fs4.statSync)(destPath).size,
13425
14638
  mimeType: res.headers.get("content-type")?.split(";")[0]?.trim() ?? null
13426
14639
  };
13427
14640
  }
@@ -13443,8 +14656,8 @@ function runFfmpegMux(videoPath, audioPath, outPath) {
13443
14656
  stdio: ["ignore", "ignore", "pipe"]
13444
14657
  });
13445
14658
  let stderr = "";
13446
- child.stderr.on("data", (chunk) => {
13447
- stderr += String(chunk);
14659
+ child.stderr.on("data", (chunk2) => {
14660
+ stderr += String(chunk2);
13448
14661
  });
13449
14662
  child.on("error", (err) => resolve({ ok: false, error: err.message }));
13450
14663
  child.on("close", (code) => resolve({ ok: code === 0, error: code === 0 ? null : stderr.trim() || `ffmpeg exited ${code}` }));
@@ -13456,15 +14669,15 @@ function trackFilename(track, index, shortcode) {
13456
14669
  const bitrate = track.bitrate ? `-${track.bitrate}` : "";
13457
14670
  return `${label}-${type}-${index}${bitrate}.mp4`;
13458
14671
  }
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;
14672
+ 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
14673
  var init_instagram_routes = __esm({
13461
14674
  "src/api/instagram-routes.ts"() {
13462
14675
  "use strict";
13463
14676
  import_hono5 = require("hono");
13464
14677
  import_zod17 = require("zod");
13465
- import_node_fs3 = require("fs");
13466
- import_node_os3 = require("os");
13467
- import_node_path5 = require("path");
14678
+ import_node_fs4 = require("fs");
14679
+ import_node_os4 = require("os");
14680
+ import_node_path6 = require("path");
13468
14681
  import_node_stream2 = require("stream");
13469
14682
  import_promises4 = require("stream/promises");
13470
14683
  import_node_child_process2 = require("child_process");
@@ -13592,16 +14805,16 @@ var init_instagram_routes = __esm({
13592
14805
  let outputDir = null;
13593
14806
  if (body.downloadMedia) {
13594
14807
  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, [
14808
+ const textPath = (0, import_node_path6.join)(outputDir, `${extraction.shortcode ?? "instagram"}-text.txt`);
14809
+ (0, import_node_fs4.writeFileSync)(textPath, [
13597
14810
  `URL: ${extraction.pageUrl}`,
13598
14811
  extraction.caption ? `Caption: ${extraction.caption}` : "",
13599
14812
  "",
13600
14813
  extraction.bodyText
13601
14814
  ].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 });
14815
+ downloads.push({ kind: "text", url: null, savedPath: textPath, sizeBytes: (0, import_node_fs4.statSync)(textPath).size, mimeType: "text/plain", error: null });
13603
14816
  if (mediaTypes.has("image") && extraction.imageUrl) {
13604
- const imagePathBase = (0, import_node_path5.join)(outputDir, `${extraction.shortcode ?? "instagram"}-image`);
14817
+ const imagePathBase = (0, import_node_path6.join)(outputDir, `${extraction.shortcode ?? "instagram"}-image`);
13605
14818
  try {
13606
14819
  const downloaded = await downloadToFile(extraction.imageUrl, imagePathBase, extraction.pageUrl);
13607
14820
  const ext = extFromMime(downloaded.mimeType, "jpg");
@@ -13610,7 +14823,7 @@ var init_instagram_routes = __esm({
13610
14823
  const { renameSync } = await import("fs");
13611
14824
  renameSync(downloaded.savedPath, finalPath);
13612
14825
  }
13613
- downloads.push({ kind: "image", url: extraction.imageUrl, savedPath: finalPath, sizeBytes: (0, import_node_fs3.statSync)(finalPath).size, mimeType: downloaded.mimeType, error: null });
14826
+ downloads.push({ kind: "image", url: extraction.imageUrl, savedPath: finalPath, sizeBytes: (0, import_node_fs4.statSync)(finalPath).size, mimeType: downloaded.mimeType, error: null });
13614
14827
  } catch (err) {
13615
14828
  downloads.push({ kind: "image", url: extraction.imageUrl, savedPath: null, sizeBytes: null, mimeType: null, error: err instanceof Error ? err.message : String(err) });
13616
14829
  }
@@ -13621,7 +14834,7 @@ var init_instagram_routes = __esm({
13621
14834
  if (track.streamType === "video" && !mediaTypes.has("video")) continue;
13622
14835
  if (track.streamType === "audio" && !mediaTypes.has("audio")) continue;
13623
14836
  const kind = track.streamType === "audio" ? "audio" : "video";
13624
- const target = (0, import_node_path5.join)(outputDir, trackFilename(track, index, extraction.shortcode));
14837
+ const target = (0, import_node_path6.join)(outputDir, trackFilename(track, index, extraction.shortcode));
13625
14838
  try {
13626
14839
  const downloaded = await downloadToFile(track.url, target, extraction.pageUrl);
13627
14840
  downloads.push({ kind, url: track.url, savedPath: downloaded.savedPath, sizeBytes: downloaded.sizeBytes, mimeType: downloaded.mimeType, error: null });
@@ -13631,10 +14844,10 @@ var init_instagram_routes = __esm({
13631
14844
  }
13632
14845
  }
13633
14846
  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`);
14847
+ const outPath = (0, import_node_path6.join)(outputDir, `${extraction.shortcode ?? "instagram"}-muxed.mp4`);
13635
14848
  const muxed = await runFfmpegMux(savedByUrl.get(extraction.selectedVideoTrack.url), savedByUrl.get(extraction.selectedAudioTrack.url), outPath);
13636
14849
  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 });
14850
+ downloads.push({ kind: "muxed_video", url: null, savedPath: outPath, sizeBytes: (0, import_node_fs4.statSync)(outPath).size, mimeType: "video/mp4", error: null });
13638
14851
  } else {
13639
14852
  warnings.push(`Mux skipped/failed: ${muxed.error}`);
13640
14853
  }
@@ -14669,13 +15882,13 @@ var init_maps_routes = __esm({
14669
15882
  });
14670
15883
 
14671
15884
  // src/mcp/workflow-catalog.ts
14672
- function normalize(value) {
15885
+ function normalize2(value) {
14673
15886
  return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
14674
15887
  }
14675
15888
  function suggestWorkflowRecipes(goal, limit = 3) {
14676
- const normalized = normalize(goal);
15889
+ const normalized = normalize2(goal);
14677
15890
  const scored = WORKFLOW_RECIPES.map((recipe) => {
14678
- const haystack = normalize([
15891
+ const haystack = normalize2([
14679
15892
  recipe.id,
14680
15893
  recipe.title,
14681
15894
  recipe.description,
@@ -14806,16 +16019,93 @@ function reportTitle(full) {
14806
16019
  return title?.replace(/^#\s+/, "").trim() || "MCP Scraper Report";
14807
16020
  }
14808
16021
  function outputBaseDir2() {
14809
- return process.env.MCP_SCRAPER_OUTPUT_DIR?.trim() || (0, import_node_path6.join)((0, import_node_os4.homedir)(), "Downloads", "mcp-scraper");
16022
+ return process.env.MCP_SCRAPER_OUTPUT_DIR?.trim() || (0, import_node_path7.join)((0, import_node_os5.homedir)(), "Downloads", "mcp-scraper");
14810
16023
  }
14811
16024
  function saveFullReport(full) {
14812
16025
  if (!reportSavingEnabled || process.env.MCP_SCRAPER_SAVE_REPORTS === "false") return null;
14813
16026
  const outDir = outputBaseDir2();
14814
16027
  try {
14815
- (0, import_node_fs4.mkdirSync)(outDir, { recursive: true });
16028
+ (0, import_node_fs5.mkdirSync)(outDir, { recursive: true });
16029
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
16030
+ const file = (0, import_node_path7.join)(outDir, `${stamp}-${slugifyReportName(reportTitle(full))}.md`);
16031
+ (0, import_node_fs5.writeFileSync)(file, full, "utf8");
16032
+ return file;
16033
+ } catch {
16034
+ return null;
16035
+ }
16036
+ }
16037
+ function reportSavingActive() {
16038
+ return reportSavingEnabled && process.env.MCP_SCRAPER_SAVE_REPORTS !== "false";
16039
+ }
16040
+ function saveBulkSite(siteUrl, pages, seo) {
16041
+ if (!reportSavingActive()) return null;
16042
+ try {
16043
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
16044
+ const dir = (0, import_node_path7.join)(outputBaseDir2(), `extract-${slugifyReportName(siteUrl)}-${stamp}`);
16045
+ const pagesDir = (0, import_node_path7.join)(dir, "pages");
16046
+ (0, import_node_fs5.mkdirSync)(pagesDir, { recursive: true });
16047
+ const indexRows = pages.map((p, i) => {
16048
+ const num = String(i + 1).padStart(4, "0");
16049
+ const slug = slugifyReportName(p.url.replace(/^https?:\/\//, "")).slice(0, 60) || "page";
16050
+ const fname = `${num}-${slug}.md`;
16051
+ const body = (p.bodyMarkdown ?? "").trim();
16052
+ const content = [
16053
+ `# ${p.title ?? "Untitled"}`,
16054
+ `- **URL:** ${p.url}`,
16055
+ p.metaDescription ? `- **Description:** ${p.metaDescription}` : "",
16056
+ p.schemaTypes?.length ? `- **Schema:** ${p.schemaTypes.join(", ")}` : "",
16057
+ "",
16058
+ body || "_(no content extracted)_"
16059
+ ].filter(Boolean).join("\n");
16060
+ (0, import_node_fs5.writeFileSync)((0, import_node_path7.join)(pagesDir, fname), content, "utf8");
16061
+ return `| ${i + 1} | ${cell(p.title ?? "Untitled")} | ${p.url} | pages/${fname} |`;
16062
+ });
16063
+ const index = [
16064
+ `# Site Extract: ${siteUrl}`,
16065
+ `**${pages.length} pages** \u2014 each saved as an individual Markdown file under \`pages/\`.`,
16066
+ `
16067
+ ## Pages
16068
+ | # | Title | URL | File |
16069
+ |---|-------|-----|------|
16070
+ ${indexRows.join("\n")}`
16071
+ ].join("\n");
16072
+ const indexFile = (0, import_node_path7.join)(dir, "index.md");
16073
+ (0, import_node_fs5.writeFileSync)(indexFile, index, "utf8");
16074
+ let seoFiles;
16075
+ if (seo) {
16076
+ const toJsonl = (rows) => rows.map((r) => JSON.stringify(r)).join("\n");
16077
+ const pagesJsonl = (0, import_node_path7.join)(dir, "pages.jsonl");
16078
+ const linksJsonl = (0, import_node_path7.join)(dir, "links.jsonl");
16079
+ const metricsJsonl = (0, import_node_path7.join)(dir, "link-metrics.jsonl");
16080
+ const issuesFile = (0, import_node_path7.join)(dir, "issues.json");
16081
+ const reportFile = (0, import_node_path7.join)(dir, "report.md");
16082
+ (0, import_node_fs5.writeFileSync)(pagesJsonl, toJsonl(seo.pageRows), "utf8");
16083
+ (0, import_node_fs5.writeFileSync)(linksJsonl, toJsonl(seo.edges), "utf8");
16084
+ (0, import_node_fs5.writeFileSync)(metricsJsonl, toJsonl(seo.metrics), "utf8");
16085
+ (0, import_node_fs5.writeFileSync)(issuesFile, JSON.stringify(seo.issues, null, 2), "utf8");
16086
+ (0, import_node_fs5.writeFileSync)(reportFile, seo.reportMd, "utf8");
16087
+ seoFiles = [pagesJsonl, linksJsonl, metricsJsonl, issuesFile, reportFile];
16088
+ if (seo.branding) {
16089
+ const brandingFile = (0, import_node_path7.join)(dir, "branding.json");
16090
+ (0, import_node_fs5.writeFileSync)(brandingFile, JSON.stringify(seo.branding, null, 2), "utf8");
16091
+ seoFiles.push(brandingFile);
16092
+ }
16093
+ }
16094
+ return { dir, indexFile, fileCount: pages.length, seoFiles };
16095
+ } catch {
16096
+ return null;
16097
+ }
16098
+ }
16099
+ function saveUrlInventory(siteUrl, urls) {
16100
+ if (!reportSavingActive()) return null;
16101
+ try {
16102
+ const outDir = outputBaseDir2();
16103
+ (0, import_node_fs5.mkdirSync)(outDir, { recursive: true });
14816
16104
  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");
16105
+ const file = (0, import_node_path7.join)(outDir, `${stamp}-urlmap-${slugifyReportName(siteUrl.replace(/^https?:\/\//, ""))}.csv`);
16106
+ const csv = (v) => /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
16107
+ const rows = ["url,status", ...urls.map((u) => `${csv(u.url)},${u.status ?? ""}`)];
16108
+ (0, import_node_fs5.writeFileSync)(file, rows.join("\n"), "utf8");
14819
16109
  return file;
14820
16110
  } catch {
14821
16111
  return null;
@@ -14824,19 +16114,19 @@ function saveFullReport(full) {
14824
16114
  function persistScreenshotLocally(base64, url) {
14825
16115
  if (!reportSavingEnabled || process.env.MCP_SCRAPER_SAVE_REPORTS === "false") return null;
14826
16116
  try {
14827
- const dir = (0, import_node_path6.join)(outputBaseDir2(), "screenshots");
14828
- (0, import_node_fs4.mkdirSync)(dir, { recursive: true });
16117
+ const dir = (0, import_node_path7.join)(outputBaseDir2(), "screenshots");
16118
+ (0, import_node_fs5.mkdirSync)(dir, { recursive: true });
14829
16119
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
14830
16120
  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"));
16121
+ const filePath = (0, import_node_path7.join)(dir, `${stamp}-${slug}.png`);
16122
+ (0, import_node_fs5.writeFileSync)(filePath, Buffer.from(base64, "base64"));
14833
16123
  return filePath;
14834
16124
  } catch {
14835
16125
  return null;
14836
16126
  }
14837
16127
  }
14838
- function oneBlock(content) {
14839
- const filePath = saveFullReport(content);
16128
+ function oneBlock(content, diskContent) {
16129
+ const filePath = saveFullReport(diskContent ?? content);
14840
16130
  const text = filePath ? `${content}
14841
16131
 
14842
16132
  \u{1F4C4} Saved: \`${filePath}\`` : content;
@@ -15105,7 +16395,12 @@ ${[h1Lines, h2Lines].filter(Boolean).join("\n")}` : "";
15105
16395
  ].filter(Boolean).join("\n") : "";
15106
16396
  const bodySection = bodyMd ? `
15107
16397
  ## Page Content
15108
- ${bodyMd.slice(0, 3e3)}${bodyMd.length > 3e3 ? "\n\n*(truncated)*" : ""}` : "";
16398
+ ${bodyMd.slice(0, 3e3)}${bodyMd.length > 3e3 ? `
16399
+
16400
+ *(truncated to 3,000 of ${bodyMd.length.toLocaleString()} chars \u2014 full content in the saved report)*` : ""}` : "";
16401
+ const bodySectionFull = bodyMd ? `
16402
+ ## Page Content
16403
+ ${bodyMd}` : "";
15109
16404
  const screenshotSection = screenshotMeta ? `
15110
16405
  ## Screenshot
15111
16406
  - **File:** ${screenshotPath ?? "(returned inline only \u2014 disk write unavailable in this environment)"}
@@ -15136,7 +16431,10 @@ ${bodyMd.slice(0, 3e3)}${bodyMd.length > 3e3 ? "\n\n*(truncated)*" : ""}` : "";
15136
16431
  const full = `# URL Extract: ${url}
15137
16432
  **${title}**
15138
16433
  ${headingSection}${kpoSection}${brandingSection}${bodySection}${screenshotSection}${mediaSection}${tips}`;
15139
- const textResult = oneBlock(full);
16434
+ const diskReport = `# URL Extract: ${url}
16435
+ **${title}**
16436
+ ${headingSection}${kpoSection}${brandingSection}${bodySectionFull}${screenshotSection}${mediaSection}${tips}`;
16437
+ const textResult = oneBlock(full, diskReport);
15140
16438
  const structuredContent = {
15141
16439
  url,
15142
16440
  title: d.title ?? null,
@@ -15169,7 +16467,16 @@ function formatMapSiteUrls(raw, input) {
15169
16467
  const ok = urls.filter((u) => (u.status ?? 0) >= 200 && (u.status ?? 0) < 300);
15170
16468
  const broken = urls.filter((u) => u.status !== null && u.status >= 400);
15171
16469
  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");
16470
+ const isBulk = urls.length > BULK_URL_THRESHOLD;
16471
+ const inventoryFile = isBulk ? saveUrlInventory(input.url, urls.map((u) => ({ url: u.url, status: u.status ?? null }))) : null;
16472
+ const inlineCount = isBulk ? BULK_URL_THRESHOLD : 200;
16473
+ const urlRows = urls.slice(0, inlineCount).map((u, i) => `| ${i + 1} | ${u.url} | ${u.status ?? "\u2014"} |`).join("\n");
16474
+ const inventoryNote = isBulk ? inventoryFile ? `
16475
+ ## \u{1F4C1} Full inventory saved
16476
+ - **File:** \`${inventoryFile}\` (CSV: url, status \u2014 all ${urls.length} URLs)
16477
+ Showing the first ${inlineCount} below; read the file for the complete list.` : `
16478
+ ## \u26A0\uFE0F Full inventory not saved
16479
+ 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
16480
  const full = [
15174
16481
  `# URL Map: ${input.url}`,
15175
16482
  `**${d.totalFound} URLs** \xB7 ${(d.durationMs / 1e3).toFixed(1)}s${d.truncated ? " \xB7 *truncated*" : ""}`,
@@ -15178,12 +16485,13 @@ function formatMapSiteUrls(raw, input) {
15178
16485
  - \u2705 2xx: ${ok.length}
15179
16486
  - \u{1F500} 3xx: ${redirects.length}
15180
16487
  - \u274C 4xx+: ${broken.length}`,
16488
+ inventoryNote,
15181
16489
  `
15182
- ## URL Inventory
16490
+ ## URL Inventory${isBulk ? ` (first ${inlineCount} of ${urls.length})` : ""}
15183
16491
  | # | URL | Status |
15184
16492
  |---|-----|--------|
15185
16493
  ${urlRows}`,
15186
- broken.length ? `
16494
+ !isBulk && broken.length ? `
15187
16495
  ## Broken URLs
15188
16496
  ${broken.map((u) => `- ${u.url} (${u.status})`).join("\n")}` : "",
15189
16497
  `
@@ -15201,47 +16509,116 @@ ${broken.map((u) => `- ${u.url} (${u.status})`).join("\n")}` : "",
15201
16509
  okCount: ok.length,
15202
16510
  redirectCount: redirects.length,
15203
16511
  brokenCount: broken.length,
16512
+ inventoryFile: inventoryFile ?? null,
15204
16513
  urls: urls.map((u) => ({ url: u.url, status: u.status ?? null })),
15205
16514
  durationMs: d.durationMs ?? 0
15206
16515
  }
15207
16516
  };
15208
16517
  }
16518
+ function buildSeoExport(siteUrl, pages, branding) {
16519
+ const { edges, metrics } = buildLinkGraph(pages, siteUrl);
16520
+ const pageRows = pages.map((p) => {
16521
+ const { bodyMarkdown: _b, schema: _s, outlinks: _o, ...rest } = p;
16522
+ const m = metrics.get(p.url);
16523
+ return { ...rest, inlinks: m?.inlinks ?? 0, crawlDepth: m?.crawlDepth ?? null, orphan: m?.orphan ?? false };
16524
+ });
16525
+ const issues = computeIssues(pages, metrics);
16526
+ return {
16527
+ pageRows,
16528
+ edges,
16529
+ metrics: [...metrics.values()],
16530
+ issues,
16531
+ reportMd: renderIssueReport(siteUrl, pages, issues, metrics),
16532
+ branding: branding ?? void 0
16533
+ };
16534
+ }
15209
16535
  function formatExtractSite(raw, input) {
15210
16536
  const parsed = parseData(raw);
15211
16537
  if ("error" in parsed) return { content: [{ type: "text", text: parsed.error }], isError: true };
15212
16538
  const d = parsed.data;
15213
16539
  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");
16540
+ const schemaTypesOf = (p) => p.kpo?.type ?? (Array.isArray(p.schema) && p.schema.length ? [`${p.schema.length} block(s)`] : []);
16541
+ const pageRow = (p, i) => {
16542
+ const schemaInfo = schemaTypesOf(p).join(", ") || "\u2014";
15216
16543
  return `| ${i + 1} | ${cell(p.title ?? "Untitled")} | ${p.url} | ${schemaInfo} |`;
15217
- }).join("\n");
15218
- const full = [
16544
+ };
16545
+ const tips = `
16546
+ ---
16547
+ \u{1F4A1} **Tips**
16548
+ - Map URLs first: use \`map_site_urls\`
16549
+ - Inspect a single page: use \`extract_url\``;
16550
+ const durationLine = `**${pages.length} pages** \xB7 ${((d.durationMs ?? 0) / 1e3).toFixed(1)}s`;
16551
+ const structuredContent = {
16552
+ url: input.url,
16553
+ pageCount: pages.length,
16554
+ pages: pages.map((p) => ({
16555
+ url: String(p.url ?? ""),
16556
+ title: p.title ?? null,
16557
+ schemaTypes: schemaTypesOf(p)
16558
+ })),
16559
+ durationMs: d.durationMs ?? 0
16560
+ };
16561
+ if (pages.length > BULK_PAGE_THRESHOLD) {
16562
+ const seo = buildSeoExport(input.url, pages, d.branding);
16563
+ const bulk = saveBulkSite(input.url, pages.map((p) => ({
16564
+ url: String(p.url ?? ""),
16565
+ title: p.title ?? null,
16566
+ bodyMarkdown: p.bodyMarkdown ?? null,
16567
+ metaDescription: p.metaDescription ?? null,
16568
+ schemaTypes: schemaTypesOf(p)
16569
+ })), seo);
16570
+ const preview = pages.slice(0, BULK_PAGE_THRESHOLD).map(pageRow).join("\n");
16571
+ const seoLine = bulk?.seoFiles?.length ? `
16572
+ - **SEO data:** \`report.md\` (issues summary), \`issues.json\` (findings + offender URLs), \`pages.jsonl\` (per-page fields), \`links.jsonl\` (link edges), \`link-metrics.jsonl\` (inlinks / crawl depth / orphans)` : "";
16573
+ const location2 = bulk ? `
16574
+ ## \u{1F4C1} Bulk scrape saved
16575
+ - **Folder:** \`${bulk.dir}\`
16576
+ - **Index:** \`${bulk.indexFile}\`
16577
+ - **Files:** ${bulk.fileCount} page files under \`pages/\`${seoLine}
16578
+
16579
+ Read \`index.md\` for the full page list, open files in \`pages/\` for full content, or read \`pages.jsonl\` / \`link-metrics.jsonl\` for the SEO crawl data. Page bodies are not inlined here to keep the context window clear.` : `
16580
+ ## \u26A0\uFE0F Bulk scrape not saved
16581
+ 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.`;
16582
+ const full2 = [
16583
+ `# Site Extract: ${input.url}`,
16584
+ durationLine,
16585
+ location2,
16586
+ `
16587
+ ## Pages (first ${Math.min(BULK_PAGE_THRESHOLD, pages.length)} of ${pages.length})
16588
+ | # | Title | URL | Schema |
16589
+ |---|-------|-----|--------|
16590
+ ${preview}`,
16591
+ tips
16592
+ ].join("\n");
16593
+ return { content: [{ type: "text", text: full2 }], structuredContent: { ...structuredContent, bulkFolder: bulk?.dir ?? null } };
16594
+ }
16595
+ const header = [
15219
16596
  `# Site Extract: ${input.url}`,
15220
- `**${pages.length} pages** \xB7 ${((d.durationMs ?? 0) / 1e3).toFixed(1)}s`,
16597
+ durationLine,
15221
16598
  `
15222
16599
  ## Pages
15223
16600
  | # | Title | URL | Schema |
15224
16601
  |---|-------|-----|--------|
15225
- ${pageRows}`,
15226
- `
15227
- ---
15228
- \u{1F4A1} **Tips**
15229
- - Map URLs first: use \`map_site_urls\`
15230
- - Inspect a single page: use \`extract_url\``
16602
+ ${pages.map(pageRow).join("\n")}`
15231
16603
  ].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
- }
15244
- };
16604
+ const full = `${header}${tips}`;
16605
+ const pageDetails = pages.map((p, i) => {
16606
+ const body = (p.bodyMarkdown ?? "").trim();
16607
+ return [
16608
+ `
16609
+ ## ${i + 1}. ${p.title ?? "Untitled"}`,
16610
+ `- **URL:** ${p.url}`,
16611
+ p.metaDescription ? `- **Description:** ${p.metaDescription}` : "",
16612
+ body ? `
16613
+ ${body}` : "_(no content extracted)_"
16614
+ ].filter(Boolean).join("\n");
16615
+ }).join("\n");
16616
+ const diskReport = `${header}
16617
+
16618
+ ---
16619
+ # Full Page Content
16620
+ ${pageDetails}${tips}`;
16621
+ return { ...oneBlock(full, diskReport), structuredContent };
15245
16622
  }
15246
16623
  function formatYoutubeHarvest(raw, input) {
15247
16624
  const parsed = parseData(raw);
@@ -16417,16 +17794,20 @@ ${rows}` : ""
16417
17794
  }
16418
17795
  };
16419
17796
  }
16420
- var import_node_fs4, import_node_os4, import_node_path6, reportSavingEnabled;
17797
+ var import_node_fs5, import_node_os5, import_node_path7, reportSavingEnabled, BULK_PAGE_THRESHOLD, BULK_URL_THRESHOLD;
16421
17798
  var init_mcp_response_formatter = __esm({
16422
17799
  "src/mcp/mcp-response-formatter.ts"() {
16423
17800
  "use strict";
16424
- import_node_fs4 = require("fs");
16425
- import_node_os4 = require("os");
16426
- import_node_path6 = require("path");
17801
+ import_node_fs5 = require("fs");
17802
+ import_node_os5 = require("os");
17803
+ import_node_path7 = require("path");
16427
17804
  init_errors();
16428
17805
  init_workflow_catalog();
17806
+ init_seo_link_graph();
17807
+ init_seo_issues();
16429
17808
  reportSavingEnabled = true;
17809
+ BULK_PAGE_THRESHOLD = 25;
17810
+ BULK_URL_THRESHOLD = 500;
16430
17811
  }
16431
17812
  });
16432
17813
 
@@ -16819,11 +18200,11 @@ function csvRowsFor(result) {
16819
18200
  return rows;
16820
18201
  }
16821
18202
  async function saveDirectoryCsv(result) {
16822
- const outDir = (0, import_node_path7.join)(outputBaseDir2(), "directory-workflows");
18203
+ const outDir = (0, import_node_path8.join)(outputBaseDir2(), "directory-workflows");
16823
18204
  await (0, import_promises6.mkdir)(outDir, { recursive: true });
16824
18205
  const stamp = result.extractedAt.replace(/[:.]/g, "-");
16825
18206
  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`);
18207
+ const path6 = (0, import_node_path8.join)(outDir, `${stamp}-${slug}-directory-workflow.csv`);
16827
18208
  const headers = [
16828
18209
  "source_query",
16829
18210
  "source_location",
@@ -16881,12 +18262,12 @@ async function runDirectoryWorkflowFromPlan(options, plan) {
16881
18262
  const csvPath = options.saveCsv ? await saveDirectoryCsv(base) : null;
16882
18263
  return { ...base, csvPath };
16883
18264
  }
16884
- var import_promises6, import_node_path7, import_zod19, DirectoryWorkflowOptionsSchema;
18265
+ var import_promises6, import_node_path8, import_zod19, DirectoryWorkflowOptionsSchema;
16885
18266
  var init_directory_workflow = __esm({
16886
18267
  "src/directory/directory-workflow.ts"() {
16887
18268
  "use strict";
16888
18269
  import_promises6 = require("fs/promises");
16889
- import_node_path7 = require("path");
18270
+ import_node_path8 = require("path");
16890
18271
  import_zod19 = require("zod");
16891
18272
  init_mcp_response_formatter();
16892
18273
  init_browser_service_env();
@@ -17013,7 +18394,7 @@ var init_slugify = __esm({
17013
18394
 
17014
18395
  // src/workflows/artifact-writer.ts
17015
18396
  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");
18397
+ return outputDir?.trim() || process.env.MCP_SCRAPER_OUTPUT_DIR?.trim() || (0, import_node_path9.join)((0, import_node_os6.homedir)(), "Downloads", "mcp-scraper");
17017
18398
  }
17018
18399
  function timestamp() {
17019
18400
  return (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
@@ -17022,11 +18403,11 @@ function safeSlug(value) {
17022
18403
  return slugify(value).replace(/^-+|-+$/g, "").slice(0, 80) || "run";
17023
18404
  }
17024
18405
  function indexPath(baseDir = workflowOutputBaseDir()) {
17025
- return (0, import_node_path8.join)(baseDir, "workflows", "index.json");
18406
+ return (0, import_node_path9.join)(baseDir, "workflows", "index.json");
17026
18407
  }
17027
18408
  async function readIndex(baseDir) {
17028
18409
  const path6 = indexPath(baseDir);
17029
- if (!(0, import_node_fs5.existsSync)(path6)) return { runs: [] };
18410
+ if (!(0, import_node_fs6.existsSync)(path6)) return { runs: [] };
17030
18411
  try {
17031
18412
  return JSON.parse(await (0, import_promises7.readFile)(path6, "utf8"));
17032
18413
  } catch {
@@ -17048,17 +18429,17 @@ async function updateWorkflowIndex(manifest, manifestPath, baseDir) {
17048
18429
  summary: `${manifest.title} (${manifest.status})`
17049
18430
  };
17050
18431
  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 });
18432
+ await (0, import_promises7.mkdir)((0, import_node_path9.dirname)(path6), { recursive: true });
17052
18433
  await (0, import_promises7.writeFile)(path6, JSON.stringify(index, null, 2), "utf8");
17053
18434
  }
17054
- var import_promises7, import_node_fs5, import_node_os5, import_node_path8, import_node_child_process3, ArtifactWriter;
18435
+ var import_promises7, import_node_fs6, import_node_os6, import_node_path9, import_node_child_process3, ArtifactWriter;
17055
18436
  var init_artifact_writer = __esm({
17056
18437
  "src/workflows/artifact-writer.ts"() {
17057
18438
  "use strict";
17058
18439
  import_promises7 = require("fs/promises");
17059
- import_node_fs5 = require("fs");
17060
- import_node_os5 = require("os");
17061
- import_node_path8 = require("path");
18440
+ import_node_fs6 = require("fs");
18441
+ import_node_os6 = require("os");
18442
+ import_node_path9 = require("path");
17062
18443
  import_node_child_process3 = require("child_process");
17063
18444
  init_csv();
17064
18445
  init_slugify();
@@ -17085,7 +18466,7 @@ var init_artifact_writer = __esm({
17085
18466
  const runId = forcedRunId?.trim() || `${timestamp()}-${safeSlug(workflowId)}-${Math.random().toString(36).slice(2, 8)}`;
17086
18467
  const nameSource = String(input.keyword ?? input.query ?? input.domain ?? input.state ?? workflowId);
17087
18468
  const baseDir = workflowOutputBaseDir(outputDir);
17088
- const runDir = (0, import_node_path8.join)(baseDir, "workflows", workflowId, `${timestamp()}-${safeSlug(nameSource)}-${safeSlug(runId).slice(0, 20)}`);
18469
+ const runDir = (0, import_node_path9.join)(baseDir, "workflows", workflowId, `${timestamp()}-${safeSlug(nameSource)}-${safeSlug(runId).slice(0, 20)}`);
17089
18470
  await (0, import_promises7.mkdir)(runDir, { recursive: true });
17090
18471
  return new _ArtifactWriter(workflowId, title, runId, baseDir, runDir, startedAt, input);
17091
18472
  }
@@ -17095,20 +18476,20 @@ var init_artifact_writer = __esm({
17095
18476
  return path6;
17096
18477
  }
17097
18478
  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 });
18479
+ const path6 = (0, import_node_path9.join)(this.runDir, relativePath);
18480
+ await (0, import_promises7.mkdir)((0, import_node_path9.dirname)(path6), { recursive: true });
17100
18481
  await (0, import_promises7.writeFile)(path6, JSON.stringify(data, null, 2), "utf8");
17101
18482
  return this.remember("json", label, path6);
17102
18483
  }
17103
18484
  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 });
18485
+ const path6 = (0, import_node_path9.join)(this.runDir, relativePath);
18486
+ await (0, import_promises7.mkdir)((0, import_node_path9.dirname)(path6), { recursive: true });
17106
18487
  await (0, import_promises7.writeFile)(path6, text, "utf8");
17107
18488
  return this.remember(kind, label, path6);
17108
18489
  }
17109
18490
  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 });
18491
+ const path6 = (0, import_node_path9.join)(this.runDir, relativePath);
18492
+ await (0, import_promises7.mkdir)((0, import_node_path9.dirname)(path6), { recursive: true });
17112
18493
  await (0, import_promises7.writeFile)(path6, rowsToCsv(headers, rows), "utf8");
17113
18494
  return this.remember("csv", label, path6, rows.length);
17114
18495
  }
@@ -17129,7 +18510,7 @@ var init_artifact_writer = __esm({
17129
18510
  errors,
17130
18511
  counts
17131
18512
  };
17132
- const path6 = (0, import_node_path8.join)(this.runDir, "manifest.json");
18513
+ const path6 = (0, import_node_path9.join)(this.runDir, "manifest.json");
17133
18514
  await (0, import_promises7.writeFile)(path6, JSON.stringify(manifest, null, 2), "utf8");
17134
18515
  await updateWorkflowIndex(manifest, path6, this.baseDir);
17135
18516
  return path6;
@@ -18709,7 +20090,7 @@ async function readManifestFromSummary(summary) {
18709
20090
  function webhookSignature(body, timestamp2) {
18710
20091
  const secret2 = process.env.MCP_SCRAPER_WEBHOOK_SECRET?.trim();
18711
20092
  if (!secret2) return null;
18712
- return (0, import_node_crypto3.createHmac)("sha256", secret2).update(`${timestamp2}.${body}`).digest("hex");
20093
+ return (0, import_node_crypto4.createHmac)("sha256", secret2).update(`${timestamp2}.${body}`).digest("hex");
18713
20094
  }
18714
20095
  async function deliverWorkflowWebhook(input) {
18715
20096
  if (!input.webhookUrl) return;
@@ -18916,11 +20297,11 @@ async function dispatchDueWorkflowSchedules(apiUrl, limit = 3) {
18916
20297
  }
18917
20298
  return { dispatched: results.length, results };
18918
20299
  }
18919
- var import_node_crypto3, import_promises9, import_hono8, import_zod25, workflowApp, WorkflowInputSchema, WorkflowIdSchema, CadenceSchema, ScheduleStatusSchema, RunBodySchema, ScheduleCreateSchema, SchedulePatchSchema, TERMINAL_RUN_STATUSES;
20300
+ var import_node_crypto4, import_promises9, import_hono8, import_zod25, workflowApp, WorkflowInputSchema, WorkflowIdSchema, CadenceSchema, ScheduleStatusSchema, RunBodySchema, ScheduleCreateSchema, SchedulePatchSchema, TERMINAL_RUN_STATUSES;
18920
20301
  var init_workflow_routes = __esm({
18921
20302
  "src/api/workflow-routes.ts"() {
18922
20303
  "use strict";
18923
- import_node_crypto3 = require("crypto");
20304
+ import_node_crypto4 = require("crypto");
18924
20305
  import_promises9 = require("fs/promises");
18925
20306
  import_hono8 = require("hono");
18926
20307
  import_zod25 = require("zod");
@@ -19201,7 +20582,7 @@ var init_workflow_routes = __esm({
19201
20582
  // src/serp-intelligence/page-snapshot-extractor.ts
19202
20583
  function sha256(value) {
19203
20584
  if (!value) return null;
19204
- return (0, import_node_crypto4.createHash)("sha256").update(value).digest("hex");
20585
+ return (0, import_node_crypto5.createHash)("sha256").update(value).digest("hex");
19205
20586
  }
19206
20587
  function countWords(markdown) {
19207
20588
  const matches = markdown.trim().match(/\b[\p{L}\p{N}][\p{L}\p{N}'-]*\b/gu);
@@ -19250,7 +20631,7 @@ function countLinks(html, baseUrl) {
19250
20631
  totalLinkCount: internalLinkCount + externalLinkCount
19251
20632
  };
19252
20633
  }
19253
- function withTimeout2(operation, timeoutMs) {
20634
+ function withTimeout3(operation, timeoutMs) {
19254
20635
  return new Promise((resolve, reject) => {
19255
20636
  const timer = setTimeout(() => reject(new Error(`Page snapshot timed out after ${timeoutMs}ms`)), timeoutMs);
19256
20637
  operation.then(
@@ -19367,7 +20748,7 @@ async function capturePageSnapshot(target, options = {}) {
19367
20748
  });
19368
20749
  }
19369
20750
  try {
19370
- const extraction = await withTimeout2(
20751
+ const extraction = await withTimeout3(
19371
20752
  extractKpo({ url: checked.parsed.href, kernelApiKey }),
19372
20753
  timeoutMs
19373
20754
  );
@@ -19501,11 +20882,11 @@ async function capturePageSnapshots(targets, options = {}) {
19501
20882
  }
19502
20883
  };
19503
20884
  }
19504
- var import_node_crypto4, import_p_limit3, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_CONCURRENCY, DEFAULT_MAX_CONTENT_CHARS;
20885
+ var import_node_crypto5, import_p_limit3, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_CONCURRENCY, DEFAULT_MAX_CONTENT_CHARS;
19505
20886
  var init_page_snapshot_extractor = __esm({
19506
20887
  "src/serp-intelligence/page-snapshot-extractor.ts"() {
19507
20888
  "use strict";
19508
- import_node_crypto4 = require("crypto");
20889
+ import_node_crypto5 = require("crypto");
19509
20890
  import_p_limit3 = __toESM(require("p-limit"), 1);
19510
20891
  init_kpo_extractor();
19511
20892
  init_url_utils();
@@ -20750,52 +22131,52 @@ var init_PAAExtractor = __esm({
20750
22131
  });
20751
22132
 
20752
22133
  // src/output/OutputSerializer.ts
20753
- var import_node_fs6, import_node_path9, import_papaparse2, OutputSerializer;
22134
+ var import_node_fs7, import_node_path10, import_papaparse2, OutputSerializer;
20754
22135
  var init_OutputSerializer = __esm({
20755
22136
  "src/output/OutputSerializer.ts"() {
20756
22137
  "use strict";
20757
- import_node_fs6 = require("fs");
20758
- import_node_path9 = __toESM(require("path"), 1);
22138
+ import_node_fs7 = require("fs");
22139
+ import_node_path10 = __toESM(require("path"), 1);
20759
22140
  import_papaparse2 = __toESM(require("papaparse"), 1);
20760
22141
  OutputSerializer = class {
20761
22142
  async writeJSON(result, outputDir) {
20762
- await import_node_fs6.promises.mkdir(outputDir, { recursive: true });
22143
+ await import_node_fs7.promises.mkdir(outputDir, { recursive: true });
20763
22144
  const slug = result.seed.toLowerCase().replace(/\W+/g, "-").slice(0, 40);
20764
22145
  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");
22146
+ const fullPath = import_node_path10.default.join(outputDir, filename);
22147
+ await import_node_fs7.promises.writeFile(fullPath, JSON.stringify(result, null, 2), "utf8");
20767
22148
  return fullPath;
20768
22149
  }
20769
22150
  async writeCSV(rows, outputDir) {
20770
- await import_node_fs6.promises.mkdir(outputDir, { recursive: true });
22151
+ await import_node_fs7.promises.mkdir(outputDir, { recursive: true });
20771
22152
  const seedRaw = rows[0]?.seed_query ?? "paa";
20772
22153
  const slug = seedRaw.toLowerCase().replace(/\W+/g, "-").slice(0, 40);
20773
22154
  const csv = import_papaparse2.default.unparse(rows, { header: true });
20774
22155
  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");
22156
+ const fullPath = import_node_path10.default.join(outputDir, filename);
22157
+ await import_node_fs7.promises.writeFile(fullPath, csv, "utf8");
20777
22158
  return fullPath;
20778
22159
  }
20779
22160
  async writeVideoCSV(videos, seed, outputDir) {
20780
- await import_node_fs6.promises.mkdir(outputDir, { recursive: true });
22161
+ await import_node_fs7.promises.mkdir(outputDir, { recursive: true });
20781
22162
  const slug = seed.toLowerCase().replace(/\W+/g, "-").slice(0, 40);
20782
22163
  const csv = import_papaparse2.default.unparse(videos, { header: true });
20783
22164
  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");
22165
+ const fullPath = import_node_path10.default.join(outputDir, filename);
22166
+ await import_node_fs7.promises.writeFile(fullPath, csv, "utf8");
20786
22167
  return fullPath;
20787
22168
  }
20788
22169
  async writeForumCSV(forums, seed, outputDir) {
20789
- await import_node_fs6.promises.mkdir(outputDir, { recursive: true });
22170
+ await import_node_fs7.promises.mkdir(outputDir, { recursive: true });
20790
22171
  const slug = seed.toLowerCase().replace(/\W+/g, "-").slice(0, 40);
20791
22172
  const csv = import_papaparse2.default.unparse(forums, { header: true });
20792
22173
  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");
22174
+ const fullPath = import_node_path10.default.join(outputDir, filename);
22175
+ await import_node_fs7.promises.writeFile(fullPath, csv, "utf8");
20795
22176
  return fullPath;
20796
22177
  }
20797
22178
  async writeAIOverviewCSV(citations, text, seed, outputDir) {
20798
- await import_node_fs6.promises.mkdir(outputDir, { recursive: true });
22179
+ await import_node_fs7.promises.mkdir(outputDir, { recursive: true });
20799
22180
  const slug = seed.toLowerCase().replace(/\W+/g, "-").slice(0, 40);
20800
22181
  const rows = citations.map((c, i) => ({
20801
22182
  seed_query: seed,
@@ -20805,12 +22186,12 @@ var init_OutputSerializer = __esm({
20805
22186
  }));
20806
22187
  const csv = import_papaparse2.default.unparse(rows, { header: true });
20807
22188
  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");
22189
+ const fullPath = import_node_path10.default.join(outputDir, filename);
22190
+ await import_node_fs7.promises.writeFile(fullPath, csv, "utf8");
20810
22191
  return fullPath;
20811
22192
  }
20812
22193
  async writeAIModeCSV(citations, text, seed, outputDir) {
20813
- await import_node_fs6.promises.mkdir(outputDir, { recursive: true });
22194
+ await import_node_fs7.promises.mkdir(outputDir, { recursive: true });
20814
22195
  const slug = seed.toLowerCase().replace(/\W+/g, "-").slice(0, 40);
20815
22196
  const rows = citations.map((c, i) => ({
20816
22197
  seed_query: seed,
@@ -20820,18 +22201,18 @@ var init_OutputSerializer = __esm({
20820
22201
  }));
20821
22202
  const csv = import_papaparse2.default.unparse(rows, { header: true });
20822
22203
  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");
22204
+ const fullPath = import_node_path10.default.join(outputDir, filename);
22205
+ await import_node_fs7.promises.writeFile(fullPath, csv, "utf8");
20825
22206
  return fullPath;
20826
22207
  }
20827
22208
  async writeWhatPeopleSayingCSV(cards, seed, outputDir) {
20828
- await import_node_fs6.promises.mkdir(outputDir, { recursive: true });
22209
+ await import_node_fs7.promises.mkdir(outputDir, { recursive: true });
20829
22210
  const slug = seed.toLowerCase().replace(/\W+/g, "-").slice(0, 40);
20830
22211
  const rows = cards.map((c) => ({ seed_query: seed, ...c }));
20831
22212
  const csv = import_papaparse2.default.unparse(rows, { header: true });
20832
22213
  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");
22214
+ const fullPath = import_node_path10.default.join(outputDir, filename);
22215
+ await import_node_fs7.promises.writeFile(fullPath, csv, "utf8");
20835
22216
  return fullPath;
20836
22217
  }
20837
22218
  };
@@ -21958,7 +23339,40 @@ var PACKAGE_VERSION;
21958
23339
  var init_version = __esm({
21959
23340
  "src/version.ts"() {
21960
23341
  "use strict";
21961
- PACKAGE_VERSION = "0.3.14";
23342
+ PACKAGE_VERSION = "0.3.15";
23343
+ }
23344
+ });
23345
+
23346
+ // src/mcp/server-instructions.ts
23347
+ var SERVER_INSTRUCTIONS;
23348
+ var init_server_instructions = __esm({
23349
+ "src/mcp/server-instructions.ts"() {
23350
+ "use strict";
23351
+ SERVER_INSTRUCTIONS = `
23352
+ This server scrapes and analyzes web, social, search, maps, and site data. Pick a tool by NAME using
23353
+ this routing map, then load its schema before calling.
23354
+
23355
+ ROUTING
23356
+ - One web page -> extract_url. Whole site (crawl + SEO report) -> extract_site. Just the URL list -> map_site_urls.
23357
+ - Web search (organic SERP) -> search_serp. Full SERP + People-Also-Ask / AI-Overview detail -> harvest_paa.
23358
+ - Google Maps: find places -> maps_search; one place deep-dive + reviews -> maps_place_intel.
23359
+ - YouTube: find or list videos -> youtube_harvest; transcribe one video -> youtube_transcribe.
23360
+ - Facebook: find ads -> facebook_ad_search; transcribe an ad -> facebook_ad_transcribe; transcribe a
23361
+ video -> facebook_video_transcribe; page profile/intel -> facebook_page_intel.
23362
+ - Instagram: profile inventory -> instagram_profile_content; one post or reel -> instagram_media_download.
23363
+ - Workflows (multi-step): local directory build -> directory_workflow; rank blueprint -> rank_tracker_workflow;
23364
+ AI-answer citation fan-out (AEO) -> query_fanout_workflow; deep research -> deep_research_workflow.
23365
+ - Anything without a dedicated tool (e.g. Reddit, arbitrary logged-in sites) -> the browser_* agent
23366
+ (browser_open, then navigate/read); save/reuse logins via browser_profile_*.
23367
+
23368
+ NOTES
23369
+ - Bulk / full-site crawls: call extract_site with rotateProxies:true for blocked or rate-limited sites.
23370
+ It discovers URLs and returns a saved folder/artifact plus a summary, not the full content inline.
23371
+ - Tools that open a browser session or transcribe media cost credits and take longer. Prefer the
23372
+ cheapest tool that answers the question (plain search/extract before browser agents).
23373
+ - Large results are saved to disk or an artifact and returned as a summary plus a path; read the path
23374
+ for full detail rather than expecting the whole payload inline.
23375
+ `.trim();
21962
23376
  }
21963
23377
  });
21964
23378
 
@@ -21991,11 +23405,14 @@ var init_mcp_tool_schemas = __esm({
21991
23405
  };
21992
23406
  MapSiteUrlsInputSchema = {
21993
23407
  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.")
23408
+ 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
23409
  };
21996
23410
  ExtractSiteInputSchema = {
21997
23411
  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.")
23412
+ 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."),
23413
+ 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."),
23414
+ 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."),
23415
+ 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.")
21999
23416
  };
22000
23417
  YoutubeHarvestInputSchema = {
22001
23418
  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."),
@@ -23076,7 +24493,7 @@ function localPlanningToolAnnotations(title) {
23076
24493
  function listSavedReports() {
23077
24494
  try {
23078
24495
  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);
24496
+ 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
24497
  } catch {
23081
24498
  return [];
23082
24499
  }
@@ -23100,15 +24517,15 @@ function registerSavedReportResources(server) {
23100
24517
  },
23101
24518
  async (uri, variables) => {
23102
24519
  const requested = Array.isArray(variables.filename) ? variables.filename[0] : variables.filename;
23103
- const filename = (0, import_node_path10.basename)(decodeURIComponent(String(requested ?? "")));
24520
+ const filename = (0, import_node_path11.basename)(decodeURIComponent(String(requested ?? "")));
23104
24521
  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");
24522
+ const text = (0, import_node_fs8.readFileSync)((0, import_node_path11.join)(outputBaseDir2(), filename), "utf8");
23106
24523
  return { contents: [{ uri: uri.href, mimeType: "text/markdown", text }] };
23107
24524
  }
23108
24525
  );
23109
24526
  }
23110
24527
  function buildPaaExtractorMcpServer(executor, options = {}) {
23111
- const server = new import_mcp.McpServer({ name: "mcp-scraper", version: PACKAGE_VERSION });
24528
+ const server = new import_mcp.McpServer({ name: "mcp-scraper", version: PACKAGE_VERSION }, { instructions: SERVER_INSTRUCTIONS });
23112
24529
  registerPaaExtractorMcpTools(server, executor, options);
23113
24530
  return server;
23114
24531
  }
@@ -23140,14 +24557,14 @@ function registerPaaExtractorMcpTools(server, executor, options = {}) {
23140
24557
  }, async (input) => formatExtractUrl(await executor.extractUrl(input), input));
23141
24558
  server.registerTool("map_site_urls", {
23142
24559
  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."),
24560
+ 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
24561
  inputSchema: MapSiteUrlsInputSchema,
23145
24562
  outputSchema: MapSiteUrlsOutputSchema,
23146
24563
  annotations: liveWebToolAnnotations("Site URL Map")
23147
24564
  }, async (input) => formatMapSiteUrls(await executor.mapSiteUrls(input), input));
23148
24565
  server.registerTool("extract_site", {
23149
24566
  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."),
24567
+ description: withReportNote("Run multi-page extraction across a public website when the user asks for a website audit, competitor audit, full-site content crawl, schema inventory, or page metadata review. Returns per-page titles, H1s, metadata, headings, schema/entity data, canonical URLs, and content. Supports up to maxPages=10000; bulk crawls over 25 pages switch to folder mode: each page is saved as its own Markdown file in a local folder and the response returns a summary plus the folder path instead of inlining all page content. Use map_site_urls first when URL selection matters; use extract_url for one page."),
23151
24568
  inputSchema: ExtractSiteInputSchema,
23152
24569
  outputSchema: ExtractSiteOutputSchema,
23153
24570
  annotations: liveWebToolAnnotations("Multi-Page Site Extract")
@@ -23271,7 +24688,7 @@ function registerPaaExtractorMcpTools(server, executor, options = {}) {
23271
24688
  outputSchema: WorkflowArtifactReadOutputSchema,
23272
24689
  annotations: liveWebToolAnnotations("Read Workflow Artifact")
23273
24690
  }, async (input) => formatWorkflowArtifactRead(await executor.workflowArtifactRead(input), input));
23274
- server.registerTool("rank_tracker_blueprint", {
24691
+ server.registerTool("rank_tracker_workflow", {
23275
24692
  title: "Rank Tracker Blueprint Builder",
23276
24693
  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
24694
  inputSchema: RankTrackerBlueprintInputSchema,
@@ -23292,15 +24709,16 @@ function registerPaaExtractorMcpTools(server, executor, options = {}) {
23292
24709
  }
23293
24710
  }, async (input) => formatCreditsInfo(await executor.creditsInfo(input), input));
23294
24711
  }
23295
- var import_mcp, import_node_fs7, import_node_path10;
24712
+ var import_mcp, import_node_fs8, import_node_path11;
23296
24713
  var init_paa_mcp_server = __esm({
23297
24714
  "src/mcp/paa-mcp-server.ts"() {
23298
24715
  "use strict";
23299
24716
  import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
23300
- import_node_fs7 = require("fs");
23301
- import_node_path10 = require("path");
24717
+ import_node_fs8 = require("fs");
24718
+ import_node_path11 = require("path");
23302
24719
  init_version();
23303
24720
  init_mcp_response_formatter();
24721
+ init_server_instructions();
23304
24722
  init_mcp_tool_schemas();
23305
24723
  init_rank_tracker_blueprint();
23306
24724
  init_mcp_response_formatter();
@@ -23709,7 +25127,7 @@ async function migrateBrowserAgent() {
23709
25127
  }
23710
25128
  async function createSessionRow(input) {
23711
25129
  const db = getDb();
23712
- const id = `bas_${(0, import_node_crypto5.randomUUID)().replace(/-/g, "").slice(0, 20)}`;
25130
+ const id = `bas_${(0, import_node_crypto6.randomUUID)().replace(/-/g, "").slice(0, 20)}`;
23713
25131
  await db.execute({
23714
25132
  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
25133
  VALUES (?, ?, ?, ?, 'open', ?, ?, ?, datetime('now'))`,
@@ -23770,7 +25188,7 @@ async function recordAction(input) {
23770
25188
  sql: `INSERT INTO browser_agent_actions (id, session_id, type, params_json, ok, error)
23771
25189
  VALUES (?, ?, ?, ?, ?, ?)`,
23772
25190
  args: [
23773
- `baa_${(0, import_node_crypto5.randomUUID)().replace(/-/g, "").slice(0, 20)}`,
25191
+ `baa_${(0, import_node_crypto6.randomUUID)().replace(/-/g, "").slice(0, 20)}`,
23774
25192
  input.sessionId,
23775
25193
  input.type,
23776
25194
  input.params == null ? null : JSON.stringify(input.params),
@@ -23810,11 +25228,11 @@ async function listReplayRows(sessionId) {
23810
25228
  });
23811
25229
  return res.rows;
23812
25230
  }
23813
- var import_node_crypto5, _ready;
25231
+ var import_node_crypto6, _ready;
23814
25232
  var init_browser_agent_db = __esm({
23815
25233
  "src/api/browser-agent-db.ts"() {
23816
25234
  "use strict";
23817
- import_node_crypto5 = require("crypto");
25235
+ import_node_crypto6 = require("crypto");
23818
25236
  init_db();
23819
25237
  _ready = false;
23820
25238
  }
@@ -24419,7 +25837,7 @@ function buildFanoutResult(ctx) {
24419
25837
  interceptorReady: ready,
24420
25838
  rawBytes,
24421
25839
  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."
25840
+ 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
25841
  };
24424
25842
  }
24425
25843
  return result;
@@ -24496,20 +25914,20 @@ var init_classify = __esm({
24496
25914
 
24497
25915
  // src/services/fanout/export.ts
24498
25916
  function outputBaseDir3() {
24499
- return process.env.MCP_SCRAPER_OUTPUT_DIR?.trim() || (0, import_node_path11.join)((0, import_node_os6.homedir)(), "Downloads", "mcp-scraper");
25917
+ return process.env.MCP_SCRAPER_OUTPUT_DIR?.trim() || (0, import_node_path12.join)((0, import_node_os7.homedir)(), "Downloads", "mcp-scraper");
24500
25918
  }
24501
25919
  function safe(value) {
24502
25920
  return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "capture";
24503
25921
  }
24504
25922
  function writeTable(path6, rows, delimiter) {
24505
- (0, import_node_fs8.writeFileSync)(path6, import_papaparse3.default.unparse(rows.length ? rows : [{}], { delimiter }));
25923
+ (0, import_node_fs9.writeFileSync)(path6, import_papaparse3.default.unparse(rows.length ? rows : [{}], { delimiter }));
24506
25924
  }
24507
25925
  function exportFanout(enriched) {
24508
25926
  const stamp = safe(enriched.capturedAt.replace(/[:.]/g, "-"));
24509
25927
  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 });
25928
+ const relativeDir = (0, import_node_path12.join)("fanout", `${stamp}-${safe(enriched.platform)}`);
25929
+ const dir = (0, import_node_path12.join)(outputDir, relativeDir);
25930
+ (0, import_node_fs9.mkdirSync)(dir, { recursive: true });
24513
25931
  const queryRows = enriched.queries.map((q, i) => ({ index: i + 1, query: q }));
24514
25932
  const citationRows = enriched.aggregates.citationOrder.map((c) => {
24515
25933
  const src = enriched.citedUrls.find((s) => s.url === c.url);
@@ -24522,25 +25940,25 @@ function exportFanout(enriched) {
24522
25940
  const relativePaths = {
24523
25941
  relativeTo: "MCP_SCRAPER_OUTPUT_DIR or ~/Downloads/mcp-scraper",
24524
25942
  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")
25943
+ json: (0, import_node_path12.join)(relativeDir, "fanout.json"),
25944
+ queriesCsv: (0, import_node_path12.join)(relativeDir, "queries.csv"),
25945
+ queriesTsv: (0, import_node_path12.join)(relativeDir, "queries.tsv"),
25946
+ citationsCsv: (0, import_node_path12.join)(relativeDir, "citations.csv"),
25947
+ sourcesCsv: (0, import_node_path12.join)(relativeDir, "sources.csv"),
25948
+ browsedOnlyCsv: (0, import_node_path12.join)(relativeDir, "browsed-only.csv"),
25949
+ snippetsCsv: (0, import_node_path12.join)(relativeDir, "snippets.csv"),
25950
+ domainsCsv: (0, import_node_path12.join)(relativeDir, "domains.csv"),
25951
+ report: (0, import_node_path12.join)(relativeDir, "report.html")
24534
25952
  };
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));
25953
+ (0, import_node_fs9.writeFileSync)((0, import_node_path12.join)(outputDir, relativePaths.json), JSON.stringify(enriched, null, 2));
25954
+ writeTable((0, import_node_path12.join)(outputDir, relativePaths.queriesCsv), queryRows, ",");
25955
+ writeTable((0, import_node_path12.join)(outputDir, relativePaths.queriesTsv), queryRows, " ");
25956
+ writeTable((0, import_node_path12.join)(outputDir, relativePaths.citationsCsv), citationRows, ",");
25957
+ writeTable((0, import_node_path12.join)(outputDir, relativePaths.sourcesCsv), sourceRows2, ",");
25958
+ writeTable((0, import_node_path12.join)(outputDir, relativePaths.browsedOnlyCsv), browsedOnlyRows, ",");
25959
+ writeTable((0, import_node_path12.join)(outputDir, relativePaths.snippetsCsv), snippetRows, ",");
25960
+ writeTable((0, import_node_path12.join)(outputDir, relativePaths.domainsCsv), domainRows, ",");
25961
+ (0, import_node_fs9.writeFileSync)((0, import_node_path12.join)(outputDir, relativePaths.report), renderReportHtml(enriched));
24544
25962
  return relativePaths;
24545
25963
  }
24546
25964
  function esc(s) {
@@ -24618,13 +26036,13 @@ render();
24618
26036
  </script>
24619
26037
  </body></html>`;
24620
26038
  }
24621
- var import_node_fs8, import_node_os6, import_node_path11, import_papaparse3;
26039
+ var import_node_fs9, import_node_os7, import_node_path12, import_papaparse3;
24622
26040
  var init_export = __esm({
24623
26041
  "src/services/fanout/export.ts"() {
24624
26042
  "use strict";
24625
- import_node_fs8 = require("fs");
24626
- import_node_os6 = require("os");
24627
- import_node_path11 = require("path");
26043
+ import_node_fs9 = require("fs");
26044
+ import_node_os7 = require("os");
26045
+ import_node_path12 = require("path");
24628
26046
  import_papaparse3 = __toESM(require("papaparse"), 1);
24629
26047
  }
24630
26048
  });
@@ -24672,7 +26090,7 @@ async function runFanoutCapture(page, input) {
24672
26090
  }
24673
26091
  const adapter = adapterForHost(hostname);
24674
26092
  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.`);
26093
+ 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
26094
  }
24677
26095
  const text = cap.bestAnswerText();
24678
26096
  const parsed = adapter.parse(text, input.prompt || "");
@@ -24710,7 +26128,7 @@ function keepHostedBrowserAlive(_browser) {
24710
26128
  function client() {
24711
26129
  const apiKey = browserServiceApiKey();
24712
26130
  if (!apiKey) throw new Error("Browser backend API key is required");
24713
- return new import_sdk6.default({ apiKey });
26131
+ return new import_sdk7.default({ apiKey });
24714
26132
  }
24715
26133
  function isProfileConflict(err) {
24716
26134
  const message = err instanceof Error ? err.message : String(err);
@@ -24862,7 +26280,7 @@ async function pressKeys(runtimeSessionId, keys) {
24862
26280
  await k.browsers.computer.pressKey(runtimeSessionId, { keys });
24863
26281
  }
24864
26282
  async function goto(cdpWsUrl, url) {
24865
- const browser = await import_playwright4.chromium.connectOverCDP(cdpWsUrl);
26283
+ const browser = await import_playwright5.chromium.connectOverCDP(cdpWsUrl);
24866
26284
  try {
24867
26285
  const context = browser.contexts()[0] ?? await browser.newContext();
24868
26286
  const page = context.pages()[0] ?? await context.newPage();
@@ -24873,7 +26291,7 @@ async function goto(cdpWsUrl, url) {
24873
26291
  }
24874
26292
  }
24875
26293
  async function captureFanout(cdpWsUrl, input) {
24876
- const browser = await import_playwright4.chromium.connectOverCDP(cdpWsUrl);
26294
+ const browser = await import_playwright5.chromium.connectOverCDP(cdpWsUrl);
24877
26295
  try {
24878
26296
  const context = browser.contexts()[0] ?? await browser.newContext();
24879
26297
  const page = context.pages()[0] ?? await context.newPage();
@@ -24883,7 +26301,7 @@ async function captureFanout(cdpWsUrl, input) {
24883
26301
  }
24884
26302
  }
24885
26303
  async function readPage(cdpWsUrl) {
24886
- const browser = await import_playwright4.chromium.connectOverCDP(cdpWsUrl);
26304
+ const browser = await import_playwright5.chromium.connectOverCDP(cdpWsUrl);
24887
26305
  try {
24888
26306
  const context = browser.contexts()[0] ?? await browser.newContext();
24889
26307
  const page = context.pages()[0] ?? await context.newPage();
@@ -24938,7 +26356,7 @@ async function readPage(cdpWsUrl) {
24938
26356
  }
24939
26357
  }
24940
26358
  async function locatePageTargets(cdpWsUrl, targets) {
24941
- const browser = await import_playwright4.chromium.connectOverCDP(cdpWsUrl);
26359
+ const browser = await import_playwright5.chromium.connectOverCDP(cdpWsUrl);
24942
26360
  try {
24943
26361
  const context = browser.contexts()[0] ?? await browser.newContext();
24944
26362
  const page = context.pages()[0] ?? await context.newPage();
@@ -24950,16 +26368,16 @@ async function locatePageTargets(cdpWsUrl, targets) {
24950
26368
  }).catch(() => {
24951
26369
  });
24952
26370
  const data = await page.evaluate((rawTargets) => {
24953
- const normalize2 = (value) => value.replace(/\s+/g, " ").trim();
26371
+ const normalize3 = (value) => value.replace(/\s+/g, " ").trim();
24954
26372
  const isVisibleRect = (r) => r.width >= 1 && r.height >= 1 && r.bottom >= 0 && r.right >= 0 && r.top <= window.innerHeight && r.left <= window.innerWidth;
24955
26373
  const roleOf = (el2) => el2.getAttribute("role") || el2.tagName.toLowerCase();
24956
26374
  const nameOf = (el2) => {
24957
26375
  const e = el2;
24958
- return normalize2(
26376
+ return normalize3(
24959
26377
  e.getAttribute("aria-label") || e.placeholder || e.innerText || e.getAttribute("value") || e.getAttribute("title") || ""
24960
26378
  ).slice(0, 160);
24961
26379
  };
24962
- const textOf = (el2) => normalize2(el2.innerText || el2.textContent || "").slice(0, 500);
26380
+ const textOf = (el2) => normalize3(el2.innerText || el2.textContent || "").slice(0, 500);
24963
26381
  const unionRects = (rects) => {
24964
26382
  const visible = rects.filter(isVisibleRect);
24965
26383
  if (!visible.length) return null;
@@ -24998,7 +26416,7 @@ async function locatePageTargets(cdpWsUrl, targets) {
24998
26416
  return nodes.map((el2) => ({ el: el2, rect: el2.getBoundingClientRect() })).filter((item) => isVisibleRect(item.rect));
24999
26417
  };
25000
26418
  const textMatches = (needle, match) => {
25001
- const wanted = normalize2(needle);
26419
+ const wanted = normalize3(needle);
25002
26420
  const wantedLower = wanted.toLowerCase();
25003
26421
  if (!wantedLower) return [];
25004
26422
  const matches = [];
@@ -25006,7 +26424,7 @@ async function locatePageTargets(cdpWsUrl, targets) {
25006
26424
  let node = walker.nextNode();
25007
26425
  while (node) {
25008
26426
  const raw = node.textContent || "";
25009
- const normalizedRaw = normalize2(raw);
26427
+ const normalizedRaw = normalize3(raw);
25010
26428
  const rawLower = raw.toLowerCase();
25011
26429
  const normalizedLower = normalizedRaw.toLowerCase();
25012
26430
  const ok = match === "exact" ? normalizedLower === wantedLower : normalizedLower.includes(wantedLower);
@@ -25102,12 +26520,12 @@ async function replayList(runtimeSessionId) {
25102
26520
  finishedAt: r.finished_at ?? null
25103
26521
  }));
25104
26522
  }
25105
- var import_sdk6, import_playwright4, DEFAULT_TIMEOUT_SECONDS;
26523
+ var import_sdk7, import_playwright5, DEFAULT_TIMEOUT_SECONDS;
25106
26524
  var init_browser_agent_service = __esm({
25107
26525
  "src/services/browser-agent/browser-agent-service.ts"() {
25108
26526
  "use strict";
25109
- import_sdk6 = __toESM(require("@onkernel/sdk"), 1);
25110
- import_playwright4 = require("playwright");
26527
+ import_sdk7 = __toESM(require("@onkernel/sdk"), 1);
26528
+ import_playwright5 = require("playwright");
25111
26529
  init_browser_service_env();
25112
26530
  init_run_capture();
25113
26531
  DEFAULT_TIMEOUT_SECONDS = 600;
@@ -26108,14 +27526,14 @@ function getSessionSecret() {
26108
27526
  function safeEqualHex(a, b) {
26109
27527
  if (a.length !== b.length) return false;
26110
27528
  try {
26111
- return (0, import_node_crypto6.timingSafeEqual)(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
27529
+ return (0, import_node_crypto7.timingSafeEqual)(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
26112
27530
  } catch {
26113
27531
  return false;
26114
27532
  }
26115
27533
  }
26116
27534
  function signSession(userId) {
26117
27535
  const payload = String(userId);
26118
- const sig = (0, import_node_crypto6.createHmac)("sha256", secret()).update(payload).digest("hex");
27536
+ const sig = (0, import_node_crypto7.createHmac)("sha256", secret()).update(payload).digest("hex");
26119
27537
  return `${payload}.${sig}`;
26120
27538
  }
26121
27539
  function verifySession(token) {
@@ -26123,16 +27541,16 @@ function verifySession(token) {
26123
27541
  if (dot === -1) return null;
26124
27542
  const payload = token.slice(0, dot);
26125
27543
  const sig = token.slice(dot + 1);
26126
- const expected = (0, import_node_crypto6.createHmac)("sha256", secret()).update(payload).digest("hex");
27544
+ const expected = (0, import_node_crypto7.createHmac)("sha256", secret()).update(payload).digest("hex");
26127
27545
  if (!safeEqualHex(sig, expected)) return null;
26128
27546
  const id = parseInt(payload);
26129
27547
  return isNaN(id) ? null : id;
26130
27548
  }
26131
- var import_node_crypto6, isProduction, secret;
27549
+ var import_node_crypto7, isProduction, secret;
26132
27550
  var init_session = __esm({
26133
27551
  "src/api/session.ts"() {
26134
27552
  "use strict";
26135
- import_node_crypto6 = require("crypto");
27553
+ import_node_crypto7 = require("crypto");
26136
27554
  isProduction = () => process.env.NODE_ENV === "production" || process.env.VERCEL === "1";
26137
27555
  secret = () => getSessionSecret();
26138
27556
  }
@@ -26379,6 +27797,9 @@ var init_server = __esm({
26379
27797
  import_hono14 = require("inngest/hono");
26380
27798
  init_client();
26381
27799
  init_site_audit();
27800
+ init_site_extract();
27801
+ init_site_extract_repository();
27802
+ init_catalog_seed();
26382
27803
  init_site_audit_routes();
26383
27804
  init_youtube_routes();
26384
27805
  init_screenshot_routes();
@@ -26563,6 +27984,10 @@ var init_server = __esm({
26563
27984
  const taken = await countActiveUsers();
26564
27985
  return c.json({ taken, open: true });
26565
27986
  });
27987
+ app.get("/catalog", (c) => {
27988
+ c.header("Cache-Control", "public, max-age=60");
27989
+ return c.json(CATALOG);
27990
+ });
26566
27991
  app.get("/me", async (c) => {
26567
27992
  const token = (0, import_cookie.getCookie)(c, "session");
26568
27993
  if (!token) return c.json({ authenticated: false });
@@ -26797,7 +28222,7 @@ var init_server = __esm({
26797
28222
  const raw = await c.req.json().catch(() => ({}));
26798
28223
  const bodyResult = ExtractUrlBodySchema.safeParse(raw);
26799
28224
  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;
28225
+ const { url, screenshot: screenshot2, screenshotDevice, extractBranding: extractBranding2, downloadMedia, mediaTypes, allowLocal } = bodyResult.data;
26801
28226
  if (!allowLocal) {
26802
28227
  const checked = await validatePublicHttpUrl(url, { field: "URL" });
26803
28228
  if (checked.error || !checked.parsed) return c.json({ error: checked.error ?? "Invalid URL" }, 400);
@@ -26830,7 +28255,7 @@ var init_server = __esm({
26830
28255
  const device = screenshotDevice === "mobile" ? "mobile" : "desktop";
26831
28256
  const [result, pageData] = await Promise.all([
26832
28257
  extractKpo({ url: canonicalUrl, kernelApiKey }),
26833
- screenshot2 || extractBranding ? capturePageData(canonicalUrl, { kernelApiKey, device, screenshot: !!screenshot2, branding: !!extractBranding }).catch((err) => {
28258
+ screenshot2 || extractBranding2 ? capturePageData(canonicalUrl, { kernelApiKey, device, screenshot: !!screenshot2, branding: !!extractBranding2 }).catch((err) => {
26834
28259
  console.error("[extract-url] capturePageData failed:", err instanceof Error ? err.message : err);
26835
28260
  return null;
26836
28261
  }) : null
@@ -26874,7 +28299,7 @@ var init_server = __esm({
26874
28299
  debited = true;
26875
28300
  const result = await spiderSite({
26876
28301
  startUrl: parsed.href,
26877
- maxUrls: Math.min(2e3, Math.max(1, body.maxUrls ?? 500)),
28302
+ maxUrls: Math.min(1e4, Math.max(1, body.maxUrls ?? 500)),
26878
28303
  concurrency: Math.min(20, Math.max(1, body.concurrency ?? 12)),
26879
28304
  kernelApiKey: body.browserFallback ?? body.kernelFallback ? browserServiceApiKey() : void 0
26880
28305
  });
@@ -26911,7 +28336,43 @@ var init_server = __esm({
26911
28336
  if (checked.error || !checked.parsed) return c.json({ error: checked.error ?? "Invalid URL" }, 400);
26912
28337
  const parsed = checked.parsed;
26913
28338
  const user = c.get("user");
26914
- const siteHoldMc = MC_COSTS.page_scrape * 10;
28339
+ if (body.background === true) {
28340
+ const affordablePages = Math.floor(user.balance_mc / MC_COSTS.page_scrape);
28341
+ if (affordablePages < 1) return c.json(insufficientBalanceResponse(user.balance_mc, MC_COSTS.page_scrape), 402);
28342
+ const maxPages = Math.min(Math.min(1e4, Math.max(1, body.maxPages ?? 1e4)), affordablePages);
28343
+ const heldMc = maxPages * MC_COSTS.page_scrape;
28344
+ const { ok: holdOk, balance_mc: holdBal } = await debitMc(user.id, heldMc, LedgerOperation.EXTRACT_SITE_HOLD, parsed.hostname);
28345
+ if (!holdOk) return c.json(insufficientBalanceResponse(holdBal, heldMc), 402);
28346
+ const jobId = `ext_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
28347
+ let jobCreated = false;
28348
+ try {
28349
+ await createExtractJob(jobId, user.id, parsed.href, {
28350
+ maxPages,
28351
+ concurrency: concurrencyLimitForUser(user),
28352
+ urlsPerBrowser: body.rotateProxyEvery ?? 10,
28353
+ formats: body.formats,
28354
+ heldMc
28355
+ });
28356
+ jobCreated = true;
28357
+ await inngest.send({ name: "mcp-scraper/extract.requested", data: { jobId } });
28358
+ } catch (err) {
28359
+ if (jobCreated) {
28360
+ await settleExtractJob(jobId, user.id, heldMc, 0, "enqueue failed").catch(() => {
28361
+ });
28362
+ } else {
28363
+ await creditMc(user.id, heldMc, LedgerOperation.EXTRACT_SITE_REFUND, "enqueue failed").catch(() => {
28364
+ });
28365
+ }
28366
+ await failExtractJob(jobId, err instanceof Error ? err.message : "enqueue failed").catch(() => {
28367
+ });
28368
+ return c.json({ error: "Failed to enqueue background crawl" }, 503);
28369
+ }
28370
+ return c.json({ jobId, status: "pending", statusUrl: `/extract-site/status/${jobId}` });
28371
+ }
28372
+ const affordablePagesSync = Math.floor(user.balance_mc / MC_COSTS.page_scrape);
28373
+ if (affordablePagesSync < 1) return c.json(insufficientBalanceResponse(user.balance_mc, MC_COSTS.page_scrape), 402);
28374
+ const siteMaxPages = Math.min(Math.min(1e4, Math.max(1, body.maxPages ?? 100)), affordablePagesSync);
28375
+ const siteHoldMc = siteMaxPages * MC_COSTS.page_scrape;
26915
28376
  const gate = await acquireConcurrencyGate(user, "extract_site", {
26916
28377
  reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
26917
28378
  metadata: { url: parsed.href }
@@ -26922,10 +28383,17 @@ var init_server = __esm({
26922
28383
  const { ok: siteOk, balance_mc: siteBal } = await debitMc(user.id, siteHoldMc, LedgerOperation.EXTRACT_SITE_HOLD, parsed.hostname);
26923
28384
  if (!siteOk) return c.json(insufficientBalanceResponse(siteBal, siteHoldMc), 402);
26924
28385
  debited = true;
28386
+ const rotateProxies = body.rotateProxies === true;
28387
+ const wantsBranding = body.formats?.includes("branding") === true;
26925
28388
  const result = await extractSite({
26926
28389
  startUrl: parsed.href,
26927
- maxPages: Math.min(200, Math.max(1, body.maxPages ?? 100)),
26928
- kernelApiKey: body.browserFallback ?? body.kernelFallback ? browserServiceApiKey() : void 0
28390
+ maxPages: siteMaxPages,
28391
+ kernelApiKey: rotateProxies || wantsBranding || body.browserFallback || body.kernelFallback ? browserServiceApiKey() : void 0,
28392
+ formats: body.formats,
28393
+ ...rotateProxies ? {
28394
+ rotateProxyEvery: body.rotateProxyEvery ?? 30,
28395
+ parallelism: concurrencyLimitForUser(user)
28396
+ } : {}
26929
28397
  });
26930
28398
  const pageCount = result.pages?.length ?? 1;
26931
28399
  const actualSiteMc = pageCount * MC_COSTS.page_scrape;
@@ -26956,6 +28424,21 @@ var init_server = __esm({
26956
28424
  await releaseConcurrencyGate(gate.lockId);
26957
28425
  }
26958
28426
  });
28427
+ app.get("/extract-site/status/:id", auth2, async (c) => {
28428
+ const user = c.get("user");
28429
+ const job = await getExtractJob(c.req.param("id"));
28430
+ if (!job || job.userId !== user.id) return c.json({ error: "Job not found" }, 404);
28431
+ return c.json({
28432
+ jobId: job.id,
28433
+ status: job.status,
28434
+ startUrl: job.startUrl,
28435
+ totalUrls: job.totalUrls,
28436
+ doneUrls: job.doneUrls,
28437
+ artifacts: job.artifacts ?? [],
28438
+ error: job.error,
28439
+ updatedAt: job.updatedAt
28440
+ });
28441
+ });
26959
28442
  app.post("/billing/checkout", requireAllowedOrigin, sessionAuth, async (c) => {
26960
28443
  try {
26961
28444
  const user = c.get("sessionUser");
@@ -27144,7 +28627,7 @@ var init_server = __esm({
27144
28627
  ]);
27145
28628
  return c.json({ drained: results.length, results, sweepResult, workflowDispatch: workflowDispatchResult });
27146
28629
  });
27147
- app.on(["GET", "POST", "PUT"], "/api/inngest", (0, import_hono14.serve)({ client: inngest, functions: [siteAuditFn] }));
28630
+ app.on(["GET", "POST", "PUT"], "/api/inngest", (0, import_hono14.serve)({ client: inngest, functions: [siteAuditFn, siteExtractFn] }));
27148
28631
  app.route("/api/internal/site-architecture-auditor", siteAuditApp);
27149
28632
  app.route("/youtube", youtubeApp);
27150
28633
  app.route("/screenshot", screenshotApp);
@@ -27266,10 +28749,10 @@ ${ATTRIBUTION_PIXEL_SCRIPT}
27266
28749
  });
27267
28750
 
27268
28751
  // bin/api-server.ts
27269
- var import_node_fs9 = require("fs");
28752
+ var import_node_fs10 = require("fs");
27270
28753
  function loadDotEnv() {
27271
28754
  try {
27272
- for (const line of (0, import_node_fs9.readFileSync)(".env", "utf8").split("\n")) {
28755
+ for (const line of (0, import_node_fs10.readFileSync)(".env", "utf8").split("\n")) {
27273
28756
  const eq = line.indexOf("=");
27274
28757
  if (eq < 1 || line.trimStart().startsWith("#")) continue;
27275
28758
  const k = line.slice(0, eq).trim();