mcp-scraper 0.3.14 → 0.3.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/bin/api-server.cjs +2261 -511
  2. package/dist/bin/api-server.cjs.map +1 -1
  3. package/dist/bin/api-server.js +3 -3
  4. package/dist/bin/browser-agent-stdio-server.cjs +6 -6
  5. package/dist/bin/browser-agent-stdio-server.cjs.map +1 -1
  6. package/dist/bin/browser-agent-stdio-server.js +2 -2
  7. package/dist/bin/mcp-scraper-cli.cjs +1 -1
  8. package/dist/bin/mcp-scraper-cli.cjs.map +1 -1
  9. package/dist/bin/mcp-scraper-cli.js +1 -1
  10. package/dist/bin/mcp-scraper-combined-stdio-server.cjs +663 -48
  11. package/dist/bin/mcp-scraper-combined-stdio-server.cjs.map +1 -1
  12. package/dist/bin/mcp-scraper-combined-stdio-server.js +6 -5
  13. package/dist/bin/mcp-scraper-combined-stdio-server.js.map +1 -1
  14. package/dist/bin/mcp-scraper-install.cjs +4 -4
  15. package/dist/bin/mcp-scraper-install.cjs.map +1 -1
  16. package/dist/bin/mcp-scraper-install.js +2 -2
  17. package/dist/bin/mcp-stdio-server.cjs +655 -40
  18. package/dist/bin/mcp-stdio-server.cjs.map +1 -1
  19. package/dist/bin/mcp-stdio-server.js +2 -2
  20. package/dist/{chunk-DBQDG7EH.js → chunk-5H22TOXQ.js} +28 -1
  21. package/dist/chunk-5H22TOXQ.js.map +1 -0
  22. package/dist/{chunk-HM6FHV5U.js → chunk-7XARJHS6.js} +662 -41
  23. package/dist/chunk-7XARJHS6.js.map +1 -0
  24. package/dist/{chunk-KPXMPAJ3.js → chunk-ACIUCZ27.js} +4 -4
  25. package/dist/chunk-ACIUCZ27.js.map +1 -0
  26. package/dist/{chunk-AX7UBYLG.js → chunk-ISZWGIWL.js} +7 -7
  27. package/dist/chunk-ISZWGIWL.js.map +1 -0
  28. package/dist/chunk-SOMBZK3V.js +7 -0
  29. package/dist/chunk-SOMBZK3V.js.map +1 -0
  30. package/dist/{chunk-AZ5PKU4F.js → chunk-ZAUMSBV3.js} +2 -2
  31. package/dist/{db-BE4JVB3V.js → db-P76GVIFN.js} +2 -2
  32. package/dist/{server-ILIVSPNY.js → server-LPVBSE2E.js} +1133 -93
  33. package/dist/server-LPVBSE2E.js.map +1 -0
  34. package/dist/{worker-FG7ZWEGA.js → worker-2WVKKCC7.js} +3 -3
  35. package/docs/final-tooling-spec.md +206 -0
  36. package/docs/mcp-tool-design-guide.md +225 -0
  37. package/docs/mcp-tool-manifest.generated.json +57 -22
  38. package/docs/seo-crawl-report-spec.md +287 -0
  39. package/docs/tool-catalog-spec.md +388 -0
  40. package/package.json +2 -1
  41. package/dist/chunk-3QHZPR4U.js +0 -7
  42. package/dist/chunk-3QHZPR4U.js.map +0 -1
  43. package/dist/chunk-AX7UBYLG.js.map +0 -1
  44. package/dist/chunk-DBQDG7EH.js.map +0 -1
  45. package/dist/chunk-HM6FHV5U.js.map +0 -1
  46. package/dist/chunk-KPXMPAJ3.js.map +0 -1
  47. package/dist/server-ILIVSPNY.js.map +0 -1
  48. /package/dist/{chunk-AZ5PKU4F.js.map → chunk-ZAUMSBV3.js.map} +0 -0
  49. /package/dist/{db-BE4JVB3V.js.map → db-P76GVIFN.js.map} +0 -0
  50. /package/dist/{worker-FG7ZWEGA.js.map → worker-2WVKKCC7.js.map} +0 -0
@@ -16,7 +16,7 @@ import {
16
16
  harvestProblemResponse,
17
17
  insufficientBalanceResponse,
18
18
  serializeHarvestProblem
19
- } from "./chunk-AZ5PKU4F.js";
19
+ } from "./chunk-ZAUMSBV3.js";
20
20
  import {
21
21
  SiteAuditJobRowSchema,
22
22
  advanceWorkflowRunStep,
@@ -77,7 +77,7 @@ import {
77
77
  setPassword,
78
78
  setStripeCustomerId,
79
79
  verifyPassword
80
- } from "./chunk-DBQDG7EH.js";
80
+ } from "./chunk-5H22TOXQ.js";
81
81
  import {
82
82
  BrowserDriver,
83
83
  MapsSelectors,
@@ -92,14 +92,19 @@ import {
92
92
  CaptureSerpSnapshotInputSchema,
93
93
  CaptureSerpSnapshotOutputSchema,
94
94
  HttpMcpToolExecutor,
95
+ auditImages,
96
+ buildLinkGraph,
95
97
  buildPaaExtractorMcpServer,
98
+ computeIssues,
96
99
  configureReportSaving,
97
100
  formatCaptureSerpPageSnapshots,
98
101
  formatCaptureSerpSnapshot,
99
102
  harvestTimeoutBudget,
100
103
  liveWebToolAnnotations,
101
- outputBaseDir
102
- } from "./chunk-HM6FHV5U.js";
104
+ outputBaseDir,
105
+ renderImageSection,
106
+ renderIssueReport
107
+ } from "./chunk-7XARJHS6.js";
103
108
  import {
104
109
  CaptchaError,
105
110
  RECAPTCHA_INSTRUCTIONS,
@@ -135,7 +140,7 @@ import {
135
140
  RawMapsOverviewSchema,
136
141
  RawMapsReviewStatsSchema
137
142
  } from "./chunk-LFATOGDF.js";
138
- import "./chunk-3QHZPR4U.js";
143
+ import "./chunk-SOMBZK3V.js";
139
144
 
140
145
  // src/api/outbound-sanitize.ts
141
146
  var KEY_RENAMES = {
@@ -4195,6 +4200,10 @@ async function captureScreenshot(url, kernelApiKey, device = "desktop") {
4195
4200
  const result = await capturePageData(url, { kernelApiKey, device, screenshot: true, branding: false });
4196
4201
  return result.screenshot;
4197
4202
  }
4203
+ async function extractBranding(url, kernelApiKey, device = "desktop") {
4204
+ const result = await capturePageData(url, { kernelApiKey, device, screenshot: false, branding: true });
4205
+ return result.branding;
4206
+ }
4198
4207
  async function capturePageData(url, opts = {}) {
4199
4208
  const device = opts.device ?? "desktop";
4200
4209
  const wantShot = opts.screenshot ?? false;
@@ -4698,9 +4707,10 @@ function baseDomain(url) {
4698
4707
  }
4699
4708
  async function spiderSite(opts) {
4700
4709
  const startMs = Date.now();
4701
- const maxUrls = Math.min(opts.maxUrls ?? 500, 2e3);
4710
+ const maxUrls = Math.min(opts.maxUrls ?? 500, 1e4);
4702
4711
  const concurrency = Math.min(opts.concurrency ?? 12, 20);
4703
4712
  const timeoutMs = opts.timeoutMs ?? 8e3;
4713
+ const budgetMs = opts.budgetMs ?? 24e4;
4704
4714
  const startNorm = normalizeUrl(opts.startUrl, opts.startUrl) ?? opts.startUrl;
4705
4715
  const startDomain = baseDomain(opts.startUrl);
4706
4716
  const visited = /* @__PURE__ */ new Set();
@@ -4714,7 +4724,7 @@ async function spiderSite(opts) {
4714
4724
  queue.push({ url: loc, from: "sitemap" });
4715
4725
  }
4716
4726
  }
4717
- while (queue.length > 0 && results.length < maxUrls) {
4727
+ while (queue.length > 0 && results.length < maxUrls && Date.now() - startMs < budgetMs) {
4718
4728
  const batch = queue.splice(0, concurrency);
4719
4729
  const settled = await Promise.all(
4720
4730
  batch.map(async ({ url, from }) => {
@@ -4774,12 +4784,193 @@ async function spiderSite(opts) {
4774
4784
 
4775
4785
  // src/api/site-extractor.ts
4776
4786
  import TurndownService2 from "turndown";
4787
+ import { createHash } from "crypto";
4788
+
4789
+ // src/api/rotating-proxy-crawl.ts
4790
+ import Kernel3 from "@onkernel/sdk";
4791
+ import { chromium as chromium3 } from "playwright";
4792
+ var DEFAULT_CONCURRENCY = 1;
4793
+ var DEFAULT_URLS_PER_BROWSER = 10;
4794
+ var DEFAULT_HEADLESS = true;
4795
+ async function discoverSitemapUrls2(startUrl, maxUrls) {
4796
+ const domain = new URL(startUrl).hostname.replace(/^www\./, "");
4797
+ const urls = await discoverSitemapUrls(startUrl, domain);
4798
+ return urls.slice(0, maxUrls);
4799
+ }
4800
+ function withTimeout(p, ms) {
4801
+ return Promise.race([p, new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms))]);
4802
+ }
4803
+ async function createBrowserWithRetry(client2, headless) {
4804
+ for (let attempt = 0; attempt < 3; attempt++) {
4805
+ try {
4806
+ return await client2.browsers.create({ stealth: true, headless, timeout_seconds: 120 });
4807
+ } catch (err) {
4808
+ if (attempt === 2) throw err;
4809
+ await new Promise((r) => setTimeout(r, 5e3));
4810
+ }
4811
+ }
4812
+ throw new Error("unreachable");
4813
+ }
4814
+ var PAGE_HARD_TIMEOUT_MS = 35e3;
4815
+ async function crawlPage(context, url) {
4816
+ const startedAt = Date.now();
4817
+ let page = null;
4818
+ try {
4819
+ page = await context.newPage();
4820
+ const work = (async (p) => {
4821
+ const resp = await p.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
4822
+ const html = await p.content();
4823
+ const headers = resp ? Object.fromEntries(Object.entries(await resp.headers()).map(([k, v]) => [k.toLowerCase(), v])) : void 0;
4824
+ return { url, html, status: resp?.status() ?? 200, via: "browser", headers, responseTimeMs: Date.now() - startedAt, redirectUrl: headers?.["location"] ?? null };
4825
+ })(page);
4826
+ const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("page-hard-timeout")), PAGE_HARD_TIMEOUT_MS));
4827
+ return await Promise.race([work, timeout]);
4828
+ } catch {
4829
+ return { url, html: null, status: 0, via: "browser", responseTimeMs: Date.now() - startedAt };
4830
+ } finally {
4831
+ if (page) void page.close().catch(() => {
4832
+ });
4833
+ }
4834
+ }
4835
+ async function crawlWithRotation(urls, opts) {
4836
+ const client2 = new Kernel3({ apiKey: opts.apiKey });
4837
+ const concurrency = Math.max(1, opts.concurrency ?? DEFAULT_CONCURRENCY);
4838
+ const urlsPerBrowser = Math.max(1, opts.urlsPerBrowser ?? DEFAULT_URLS_PER_BROWSER);
4839
+ const headless = opts.headless ?? DEFAULT_HEADLESS;
4840
+ const uniqueUrls = [...new Set(urls)];
4841
+ const byUrl = /* @__PURE__ */ new Map();
4842
+ const runPass = async (targets, phase) => {
4843
+ if (targets.length === 0) return;
4844
+ let next = 0;
4845
+ let done = 0;
4846
+ const workers = Math.min(concurrency, targets.length);
4847
+ const worker = async () => {
4848
+ let kb = null;
4849
+ let browser = null;
4850
+ let inBrowser = 0;
4851
+ const retire = () => {
4852
+ const b = browser, k = kb;
4853
+ browser = null;
4854
+ kb = null;
4855
+ if (b) void b.close().catch(() => {
4856
+ });
4857
+ if (k) void client2.browsers.deleteByID(k.session_id).catch(() => {
4858
+ });
4859
+ };
4860
+ try {
4861
+ while (true) {
4862
+ const i = next++;
4863
+ if (i >= targets.length) break;
4864
+ const url = targets[i];
4865
+ let r;
4866
+ try {
4867
+ if (!browser || inBrowser >= urlsPerBrowser) {
4868
+ retire();
4869
+ kb = await createBrowserWithRetry(client2, headless);
4870
+ browser = await withTimeout(chromium3.connectOverCDP(kb.cdp_ws_url), 3e4);
4871
+ inBrowser = 0;
4872
+ }
4873
+ const context = browser.contexts()[0] ?? await browser.newContext();
4874
+ r = await crawlPage(context, url);
4875
+ inBrowser++;
4876
+ } catch {
4877
+ r = { url, html: null, status: 0, via: "browser" };
4878
+ retire();
4879
+ }
4880
+ byUrl.set(url, r);
4881
+ done++;
4882
+ opts.onPage?.({ url, status: r.status, ok: !!(r.html && r.status >= 200 && r.status < 300), ms: r.responseTimeMs ?? 0, done, total: targets.length, phase });
4883
+ }
4884
+ } finally {
4885
+ retire();
4886
+ }
4887
+ };
4888
+ await Promise.all(Array.from({ length: workers }, () => worker()));
4889
+ };
4890
+ await runPass(uniqueUrls, "main");
4891
+ const failed = uniqueUrls.filter((u) => {
4892
+ const r = byUrl.get(u);
4893
+ return !r || !r.html || r.status < 200 || r.status >= 300;
4894
+ });
4895
+ if (failed.length > 0) await runPass(failed, "retry");
4896
+ return urls.map((u) => byUrl.get(u) ?? { url: u, html: null, status: 0, via: "browser" });
4897
+ }
4898
+
4899
+ // src/api/site-extractor.ts
4900
+ var 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 };
4901
+ function estimatePixels(s) {
4902
+ let px = 0;
4903
+ for (const ch of s) px += PIXEL_WIDTHS[ch] ?? (ch === ch.toUpperCase() && ch !== ch.toLowerCase() ? 10 : 8);
4904
+ return px;
4905
+ }
4906
+ function emptyPageData(url, status, via) {
4907
+ return {
4908
+ url,
4909
+ status,
4910
+ via,
4911
+ contentType: null,
4912
+ responseTimeMs: null,
4913
+ sizeBytes: null,
4914
+ redirectUrl: null,
4915
+ lastModified: null,
4916
+ xRobotsTag: null,
4917
+ title: null,
4918
+ titleLength: null,
4919
+ titlePixels: null,
4920
+ metaDescription: null,
4921
+ metaDescLength: null,
4922
+ metaKeywords: null,
4923
+ h1: null,
4924
+ h1_2: null,
4925
+ h2Count: 0,
4926
+ headings: [],
4927
+ wordCount: 0,
4928
+ textRatio: 0,
4929
+ contentHash: "",
4930
+ metaRobots: null,
4931
+ relNext: null,
4932
+ relPrev: null,
4933
+ hreflang: [],
4934
+ og: null,
4935
+ twitter: null,
4936
+ ampHref: null,
4937
+ imageCount: 0,
4938
+ imagesMissingAlt: 0,
4939
+ imageLinks: [],
4940
+ indexable: status === 200,
4941
+ indexabilityReason: status === 200 ? null : "non-200",
4942
+ schemaTypes: [],
4943
+ canonicalUrl: null,
4944
+ internalLinks: 0,
4945
+ externalLinks: 0,
4946
+ outlinks: [],
4947
+ bodyMarkdown: "",
4948
+ schema: []
4949
+ };
4950
+ }
4951
+ function makeSeedSpider(startUrl, urls) {
4952
+ return {
4953
+ startUrl,
4954
+ urls: urls.map((url) => ({ url, status: 200, linkedFrom: "seed", via: "fetch" })),
4955
+ totalFound: urls.length,
4956
+ durationMs: 0,
4957
+ truncated: false,
4958
+ browserRetries: 0
4959
+ };
4960
+ }
4777
4961
  var UA2 = "Mozilla/5.0 (compatible; ThorbitBot/1.0; +https://thorbit.ai)";
4778
4962
  var EXTRACT_CONCURRENCY = 6;
4779
4963
  var MAX_PAGE_MARKDOWN = 4e4;
4780
4964
  var turndown = new TurndownService2({ headingStyle: "atx", bulletListMarker: "-", codeBlockStyle: "fenced" });
4781
- function parsePageData(url, html, status, via) {
4965
+ function parsePageData(url, html, status, via, resp) {
4782
4966
  const origin = new URL(url).origin;
4967
+ const headers = resp?.headers ?? {};
4968
+ const contentType = headers["content-type"] ?? null;
4969
+ const lastModified = headers["last-modified"] ?? null;
4970
+ const xRobotsTag = headers["x-robots-tag"] ?? null;
4971
+ const responseTimeMs = resp?.responseTimeMs ?? null;
4972
+ const redirectUrl = resp?.redirectUrl ?? headers["location"] ?? null;
4973
+ const sizeBytes = headers["content-length"] ? Number(headers["content-length"]) : Buffer.byteLength(html);
4783
4974
  const title = html.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1]?.trim() ?? null;
4784
4975
  const metaDescription = html.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i)?.[1]?.trim() ?? html.match(/<meta[^>]+content=["']([^"']*)["'][^>]+name=["']description["']/i)?.[1]?.trim() ?? null;
4785
4976
  const canonicalUrl = html.match(/<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']*)["']/i)?.[1]?.trim() ?? html.match(/<link[^>]+href=["']([^"']*)["'][^>]+rel=["']canonical["']/i)?.[1]?.trim() ?? null;
@@ -4816,38 +5007,170 @@ function parsePageData(url, html, status, via) {
4816
5007
  } catch {
4817
5008
  bodyMarkdown = bodyText.slice(0, MAX_PAGE_MARKDOWN);
4818
5009
  }
4819
- let internalLinks = 0;
4820
- let externalLinks = 0;
4821
- for (const m of html.matchAll(/href\s*=\s*["']([^"'\s>]+)/gi)) {
5010
+ const outlinks = [];
5011
+ for (const m of html.matchAll(/<a\b([^>]*?)href\s*=\s*["']([^"'#][^"']*)["']([^>]*)>([\s\S]*?)<\/a>/gi)) {
4822
5012
  try {
4823
- const abs = new URL(m[1], url);
4824
- if (abs.origin === origin) internalLinks++;
4825
- else externalLinks++;
5013
+ const abs = new URL(m[2], url);
5014
+ if (abs.protocol !== "http:" && abs.protocol !== "https:") continue;
5015
+ const attrs = `${m[1]} ${m[3]}`;
5016
+ const rel = attrs.match(/\brel\s*=\s*["']([^"']*)["']/i)?.[1]?.toLowerCase() ?? null;
5017
+ const anchor = m[4].replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 160);
5018
+ outlinks.push({ href: abs.href, anchor, rel, internal: abs.origin === origin });
4826
5019
  } catch {
4827
5020
  }
4828
5021
  }
4829
- return { url, status, via, title, metaDescription, h1, headings, wordCount, schemaTypes, canonicalUrl, internalLinks, externalLinks, bodyMarkdown, schema };
5022
+ const internalLinks = outlinks.filter((l) => l.internal).length;
5023
+ const externalLinks = outlinks.length - internalLinks;
5024
+ const titleLength = title?.length ?? null;
5025
+ const titlePixels = title ? estimatePixels(title) : null;
5026
+ const metaDescLength = metaDescription?.length ?? null;
5027
+ const metaKeywords = html.match(/<meta[^>]+name=["']keywords["'][^>]+content=["']([^"']*)["']/i)?.[1]?.trim() ?? null;
5028
+ const h1List = headings.filter((h) => h.level === 1);
5029
+ const h1_2 = h1List[1]?.text ?? null;
5030
+ const h2Count = headings.filter((h) => h.level === 2).length;
5031
+ const textRatio = Number((bodyText.length / Math.max(1, html.length)).toFixed(3));
5032
+ const contentHash = createHash("sha1").update(bodyText).digest("hex");
5033
+ const metaRobots = html.match(/<meta[^>]+name=["']robots["'][^>]+content=["']([^"']*)["']/i)?.[1]?.trim().toLowerCase() ?? null;
5034
+ const relNext = html.match(/<link[^>]+rel=["']next["'][^>]+href=["']([^"']*)["']/i)?.[1]?.trim() ?? null;
5035
+ const relPrev = html.match(/<link[^>]+rel=["']prev["'][^>]+href=["']([^"']*)["']/i)?.[1]?.trim() ?? null;
5036
+ const hreflang = [];
5037
+ for (const m of html.matchAll(/<link[^>]+rel=["']alternate["'][^>]*>/gi)) {
5038
+ const lang = m[0].match(/hreflang=["']([^"']*)["']/i)?.[1];
5039
+ const href = m[0].match(/href=["']([^"']*)["']/i)?.[1];
5040
+ if (lang && href) hreflang.push({ lang, href });
5041
+ }
5042
+ const ogTag = (p) => html.match(new RegExp(`<meta[^>]+property=["']og:${p}["'][^>]+content=["']([^"']*)["']`, "i"))?.[1]?.trim();
5043
+ const og = ogTag("title") || ogTag("description") || ogTag("image") || ogTag("type") ? { title: ogTag("title"), description: ogTag("description"), image: ogTag("image"), type: ogTag("type") } : null;
5044
+ const twTag = (p) => html.match(new RegExp(`<meta[^>]+name=["']twitter:${p}["'][^>]+content=["']([^"']*)["']`, "i"))?.[1]?.trim();
5045
+ const twitter = twTag("card") || twTag("title") || twTag("description") ? { card: twTag("card"), title: twTag("title"), description: twTag("description") } : null;
5046
+ const ampHref = html.match(/<link[^>]+rel=["']amphtml["'][^>]+href=["']([^"']*)["']/i)?.[1]?.trim() ?? null;
5047
+ let imageCount = 0;
5048
+ let imagesMissingAlt = 0;
5049
+ const imageLinkSet = /* @__PURE__ */ new Set();
5050
+ for (const m of html.matchAll(/<img\b([^>]*)>/gi)) {
5051
+ imageCount++;
5052
+ const alt = m[1].match(/\balt\s*=\s*["']([^"']*)["']/i)?.[1];
5053
+ if (!alt || !alt.trim()) imagesMissingAlt++;
5054
+ const src = m[1].match(/\bsrc\s*=\s*["']([^"']+)["']/i)?.[1];
5055
+ if (src) {
5056
+ try {
5057
+ const abs = new URL(src, url);
5058
+ if (abs.protocol === "http:" || abs.protocol === "https:") imageLinkSet.add(abs.href);
5059
+ } catch {
5060
+ }
5061
+ }
5062
+ }
5063
+ if (og?.image) {
5064
+ try {
5065
+ imageLinkSet.add(new URL(og.image, url).href);
5066
+ } catch {
5067
+ }
5068
+ }
5069
+ const imageLinks = [...imageLinkSet].slice(0, 300);
5070
+ const canonNorm = (u) => {
5071
+ try {
5072
+ const x = new URL(u, url);
5073
+ return (x.origin + x.pathname).replace(/\/+$/, "");
5074
+ } catch {
5075
+ return u.replace(/\/+$/, "");
5076
+ }
5077
+ };
5078
+ const canonicalSelf = canonicalUrl ? canonNorm(canonicalUrl) === canonNorm(url) : true;
5079
+ let indexable = true;
5080
+ let indexabilityReason = null;
5081
+ if (status !== 200) {
5082
+ indexable = false;
5083
+ indexabilityReason = "non-200";
5084
+ } else if (metaRobots?.includes("noindex")) {
5085
+ indexable = false;
5086
+ indexabilityReason = "noindex";
5087
+ } else if (xRobotsTag?.toLowerCase().includes("noindex")) {
5088
+ indexable = false;
5089
+ indexabilityReason = "x-robots-noindex";
5090
+ } else if (canonicalUrl && !canonicalSelf) {
5091
+ indexable = false;
5092
+ indexabilityReason = "canonicalised";
5093
+ }
5094
+ return {
5095
+ url,
5096
+ status,
5097
+ via,
5098
+ contentType,
5099
+ responseTimeMs,
5100
+ sizeBytes,
5101
+ redirectUrl,
5102
+ lastModified,
5103
+ xRobotsTag,
5104
+ title,
5105
+ titleLength,
5106
+ titlePixels,
5107
+ metaDescription,
5108
+ metaDescLength,
5109
+ metaKeywords,
5110
+ h1,
5111
+ h1_2,
5112
+ h2Count,
5113
+ headings,
5114
+ wordCount,
5115
+ textRatio,
5116
+ contentHash,
5117
+ metaRobots,
5118
+ relNext,
5119
+ relPrev,
5120
+ hreflang,
5121
+ og,
5122
+ twitter,
5123
+ ampHref,
5124
+ imageCount,
5125
+ imagesMissingAlt,
5126
+ imageLinks,
5127
+ indexable,
5128
+ indexabilityReason,
5129
+ schemaTypes,
5130
+ canonicalUrl,
5131
+ internalLinks,
5132
+ externalLinks,
5133
+ outlinks,
5134
+ bodyMarkdown,
5135
+ schema
5136
+ };
5137
+ }
5138
+ async function extractPagesRotating(urls, opts) {
5139
+ const fetched = await crawlWithRotation(urls, { apiKey: opts.kernelApiKey, concurrency: opts.concurrency, urlsPerBrowser: opts.urlsPerBrowser });
5140
+ 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"));
5141
+ }
5142
+ async function discoverUrls(startUrl, maxPages, kernelApiKey) {
5143
+ const sitemapUrls = await discoverSitemapUrls2(startUrl, maxPages);
5144
+ if (sitemapUrls.length > 0) return sitemapUrls;
5145
+ const spider = await spiderSite({ startUrl, maxUrls: maxPages, concurrency: 12, kernelApiKey });
5146
+ return spider.urls.filter((u) => u.status === 200 || u.status === null).slice(0, maxPages).map((u) => u.url);
4830
5147
  }
4831
5148
  async function fetchAndParse(url, kernelApiKey) {
5149
+ const startedAt = Date.now();
4832
5150
  try {
4833
5151
  const res = await fetch(url, {
4834
5152
  headers: { "User-Agent": UA2, "Accept": "text/html,application/xhtml+xml" },
4835
5153
  signal: AbortSignal.timeout(1e4),
4836
5154
  redirect: "manual"
4837
5155
  });
5156
+ const meta = {
5157
+ headers: Object.fromEntries([...res.headers.entries()].map(([k, v]) => [k.toLowerCase(), v])),
5158
+ responseTimeMs: Date.now() - startedAt,
5159
+ redirectUrl: res.headers.get("location")
5160
+ };
4838
5161
  if (res.ok) {
4839
5162
  const ct = res.headers.get("content-type") ?? "";
4840
5163
  if (ct.includes("html")) {
4841
5164
  const html = await res.text();
4842
- return parsePageData(url, html, res.status, "fetch");
5165
+ return parsePageData(url, html, res.status, "fetch", meta);
4843
5166
  }
4844
- 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: [] };
5167
+ return { ...emptyPageData(url, res.status, "fetch"), contentType: ct || null, responseTimeMs: meta.responseTimeMs ?? null };
4845
5168
  }
4846
5169
  if ((res.status === 403 || res.status === 429) && kernelApiKey) {
4847
5170
  const html = await fetchWithKernel(url);
4848
5171
  return parsePageData(url, html, 200, "browser");
4849
5172
  }
4850
- 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: [] };
5173
+ return { ...emptyPageData(url, res.status, "fetch"), responseTimeMs: meta.responseTimeMs ?? null, redirectUrl: meta.redirectUrl ?? null };
4851
5174
  } catch {
4852
5175
  if (kernelApiKey) {
4853
5176
  try {
@@ -4856,7 +5179,7 @@ async function fetchAndParse(url, kernelApiKey) {
4856
5179
  } catch {
4857
5180
  }
4858
5181
  }
4859
- return { url, status: null, via: "fetch", title: null, metaDescription: null, h1: null, headings: [], wordCount: 0, schemaTypes: [], canonicalUrl: null, internalLinks: 0, externalLinks: 0, bodyMarkdown: "", schema: [] };
5182
+ return emptyPageData(url, null, "fetch");
4860
5183
  }
4861
5184
  }
4862
5185
  async function runWithConcurrency(items, concurrency, fn) {
@@ -4871,30 +5194,82 @@ async function runWithConcurrency(items, concurrency, fn) {
4871
5194
  }
4872
5195
  async function extractSite(opts) {
4873
5196
  const startMs = Date.now();
4874
- const maxPages = Math.min(opts.maxPages ?? 100, 200);
5197
+ const maxPages = Math.min(opts.maxPages ?? 100, 1e4);
4875
5198
  const concurrency = Math.min(opts.concurrency ?? EXTRACT_CONCURRENCY, 10);
4876
- const spider = await spiderSite({
4877
- startUrl: opts.startUrl,
4878
- maxUrls: maxPages,
4879
- concurrency: 12,
4880
- kernelApiKey: opts.kernelApiKey
4881
- });
4882
- const urlsToExtract = spider.urls.filter((u) => u.status === 200 || u.status === null).slice(0, maxPages).map((u) => u.url);
5199
+ const rotating = Boolean(opts.rotateProxyEvery && opts.kernelApiKey);
5200
+ const seeded = opts.seedUrls && opts.seedUrls.length > 0;
4883
5201
  const pages = [];
4884
- let browserRetries = spider.browserRetries;
4885
- await runWithConcurrency(urlsToExtract, concurrency, async (url) => {
4886
- const data = await fetchAndParse(url, opts.kernelApiKey);
4887
- if (data.via === "browser") browserRetries++;
4888
- pages.push(data);
4889
- });
5202
+ let browserRetries = 0;
5203
+ let truncated = false;
5204
+ let spider;
5205
+ if (rotating) {
5206
+ const browserConcurrency = Math.max(1, Math.min(opts.parallelism ?? 1, 8));
5207
+ const urlsPerBrowser = opts.rotateProxyEvery ?? 10;
5208
+ const followLinks = !seeded;
5209
+ const seen = /* @__PURE__ */ new Set();
5210
+ const frontier = [];
5211
+ const enqueue = (u) => {
5212
+ const n = normalizeUrl(u, opts.startUrl);
5213
+ if (n && !seen.has(n)) {
5214
+ seen.add(n);
5215
+ frontier.push(n);
5216
+ }
5217
+ };
5218
+ if (seeded) {
5219
+ for (const u of opts.seedUrls) enqueue(u);
5220
+ } else {
5221
+ enqueue(opts.startUrl);
5222
+ for (const u of await discoverSitemapUrls2(opts.startUrl, maxPages)) enqueue(u);
5223
+ }
5224
+ const waveCap = Math.max(browserConcurrency * urlsPerBrowser, 30);
5225
+ let done = 0;
5226
+ while (frontier.length > 0 && pages.length < maxPages) {
5227
+ const capacity = maxPages - pages.length;
5228
+ const wave = frontier.splice(0, Math.min(capacity, waveCap));
5229
+ const fetched = await crawlWithRotation(wave, {
5230
+ apiKey: opts.kernelApiKey,
5231
+ concurrency: browserConcurrency,
5232
+ urlsPerBrowser,
5233
+ onPage: (p) => {
5234
+ done++;
5235
+ opts.onProgress?.({ done, discovered: seen.size, frontier: frontier.length, lastOk: p.ok ? 1 : 0, lastFail: p.ok ? 0 : 1, phase: p.phase });
5236
+ }
5237
+ });
5238
+ for (const r of fetched) {
5239
+ 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");
5240
+ pages.push(page);
5241
+ browserRetries++;
5242
+ if (followLinks && r.html) {
5243
+ for (const l of page.outlinks) if (l.internal) enqueue(l.href);
5244
+ }
5245
+ }
5246
+ }
5247
+ truncated = frontier.length > 0;
5248
+ spider = makeSeedSpider(opts.startUrl, [...seen]);
5249
+ } else {
5250
+ spider = seeded ? makeSeedSpider(opts.startUrl, opts.seedUrls) : await spiderSite({ startUrl: opts.startUrl, maxUrls: maxPages, concurrency: 12, kernelApiKey: opts.kernelApiKey });
5251
+ const urlsToExtract = spider.urls.filter((u) => u.status === 200 || u.status === null).slice(0, maxPages).map((u) => u.url);
5252
+ browserRetries = spider.browserRetries;
5253
+ await runWithConcurrency(urlsToExtract, concurrency, async (url) => {
5254
+ const data = await fetchAndParse(url, opts.kernelApiKey);
5255
+ if (data.via === "browser") browserRetries++;
5256
+ pages.push(data);
5257
+ });
5258
+ truncated = spider.truncated || pages.length >= maxPages;
5259
+ }
5260
+ let branding = null;
5261
+ if (opts.formats?.includes("branding") && opts.kernelApiKey) {
5262
+ branding = await extractBranding(opts.startUrl, opts.kernelApiKey).catch(() => null);
5263
+ }
4890
5264
  return {
4891
5265
  startUrl: opts.startUrl,
4892
5266
  pages,
4893
5267
  totalPages: pages.length,
4894
5268
  spider,
4895
5269
  durationMs: Date.now() - startMs,
4896
- truncated: spider.truncated || pages.length >= maxPages,
4897
- browserRetries
5270
+ truncated,
5271
+ browserRetries,
5272
+ branding
4898
5273
  };
4899
5274
  }
4900
5275
 
@@ -7059,6 +7434,605 @@ var siteAuditFn = inngest.createFunction(
7059
7434
  }
7060
7435
  );
7061
7436
 
7437
+ // src/api/blob-store.ts
7438
+ import { mkdirSync as mkdirSync2, writeFileSync } from "fs";
7439
+ import { homedir as homedir2 } from "os";
7440
+ import { join as join4, dirname as dirname2 } from "path";
7441
+ function byteLength(data) {
7442
+ return Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data);
7443
+ }
7444
+ var LocalBlobStore = class {
7445
+ constructor(baseDir) {
7446
+ this.baseDir = baseDir;
7447
+ }
7448
+ baseDir;
7449
+ kind = "local";
7450
+ async put(key, data, contentType = "application/octet-stream") {
7451
+ const path5 = join4(this.baseDir, "blobs", key);
7452
+ mkdirSync2(dirname2(path5), { recursive: true });
7453
+ writeFileSync(path5, data);
7454
+ return { key, url: `file://${path5}`, bytes: byteLength(data), contentType };
7455
+ }
7456
+ };
7457
+ function localBaseDir() {
7458
+ return process.env.MCP_SCRAPER_OUTPUT_DIR?.trim() || join4(homedir2(), "Downloads", "mcp-scraper");
7459
+ }
7460
+ var cached = null;
7461
+ function getBlobStore() {
7462
+ if (cached) return cached;
7463
+ if (process.env.BLOB_READ_WRITE_TOKEN) {
7464
+ cached = new VercelBlobStore(process.env.BLOB_READ_WRITE_TOKEN);
7465
+ } else {
7466
+ cached = new LocalBlobStore(localBaseDir());
7467
+ }
7468
+ return cached;
7469
+ }
7470
+ var VercelBlobStore = class {
7471
+ constructor(token) {
7472
+ this.token = token;
7473
+ }
7474
+ token;
7475
+ kind = "vercel-blob";
7476
+ async put(key, data, contentType = "application/octet-stream") {
7477
+ const { put } = await import("@vercel/blob");
7478
+ const body = Buffer.isBuffer(data) ? data : Buffer.from(data);
7479
+ const result = await put(key, body, {
7480
+ access: "public",
7481
+ token: this.token,
7482
+ contentType,
7483
+ addRandomSuffix: true
7484
+ });
7485
+ return { key, url: result.url, bytes: byteLength(data), contentType };
7486
+ }
7487
+ };
7488
+
7489
+ // src/api/site-extract-repository.ts
7490
+ function rowToJob(r) {
7491
+ return {
7492
+ id: String(r.id),
7493
+ userId: r.user_id != null ? Number(r.user_id) : null,
7494
+ status: r.status != null ? String(r.status) : "pending",
7495
+ startUrl: String(r.start_url ?? ""),
7496
+ options: r.options ? JSON.parse(String(r.options)) : {},
7497
+ totalUrls: Number(r.total_urls ?? 0),
7498
+ doneUrls: Number(r.done_urls ?? 0),
7499
+ artifacts: r.artifacts ? JSON.parse(String(r.artifacts)) : null,
7500
+ error: r.error != null ? String(r.error) : null,
7501
+ billedMc: r.billed_mc != null ? Number(r.billed_mc) : null,
7502
+ createdAt: String(r.created_at ?? ""),
7503
+ updatedAt: String(r.updated_at ?? "")
7504
+ };
7505
+ }
7506
+ async function createExtractJob(jobId, userId, startUrl, options) {
7507
+ const db = getDb();
7508
+ await db.execute({
7509
+ sql: `INSERT INTO site_extract_jobs (id, user_id, status, start_url, options, created_at, updated_at)
7510
+ VALUES (?, ?, 'pending', ?, ?, datetime('now'), datetime('now'))`,
7511
+ args: [jobId, userId, startUrl, JSON.stringify(options)]
7512
+ });
7513
+ }
7514
+ async function getExtractJob(jobId) {
7515
+ const db = getDb();
7516
+ const res = await db.execute({ sql: `SELECT * FROM site_extract_jobs WHERE id = ?`, args: [jobId] });
7517
+ return res.rows[0] ? rowToJob(res.rows[0]) : null;
7518
+ }
7519
+ async function setExtractJobTotal(jobId, totalUrls) {
7520
+ const db = getDb();
7521
+ await db.execute({
7522
+ sql: `UPDATE site_extract_jobs SET status = 'running', total_urls = ?, updated_at = datetime('now') WHERE id = ?`,
7523
+ args: [totalUrls, jobId]
7524
+ });
7525
+ }
7526
+ async function saveExtractPages(jobId, pages) {
7527
+ if (pages.length === 0) return;
7528
+ const db = getDb();
7529
+ await db.batch(
7530
+ pages.map((p) => ({
7531
+ sql: `INSERT OR REPLACE INTO site_extract_pages (job_id, url, page) VALUES (?, ?, ?)`,
7532
+ args: [jobId, p.url, JSON.stringify({ ...p, bodyMarkdown: "", schema: [] })]
7533
+ }))
7534
+ );
7535
+ await db.execute({
7536
+ sql: `UPDATE site_extract_jobs
7537
+ SET done_urls = (SELECT COUNT(*) FROM site_extract_pages WHERE job_id = ?), updated_at = datetime('now')
7538
+ WHERE id = ?`,
7539
+ args: [jobId, jobId]
7540
+ });
7541
+ }
7542
+ async function getExtractedPages(jobId) {
7543
+ const db = getDb();
7544
+ const res = await db.execute({ sql: `SELECT page FROM site_extract_pages WHERE job_id = ?`, args: [jobId] });
7545
+ return res.rows.map((r) => JSON.parse(String(r.page)));
7546
+ }
7547
+ async function countSuccessfulPages(jobId) {
7548
+ const db = getDb();
7549
+ const res = await db.execute({
7550
+ sql: `SELECT COUNT(*) AS n FROM site_extract_pages
7551
+ WHERE job_id = ? AND json_extract(page, '$.status') = 200 AND json_extract(page, '$.wordCount') > 0`,
7552
+ args: [jobId]
7553
+ });
7554
+ return Number(res.rows[0]?.n ?? 0);
7555
+ }
7556
+ async function completeExtractJob(jobId, artifacts) {
7557
+ const db = getDb();
7558
+ await db.execute({
7559
+ sql: `UPDATE site_extract_jobs SET status = 'complete', artifacts = ?, updated_at = datetime('now') WHERE id = ?`,
7560
+ args: [JSON.stringify(artifacts ?? []), jobId]
7561
+ });
7562
+ }
7563
+ async function failExtractJob(jobId, error) {
7564
+ const db = getDb();
7565
+ await db.execute({
7566
+ sql: `UPDATE site_extract_jobs SET status = 'failed', error = ?, updated_at = datetime('now') WHERE id = ?`,
7567
+ args: [sanitizeVendorName(error).slice(0, 2e3), jobId]
7568
+ });
7569
+ }
7570
+ async function settleExtractJob(jobId, userId, refundMc, netChargeMc, reference) {
7571
+ const db = getDb();
7572
+ const job = await getExtractJob(jobId);
7573
+ if (!job || job.billedMc != null) return;
7574
+ if (refundMc <= 0) {
7575
+ 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] });
7576
+ return;
7577
+ }
7578
+ await db.batch([
7579
+ { sql: "UPDATE users SET balance_mc = balance_mc + ? WHERE id = ?", args: [refundMc, userId] },
7580
+ { sql: "INSERT INTO ledger (user_id, amount_mc, operation, description) VALUES (?, ?, ?, ?)", args: [userId, refundMc, LedgerOperation.EXTRACT_SITE_REFUND, reference] },
7581
+ { 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] }
7582
+ ]);
7583
+ }
7584
+
7585
+ // src/inngest/functions/site-extract.ts
7586
+ var BATCH = 30;
7587
+ function chunk(items, size) {
7588
+ const out = [];
7589
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
7590
+ return out;
7591
+ }
7592
+ function buildArtifacts(job, pages, branding, imageAudit) {
7593
+ const { edges, metrics } = buildLinkGraph(pages, job.startUrl);
7594
+ const issues = computeIssues(pages, metrics);
7595
+ let reportMd = renderIssueReport(job.startUrl, pages, issues, metrics);
7596
+ if (imageAudit) reportMd += `
7597
+
7598
+ ${renderImageSection(imageAudit)}`;
7599
+ const pageRows = pages.map((p) => {
7600
+ const { bodyMarkdown: _b, schema: _s, outlinks: _o, ...rest } = p;
7601
+ const m = metrics.get(p.url);
7602
+ return { ...rest, inlinks: m?.inlinks ?? 0, crawlDepth: m?.crawlDepth ?? null, orphan: m?.orphan ?? false };
7603
+ });
7604
+ const jsonl = (rows) => rows.map((r) => JSON.stringify(r)).join("\n");
7605
+ const files = [
7606
+ { name: "report.md", body: reportMd, type: "text/markdown" },
7607
+ { name: "issues.json", body: JSON.stringify(issues, null, 2), type: "application/json" },
7608
+ { name: "pages.jsonl", body: jsonl(pageRows), type: "application/x-ndjson" },
7609
+ { name: "links.jsonl", body: jsonl(edges), type: "application/x-ndjson" },
7610
+ { name: "link-metrics.jsonl", body: jsonl([...metrics.values()]), type: "application/x-ndjson" }
7611
+ ];
7612
+ if (imageAudit) {
7613
+ files.push({ name: "images.jsonl", body: jsonl(imageAudit.rows), type: "application/x-ndjson" });
7614
+ files.push({ name: "images-summary.json", body: JSON.stringify(imageAudit.summary, null, 2), type: "application/json" });
7615
+ }
7616
+ if (branding) files.push({ name: "branding.json", body: JSON.stringify(branding, null, 2), type: "application/json" });
7617
+ return files;
7618
+ }
7619
+ var siteExtractFn = inngest.createFunction(
7620
+ {
7621
+ id: "site-extract",
7622
+ retries: 2,
7623
+ triggers: [{ event: "mcp-scraper/extract.requested" }],
7624
+ onFailure: async ({ event }) => {
7625
+ const jobId = event?.data?.event?.data?.jobId;
7626
+ if (!jobId) return;
7627
+ const current = await getExtractJob(jobId);
7628
+ if (current && current.userId != null && current.billedMc == null) {
7629
+ const heldMc = Number(current.options.heldMc ?? 0);
7630
+ await settleExtractJob(jobId, current.userId, heldMc, 0, "crawl failed refund").catch(() => {
7631
+ });
7632
+ }
7633
+ await failExtractJob(jobId, String(event?.data?.error?.message ?? "crawl failed")).catch(() => {
7634
+ });
7635
+ }
7636
+ },
7637
+ async ({ event, step }) => {
7638
+ const jobId = event.data.jobId;
7639
+ const job = await step.run("load-job", () => getExtractJob(jobId));
7640
+ if (!job) return { jobId, status: "missing" };
7641
+ const key = browserServiceApiKey();
7642
+ if (!key) throw new Error("browser service key not configured");
7643
+ const maxPages = Number(job.options.maxPages ?? 1e4);
7644
+ const concurrency = Number(job.options.concurrency ?? 1);
7645
+ const urlsPerBrowser = Number(job.options.urlsPerBrowser ?? job.options.rotateProxyEvery ?? 10);
7646
+ const urls = await step.run("discover", async () => {
7647
+ const discovered = await discoverUrls(job.startUrl, maxPages, key);
7648
+ await setExtractJobTotal(jobId, discovered.length);
7649
+ return discovered;
7650
+ });
7651
+ const batches = chunk(urls, BATCH);
7652
+ for (let i = 0; i < batches.length; i++) {
7653
+ await step.run(`crawl-batch-${i}`, async () => {
7654
+ const pages = await extractPagesRotating(batches[i], { kernelApiKey: key, concurrency, urlsPerBrowser });
7655
+ await saveExtractPages(jobId, pages);
7656
+ return pages.length;
7657
+ });
7658
+ }
7659
+ const branding = Array.isArray(job.options.formats) && job.options.formats.includes("branding") ? await step.run("branding", () => extractBranding(job.startUrl, key).catch(() => null)) : null;
7660
+ const imageAudit = Array.isArray(job.options.formats) && job.options.formats.includes("images") ? await step.run("image-audit", async () => {
7661
+ const pages = await getExtractedPages(jobId);
7662
+ return auditImages(pages, { concurrency: 12, timeoutMs: 12e3 });
7663
+ }) : null;
7664
+ const artifacts = await step.run("finalize", async () => {
7665
+ const pages = await getExtractedPages(jobId);
7666
+ const store = getBlobStore();
7667
+ const files = buildArtifacts(job, pages, branding, imageAudit);
7668
+ const stored = [];
7669
+ for (const f of files) stored.push(await store.put(`extract/${jobId}/${f.name}`, f.body, f.type));
7670
+ await completeExtractJob(jobId, stored);
7671
+ return stored;
7672
+ });
7673
+ await step.run("settle", async () => {
7674
+ const current = await getExtractJob(jobId);
7675
+ if (!current || current.billedMc != null || current.userId == null) return;
7676
+ const heldMc = Number(current.options.heldMc ?? 0);
7677
+ const successful = await countSuccessfulPages(jobId);
7678
+ const usedMc = Math.min(successful * MC_COSTS.page_scrape, heldMc);
7679
+ await settleExtractJob(jobId, current.userId, heldMc - usedMc, usedMc, new URL(job.startUrl).hostname);
7680
+ });
7681
+ return { jobId, status: "complete", artifacts };
7682
+ }
7683
+ );
7684
+
7685
+ // src/api/catalog-seed.ts
7686
+ var URL_PARAM = { key: "url", label: "URL", type: "url", required: true, placeholder: "https://example.com" };
7687
+ var FORMATS_PARAM = {
7688
+ key: "formats",
7689
+ label: "Formats",
7690
+ type: "multi_enum",
7691
+ default: ["markdown", "links"],
7692
+ enumValues: [
7693
+ { value: "markdown", label: "Markdown" },
7694
+ { value: "links", label: "Links" },
7695
+ { value: "json", label: "JSON / schema" },
7696
+ { value: "images", label: "Image links" },
7697
+ { value: "branding", label: "Branding" }
7698
+ ]
7699
+ };
7700
+ var CATALOG = {
7701
+ groups: [
7702
+ {
7703
+ key: "youtube",
7704
+ label: "YouTube",
7705
+ icon: "youtube",
7706
+ tools: [
7707
+ {
7708
+ key: "youtube_harvest",
7709
+ label: "Search",
7710
+ kind: "simple",
7711
+ endpoint: "/youtube/harvest",
7712
+ mcpName: "youtube_harvest",
7713
+ tagline: "Find or list videos by topic or channel",
7714
+ params: [
7715
+ { key: "mode", label: "Mode", type: "enum", required: true, default: "search", enumValues: [{ value: "search", label: "Search" }, { value: "channel", label: "Channel" }] },
7716
+ { key: "query", label: "Query", type: "string", placeholder: "best local SEO 2026" },
7717
+ { key: "channelHandle", label: "Channel handle", type: "string", placeholder: "@mkbhd" },
7718
+ { key: "maxVideos", label: "Max videos", type: "number", min: 1, max: 500, default: 50, advanced: true }
7719
+ ],
7720
+ examples: ["Find YouTube videos about local SEO", "Harvest videos from @mkbhd"]
7721
+ },
7722
+ {
7723
+ key: "youtube_transcribe",
7724
+ label: "Transcribe",
7725
+ kind: "simple",
7726
+ endpoint: "/youtube/transcribe",
7727
+ mcpName: "youtube_transcribe",
7728
+ tagline: "Transcribe one video to text",
7729
+ params: [{ key: "url", label: "Video URL", type: "url", required: true, placeholder: "https://www.youtube.com/watch?v=\u2026" }],
7730
+ examples: ["Transcribe this YouTube URL"]
7731
+ }
7732
+ ]
7733
+ },
7734
+ {
7735
+ key: "facebook",
7736
+ label: "Facebook",
7737
+ icon: "facebook",
7738
+ tools: [
7739
+ {
7740
+ key: "facebook_ad_search",
7741
+ label: "Ad Search",
7742
+ kind: "simple",
7743
+ endpoint: "/facebook/search",
7744
+ mcpName: "facebook_ad_search",
7745
+ tagline: "Find ads in the Ad Library",
7746
+ params: [
7747
+ { key: "query", label: "Advertiser / query", type: "string", required: true },
7748
+ { key: "country", label: "Country", type: "string", default: "US" },
7749
+ { key: "maxResults", label: "Max results", type: "number", min: 1, max: 20, default: 10, advanced: true }
7750
+ ],
7751
+ examples: ["Find Facebook ads for this brand"]
7752
+ },
7753
+ {
7754
+ key: "facebook_video_transcribe",
7755
+ label: "Video Transcribe",
7756
+ kind: "simple",
7757
+ endpoint: "/facebook/video-transcribe",
7758
+ mcpName: "facebook_video_transcribe",
7759
+ tagline: "Transcribe a Facebook video/reel",
7760
+ params: [{ ...URL_PARAM, label: "Video URL", placeholder: "https://www.facebook.com/share/v/\u2026" }]
7761
+ },
7762
+ {
7763
+ key: "facebook_ad_transcribe",
7764
+ label: "Ad Transcribe",
7765
+ kind: "simple",
7766
+ endpoint: "/facebook/transcribe",
7767
+ mcpName: "facebook_ad_transcribe",
7768
+ tagline: "Transcribe an ad creative",
7769
+ params: [{ ...URL_PARAM, label: "Ad video URL" }]
7770
+ },
7771
+ {
7772
+ key: "facebook_page_intel",
7773
+ label: "Page Intel",
7774
+ kind: "simple",
7775
+ endpoint: "/facebook/page-intel",
7776
+ mcpName: "facebook_page_intel",
7777
+ tagline: "Advertiser page profile + ads",
7778
+ params: [{ key: "libraryId", label: "Page / Library ID or URL", type: "string", required: true }]
7779
+ }
7780
+ ]
7781
+ },
7782
+ {
7783
+ key: "instagram",
7784
+ label: "Instagram",
7785
+ icon: "instagram",
7786
+ tools: [
7787
+ {
7788
+ key: "instagram_profile_content",
7789
+ label: "Profile",
7790
+ kind: "simple",
7791
+ endpoint: "/instagram/profile-content",
7792
+ mcpName: "instagram_profile_content",
7793
+ tagline: "Inventory a profile grid",
7794
+ params: [
7795
+ { key: "url", label: "Profile URL or handle", type: "string", required: true, placeholder: "https://instagram.com/nasa" },
7796
+ { key: "maxItems", label: "Max items", type: "number", min: 1, max: 2e3, default: 50, advanced: true },
7797
+ { key: "maxScrolls", label: "Max scrolls", type: "number", min: 0, max: 250, default: 10, advanced: true }
7798
+ ]
7799
+ },
7800
+ {
7801
+ key: "instagram_media_download",
7802
+ label: "Post / Reel",
7803
+ kind: "simple",
7804
+ endpoint: "/instagram/media-download",
7805
+ mcpName: "instagram_media_download",
7806
+ tagline: "Download a post or reel (+ transcript)",
7807
+ params: [
7808
+ { ...URL_PARAM, label: "Post / reel URL", placeholder: "https://www.instagram.com/reel/\u2026" },
7809
+ { key: "includeTranscript", label: "Include transcript", type: "boolean", default: true }
7810
+ ]
7811
+ }
7812
+ ]
7813
+ },
7814
+ {
7815
+ key: "reddit",
7816
+ label: "Reddit",
7817
+ icon: "message-circle",
7818
+ tools: [
7819
+ {
7820
+ key: "reddit_browser",
7821
+ label: "Research (Browser)",
7822
+ kind: "simple",
7823
+ endpoint: "/agent",
7824
+ mcpName: "browser_open",
7825
+ tagline: "No dedicated tool yet \u2014 driven by the Browser agent",
7826
+ params: [{ key: "query", label: "What to research", type: "string", required: true, placeholder: "ICP pain points in [niche]" }]
7827
+ }
7828
+ ]
7829
+ },
7830
+ {
7831
+ key: "google_search",
7832
+ label: "Google Search",
7833
+ icon: "search",
7834
+ tools: [
7835
+ {
7836
+ key: "search_serp",
7837
+ label: "SERP",
7838
+ kind: "simple",
7839
+ endpoint: "/harvest/sync",
7840
+ mcpName: "search_serp",
7841
+ tagline: "Organic results only",
7842
+ params: [
7843
+ { key: "query", label: "Query", type: "string", required: true },
7844
+ { key: "location", label: "Location", type: "string", placeholder: "Denver, CO" }
7845
+ ],
7846
+ examples: ["Top restaurants in San Francisco"]
7847
+ },
7848
+ {
7849
+ key: "harvest_paa",
7850
+ label: "PAA + SERP Detail",
7851
+ kind: "simple",
7852
+ endpoint: "/harvest/sync",
7853
+ mcpName: "harvest_paa",
7854
+ tagline: "People-Also-Ask, AI Overview, full SERP features",
7855
+ params: [
7856
+ { key: "query", label: "Query", type: "string", required: true },
7857
+ { key: "maxQuestions", label: "Max questions", type: "number", min: 1, max: 200, default: 30, advanced: true },
7858
+ { key: "location", label: "Location", type: "string" }
7859
+ ]
7860
+ }
7861
+ ]
7862
+ },
7863
+ {
7864
+ key: "google_maps",
7865
+ label: "Google Maps",
7866
+ icon: "map-pin",
7867
+ tools: [
7868
+ {
7869
+ key: "maps_search",
7870
+ label: "Search",
7871
+ kind: "simple",
7872
+ endpoint: "/maps/search",
7873
+ mcpName: "maps_search",
7874
+ tagline: "Find local businesses",
7875
+ params: [
7876
+ { key: "query", label: "Query", type: "string", required: true, placeholder: "roofers" },
7877
+ { key: "location", label: "Location", type: "string", required: true, placeholder: "Denver, CO" },
7878
+ { key: "maxResults", label: "Max results", type: "number", min: 1, max: 20, default: 10, advanced: true }
7879
+ ]
7880
+ },
7881
+ {
7882
+ key: "maps_place_intel",
7883
+ label: "Place Intel",
7884
+ kind: "simple",
7885
+ endpoint: "/maps/place",
7886
+ mcpName: "maps_place_intel",
7887
+ tagline: "One place: details + reviews",
7888
+ params: [
7889
+ { key: "businessName", label: "Business name", type: "string", required: true },
7890
+ { key: "location", label: "Location", type: "string", required: true },
7891
+ { key: "includeReviews", label: "Include reviews", type: "boolean", default: false },
7892
+ { key: "maxReviews", label: "Max reviews", type: "number", min: 1, max: 500, default: 50, advanced: true }
7893
+ ]
7894
+ }
7895
+ ]
7896
+ },
7897
+ {
7898
+ key: "websites",
7899
+ label: "Websites",
7900
+ icon: "globe",
7901
+ tools: [
7902
+ {
7903
+ key: "scrape",
7904
+ label: "Scrape",
7905
+ kind: "modal",
7906
+ endpoint: "/extract-url",
7907
+ mcpName: "extract_url",
7908
+ tagline: "Headless single or bulk page extraction",
7909
+ modes: [
7910
+ { groupKey: "scope", groupLabel: "Scope", optionKey: "single", optionLabel: "Headless Single", isDefault: true, endpointOverride: "/extract-url", mcpNameOverride: "extract_url" },
7911
+ { groupKey: "scope", groupLabel: "Scope", optionKey: "bulk", optionLabel: "Headless Bulk", endpointOverride: "/extract-site", mcpNameOverride: "extract_site", paramOverrides: { rotateProxies: true } }
7912
+ ],
7913
+ params: [
7914
+ URL_PARAM,
7915
+ FORMATS_PARAM,
7916
+ { key: "maxPages", label: "Max pages", type: "number", min: 1, max: 1e4, default: 100, advanced: true, sourceKey: "scope:bulk" },
7917
+ { key: "screenshot", label: "Screenshot", type: "boolean", advanced: true }
7918
+ ],
7919
+ examples: ["https://steenshoney.com/"]
7920
+ },
7921
+ {
7922
+ key: "map_site_urls",
7923
+ label: "Map",
7924
+ kind: "simple",
7925
+ endpoint: "/map-urls",
7926
+ mcpName: "map_site_urls",
7927
+ tagline: "URL inventory only",
7928
+ params: [URL_PARAM, { key: "maxUrls", label: "Max URLs", type: "number", min: 1, max: 1e4, default: 100, advanced: true }]
7929
+ },
7930
+ {
7931
+ key: "extract_site",
7932
+ label: "Crawl",
7933
+ kind: "simple",
7934
+ endpoint: "/extract-site",
7935
+ mcpName: "extract_site",
7936
+ tagline: "Map + bulk scrape (content)",
7937
+ params: [
7938
+ URL_PARAM,
7939
+ { key: "maxPages", label: "Max pages", type: "number", min: 1, max: 1e4, default: 100 },
7940
+ FORMATS_PARAM,
7941
+ { key: "rotateProxies", label: "Rotate proxies (blocked sites)", type: "boolean", default: true },
7942
+ { key: "background", label: "Run in background", type: "boolean", default: true, advanced: true }
7943
+ ],
7944
+ examples: ["https://steenshoney.com/ (full-site content crawl)"]
7945
+ },
7946
+ {
7947
+ key: "audit_site",
7948
+ label: "Audit",
7949
+ kind: "simple",
7950
+ endpoint: "/extract-site",
7951
+ mcpName: "audit_site",
7952
+ tagline: "Technical SEO audit (Screaming-Frog style)",
7953
+ params: [
7954
+ URL_PARAM,
7955
+ { key: "maxPages", label: "Max pages", type: "number", min: 1, max: 1e4, default: 100 },
7956
+ { key: "rotateProxies", label: "Rotate proxies (blocked sites)", type: "boolean", default: true }
7957
+ ],
7958
+ examples: ["https://steenshoney.com/ (issues + link graph + image audit)"]
7959
+ }
7960
+ ]
7961
+ },
7962
+ {
7963
+ key: "browser",
7964
+ label: "Browser",
7965
+ icon: "monitor",
7966
+ tools: [
7967
+ {
7968
+ key: "browser_agent",
7969
+ label: "Browser Agent",
7970
+ kind: "simple",
7971
+ endpoint: "/agent",
7972
+ mcpName: "browser_open",
7973
+ tagline: "Drive a live browser (open, navigate, read, click)",
7974
+ params: [
7975
+ { key: "url", label: "Start URL", type: "url" },
7976
+ { key: "profile", label: "Use Chrome profile", type: "string", placeholder: "saved profile name", advanced: true }
7977
+ ]
7978
+ },
7979
+ {
7980
+ key: "query_fanout_workflow",
7981
+ label: "AI Visibility (Fan-out)",
7982
+ kind: "simple",
7983
+ mcpName: "query_fanout_workflow",
7984
+ tagline: "Capture what ChatGPT/Claude search & cite (AEO)",
7985
+ params: [
7986
+ { key: "prompt", label: "Prompt", type: "string" },
7987
+ { key: "first_party_domain", label: "Your domain", type: "string", advanced: true },
7988
+ { key: "export", label: "Export artifacts", type: "boolean", advanced: true }
7989
+ ]
7990
+ }
7991
+ ]
7992
+ },
7993
+ {
7994
+ key: "workflows",
7995
+ label: "Workflows",
7996
+ icon: "workflow",
7997
+ tools: [
7998
+ {
7999
+ key: "directory_workflow",
8000
+ label: "Directory",
8001
+ kind: "simple",
8002
+ endpoint: "/directory/run",
8003
+ mcpName: "directory_workflow",
8004
+ isSpecial: true,
8005
+ tagline: "Build a local business directory",
8006
+ params: [
8007
+ { key: "query", label: "Business type", type: "string", required: true, placeholder: "roofers" },
8008
+ { key: "state", label: "State", type: "string", required: true, placeholder: "TN" },
8009
+ { key: "minPopulation", label: "Min city population", type: "number", default: 1e5, advanced: true }
8010
+ ]
8011
+ },
8012
+ {
8013
+ key: "rank_tracker_workflow",
8014
+ label: "Rank Tracker",
8015
+ kind: "simple",
8016
+ mcpName: "rank_tracker_workflow",
8017
+ isSpecial: true,
8018
+ tagline: "Generate a rank-tracker blueprint",
8019
+ params: [{ key: "domain", label: "Domain", type: "string", required: true }]
8020
+ },
8021
+ {
8022
+ key: "deep_research_workflow",
8023
+ label: "Deep Research",
8024
+ kind: "simple",
8025
+ endpoint: "/workflows/run",
8026
+ mcpName: "workflow_run",
8027
+ isSpecial: true,
8028
+ tagline: "Multi-source, fact-checked research report",
8029
+ params: [{ key: "question", label: "Question", type: "string", required: true }]
8030
+ }
8031
+ ]
8032
+ }
8033
+ ]
8034
+ };
8035
+
7062
8036
  // src/api/site-audit-routes.ts
7063
8037
  import path2 from "path";
7064
8038
  import os from "os";
@@ -8049,9 +9023,13 @@ var MapUrlsBodySchema = z11.object({
8049
9023
  });
8050
9024
  var ExtractSiteBodySchema = z11.object({
8051
9025
  url: z11.string().min(1, "url is required"),
8052
- maxPages: z11.number().int().min(1).max(200).optional(),
9026
+ maxPages: z11.number().int().min(1).max(1e4).optional(),
8053
9027
  browserFallback: z11.boolean().optional(),
8054
- kernelFallback: z11.boolean().optional()
9028
+ kernelFallback: z11.boolean().optional(),
9029
+ rotateProxies: z11.boolean().optional(),
9030
+ rotateProxyEvery: z11.number().int().min(1).max(100).optional(),
9031
+ background: z11.boolean().optional(),
9032
+ formats: z11.array(z11.enum(["markdown", "links", "json", "images", "branding"])).optional()
8055
9033
  });
8056
9034
  var YoutubeHarvestBodySchema = z11.object({
8057
9035
  mode: z11.enum(["search", "channel"]),
@@ -8359,10 +9337,10 @@ function buildTranscriptMarkdown(result) {
8359
9337
  if (result.chunks?.length) {
8360
9338
  lines.push("## Timestamped Segments");
8361
9339
  lines.push("");
8362
- for (const chunk of result.chunks) {
8363
- const start = fmtTs(chunk.timestamp[0]);
8364
- const end = fmtTs(chunk.timestamp[1]);
8365
- lines.push(`**[${start} \u2192 ${end}]** ${chunk.text.trim()}`);
9340
+ for (const chunk2 of result.chunks) {
9341
+ const start = fmtTs(chunk2.timestamp[0]);
9342
+ const end = fmtTs(chunk2.timestamp[1]);
9343
+ lines.push(`**[${start} \u2192 ${end}]** ${chunk2.text.trim()}`);
8366
9344
  lines.push("");
8367
9345
  }
8368
9346
  }
@@ -8863,20 +9841,20 @@ var FacebookAdExtractor = class {
8863
9841
  if (splitRe.lastIndex === m.index) splitRe.lastIndex++;
8864
9842
  }
8865
9843
  if (last < bodyText.length) adChunks.push(bodyText.slice(last));
8866
- const ads = adChunks.filter((c) => /Library ID[:\s]+\d{10,20}/i.test(c)).slice(0, maxAds).map((chunk) => {
8867
- const lidM = chunk.match(/Library ID[:\s]+([0-9]{10,20})/i);
9844
+ const ads = adChunks.filter((c) => /Library ID[:\s]+\d{10,20}/i.test(c)).slice(0, maxAds).map((chunk2) => {
9845
+ const lidM = chunk2.match(/Library ID[:\s]+([0-9]{10,20})/i);
8868
9846
  const libraryId = lidM ? lidM[1] : null;
8869
- const statusM = chunk.match(/^(Active|Inactive)/i);
9847
+ const statusM = chunk2.match(/^(Active|Inactive)/i);
8870
9848
  const status = statusM ? statusM[1] : null;
8871
- const startM = chunk.match(/Started running on\s+([A-Za-z]+\.?\s+\d{1,2},?\s*\d{4})/i);
9849
+ const startM = chunk2.match(/Started running on\s+([A-Za-z]+\.?\s+\d{1,2},?\s*\d{4})/i);
8872
9850
  const started = startM ? startM[1].replace(/\s+/g, " ").trim() : null;
8873
9851
  const visual = adVisuals[libraryId ?? ""] ?? { imageSrc: null, videoSrc: null, videoPoster: null };
8874
- const hasVideoText = /0:00\s*\/\s*0:00|\d+:\d+\s*\/\s*\d+:\d+/.test(chunk);
9852
+ const hasVideoText = /0:00\s*\/\s*0:00|\d+:\d+\s*\/\s*\d+:\d+/.test(chunk2);
8875
9853
  const creativeType = visual.videoSrc || hasVideoText ? "video" : visual.imageSrc ? "image" : "unknown";
8876
9854
  let domain = null;
8877
9855
  const domainRe = /\b([A-Z0-9]{2,}(?:\.[A-Z]{2,})+)\b/g;
8878
9856
  let dm;
8879
- while ((dm = domainRe.exec(chunk)) !== null) {
9857
+ while ((dm = domainRe.exec(chunk2)) !== null) {
8880
9858
  const c = dm[1];
8881
9859
  if (c.length >= 6 && c !== "LIBRARY") {
8882
9860
  domain = c;
@@ -8885,9 +9863,9 @@ var FacebookAdExtractor = class {
8885
9863
  }
8886
9864
  let primaryText = null;
8887
9865
  let headline = null;
8888
- const sponsoredIdx = chunk.search(/\bSponsored\b/i);
9866
+ const sponsoredIdx = chunk2.search(/\bSponsored\b/i);
8889
9867
  if (sponsoredIdx >= 0) {
8890
- let afterSponsored = chunk.slice(sponsoredIdx + "Sponsored".length).trim();
9868
+ let afterSponsored = chunk2.slice(sponsoredIdx + "Sponsored".length).trim();
8891
9869
  afterSponsored = afterSponsored.replace(/\s*\d*:?\d+\s*\/\s*\d*:?\d+.*$/, "");
8892
9870
  if (domain) {
8893
9871
  const di = afterSponsored.indexOf(domain);
@@ -8898,18 +9876,18 @@ var FacebookAdExtractor = class {
8898
9876
  }
8899
9877
  const ctaRe2 = new RegExp("\\b(" + CTA_LABELS.map((l) => l.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|") + ")\\b", "i");
8900
9878
  if (domain) {
8901
- const di = chunk.indexOf(domain);
9879
+ const di = chunk2.indexOf(domain);
8902
9880
  if (di >= 0) {
8903
- let afterDomain = chunk.slice(di + domain.length).trim();
9881
+ let afterDomain = chunk2.slice(di + domain.length).trim();
8904
9882
  const ctaMatch = afterDomain.search(ctaRe2);
8905
9883
  if (ctaMatch >= 0) afterDomain = afterDomain.slice(0, ctaMatch).trim();
8906
9884
  if (afterDomain.length > 3 && afterDomain.length < 120) headline = afterDomain;
8907
9885
  }
8908
9886
  }
8909
9887
  if (!headline) {
8910
- const ctaIdx = chunk.search(ctaRe2);
9888
+ const ctaIdx = chunk2.search(ctaRe2);
8911
9889
  if (ctaIdx > 0) {
8912
- const beforeCta = chunk.slice(0, ctaIdx).trimEnd();
9890
+ const beforeCta = chunk2.slice(0, ctaIdx).trimEnd();
8913
9891
  const sentenceEnd = Math.max(beforeCta.lastIndexOf(". "), beforeCta.lastIndexOf("! "), beforeCta.lastIndexOf("? "));
8914
9892
  if (sentenceEnd >= 0) {
8915
9893
  const candidate = beforeCta.slice(sentenceEnd + 2).trim();
@@ -8921,12 +9899,12 @@ var FacebookAdExtractor = class {
8921
9899
  }
8922
9900
  let cta = null;
8923
9901
  for (const lbl of CTA_LABELS) {
8924
- if (chunk.toLowerCase().includes(lbl.toLowerCase())) {
9902
+ if (chunk2.toLowerCase().includes(lbl.toLowerCase())) {
8925
9903
  cta = lbl;
8926
9904
  break;
8927
9905
  }
8928
9906
  }
8929
- const multiM = chunk.match(/(\d+)\s+ad[s]?\s+use[s]?\s+this/i);
9907
+ const multiM = chunk2.match(/(\d+)\s+ad[s]?\s+use[s]?\s+this/i);
8930
9908
  const clusterCount = multiM ? parseInt(multiM[1]) : null;
8931
9909
  return {
8932
9910
  libraryId,
@@ -8949,8 +9927,8 @@ var FacebookAdExtractor = class {
8949
9927
  const unknownCreativeCount = ads.filter((a) => a.creativeType === "unknown").length;
8950
9928
  const advertiserPageId = new URL(page.url()).searchParams.get("view_all_page_id") ?? null;
8951
9929
  const nameFreq = /* @__PURE__ */ new Map();
8952
- for (const chunk of adChunks) {
8953
- const detailsParts = chunk.split("See ad details ");
9930
+ for (const chunk2 of adChunks) {
9931
+ const detailsParts = chunk2.split("See ad details ");
8954
9932
  if (detailsParts.length < 2) continue;
8955
9933
  const sponsoredIdx = detailsParts[1].indexOf(" Sponsored");
8956
9934
  if (sponsoredIdx < 0) continue;
@@ -9690,11 +10668,11 @@ facebookAdApp.post("/search", createApiKeyAuth(), async (c) => {
9690
10668
  }
9691
10669
  if (last < bodyText.length) adChunks.push(bodyText.slice(last));
9692
10670
  const advertiserMap = /* @__PURE__ */ new Map();
9693
- for (const chunk of adChunks) {
9694
- const lidM = chunk.match(/Library ID[:\s]+([0-9]{10,20})/i);
10671
+ for (const chunk2 of adChunks) {
10672
+ const lidM = chunk2.match(/Library ID[:\s]+([0-9]{10,20})/i);
9695
10673
  if (!lidM) continue;
9696
10674
  const libraryId = lidM[1];
9697
- const detailsParts = chunk.split("See ad details ");
10675
+ const detailsParts = chunk2.split("See ad details ");
9698
10676
  if (detailsParts.length < 2) continue;
9699
10677
  const afterDetails = detailsParts[1];
9700
10678
  const sponsoredIdx = afterDetails.indexOf(" Sponsored");
@@ -9774,9 +10752,9 @@ facebookAdApp.post("/media", createApiKeyAuth(), async (c) => {
9774
10752
  // src/api/instagram-routes.ts
9775
10753
  import { Hono as Hono5 } from "hono";
9776
10754
  import { z as z14 } from "zod";
9777
- import { createWriteStream as createWriteStream2, mkdirSync as mkdirSync2, statSync, writeFileSync } from "fs";
9778
- import { homedir as homedir2 } from "os";
9779
- import { join as join4 } from "path";
10755
+ import { createWriteStream as createWriteStream2, mkdirSync as mkdirSync3, statSync, writeFileSync as writeFileSync2 } from "fs";
10756
+ import { homedir as homedir3 } from "os";
10757
+ import { join as join5 } from "path";
9780
10758
  import { Readable as Readable2 } from "stream";
9781
10759
  import { pipeline as pipeline2 } from "stream/promises";
9782
10760
  import { spawn } from "child_process";
@@ -10199,7 +11177,7 @@ async function resolveInstagramLaunch(body) {
10199
11177
  };
10200
11178
  }
10201
11179
  function outputBaseDir2() {
10202
- return process.env.MCP_SCRAPER_OUTPUT_DIR?.trim() || join4(homedir2(), "Downloads", "mcp-scraper");
11180
+ return process.env.MCP_SCRAPER_OUTPUT_DIR?.trim() || join5(homedir3(), "Downloads", "mcp-scraper");
10203
11181
  }
10204
11182
  function safeFilePart(input) {
10205
11183
  return input.replace(/^https?:\/\//, "").replace(/[^a-z0-9._-]+/gi, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "instagram";
@@ -10207,8 +11185,8 @@ function safeFilePart(input) {
10207
11185
  function mediaOutputDir(shortcode, sourceUrl) {
10208
11186
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
10209
11187
  const slug = shortcode ? `instagram-${shortcode}` : safeFilePart(sourceUrl);
10210
- const dir = join4(outputBaseDir2(), "instagram", `${stamp}-${slug}`);
10211
- mkdirSync2(dir, { recursive: true });
11188
+ const dir = join5(outputBaseDir2(), "instagram", `${stamp}-${slug}`);
11189
+ mkdirSync3(dir, { recursive: true });
10212
11190
  return dir;
10213
11191
  }
10214
11192
  async function downloadToFile(url, destPath, referer) {
@@ -10246,8 +11224,8 @@ function runFfmpegMux(videoPath, audioPath, outPath) {
10246
11224
  stdio: ["ignore", "ignore", "pipe"]
10247
11225
  });
10248
11226
  let stderr = "";
10249
- child.stderr.on("data", (chunk) => {
10250
- stderr += String(chunk);
11227
+ child.stderr.on("data", (chunk2) => {
11228
+ stderr += String(chunk2);
10251
11229
  });
10252
11230
  child.on("error", (err) => resolve({ ok: false, error: err.message }));
10253
11231
  child.on("close", (code) => resolve({ ok: code === 0, error: code === 0 ? null : stderr.trim() || `ffmpeg exited ${code}` }));
@@ -10348,8 +11326,8 @@ instagramApp.post("/media-download", createApiKeyAuth(), async (c) => {
10348
11326
  let outputDir = null;
10349
11327
  if (body.downloadMedia) {
10350
11328
  outputDir = mediaOutputDir(extraction.shortcode, sourceUrl.href);
10351
- const textPath = join4(outputDir, `${extraction.shortcode ?? "instagram"}-text.txt`);
10352
- writeFileSync(textPath, [
11329
+ const textPath = join5(outputDir, `${extraction.shortcode ?? "instagram"}-text.txt`);
11330
+ writeFileSync2(textPath, [
10353
11331
  `URL: ${extraction.pageUrl}`,
10354
11332
  extraction.caption ? `Caption: ${extraction.caption}` : "",
10355
11333
  "",
@@ -10357,7 +11335,7 @@ instagramApp.post("/media-download", createApiKeyAuth(), async (c) => {
10357
11335
  ].filter(Boolean).join("\n"), "utf8");
10358
11336
  downloads.push({ kind: "text", url: null, savedPath: textPath, sizeBytes: statSync(textPath).size, mimeType: "text/plain", error: null });
10359
11337
  if (mediaTypes.has("image") && extraction.imageUrl) {
10360
- const imagePathBase = join4(outputDir, `${extraction.shortcode ?? "instagram"}-image`);
11338
+ const imagePathBase = join5(outputDir, `${extraction.shortcode ?? "instagram"}-image`);
10361
11339
  try {
10362
11340
  const downloaded = await downloadToFile(extraction.imageUrl, imagePathBase, extraction.pageUrl);
10363
11341
  const ext = extFromMime(downloaded.mimeType, "jpg");
@@ -10377,7 +11355,7 @@ instagramApp.post("/media-download", createApiKeyAuth(), async (c) => {
10377
11355
  if (track.streamType === "video" && !mediaTypes.has("video")) continue;
10378
11356
  if (track.streamType === "audio" && !mediaTypes.has("audio")) continue;
10379
11357
  const kind = track.streamType === "audio" ? "audio" : "video";
10380
- const target = join4(outputDir, trackFilename(track, index, extraction.shortcode));
11358
+ const target = join5(outputDir, trackFilename(track, index, extraction.shortcode));
10381
11359
  try {
10382
11360
  const downloaded = await downloadToFile(track.url, target, extraction.pageUrl);
10383
11361
  downloads.push({ kind, url: track.url, savedPath: downloaded.savedPath, sizeBytes: downloaded.sizeBytes, mimeType: downloaded.mimeType, error: null });
@@ -10387,7 +11365,7 @@ instagramApp.post("/media-download", createApiKeyAuth(), async (c) => {
10387
11365
  }
10388
11366
  }
10389
11367
  if (body.mux && mediaTypes.has("video") && mediaTypes.has("audio") && extraction.selectedVideoTrack && extraction.selectedAudioTrack && savedByUrl.has(extraction.selectedVideoTrack.url) && savedByUrl.has(extraction.selectedAudioTrack.url)) {
10390
- const outPath = join4(outputDir, `${extraction.shortcode ?? "instagram"}-muxed.mp4`);
11368
+ const outPath = join5(outputDir, `${extraction.shortcode ?? "instagram"}-muxed.mp4`);
10391
11369
  const muxed = await runFfmpegMux(savedByUrl.get(extraction.selectedVideoTrack.url), savedByUrl.get(extraction.selectedAudioTrack.url), outPath);
10392
11370
  if (muxed.ok) {
10393
11371
  downloads.push({ kind: "muxed_video", url: null, savedPath: outPath, sizeBytes: statSync(outPath).size, mimeType: "video/mp4", error: null });
@@ -11372,7 +12350,7 @@ import { Hono as Hono7 } from "hono";
11372
12350
 
11373
12351
  // src/directory/directory-workflow.ts
11374
12352
  import { mkdir as mkdir2, writeFile } from "fs/promises";
11375
- import { join as join5 } from "path";
12353
+ import { join as join6 } from "path";
11376
12354
  import { z as z16 } from "zod";
11377
12355
 
11378
12356
  // src/directory/location-db.ts
@@ -11708,11 +12686,11 @@ function csvRowsFor(result) {
11708
12686
  return rows;
11709
12687
  }
11710
12688
  async function saveDirectoryCsv(result) {
11711
- const outDir = join5(outputBaseDir(), "directory-workflows");
12689
+ const outDir = join6(outputBaseDir(), "directory-workflows");
11712
12690
  await mkdir2(outDir, { recursive: true });
11713
12691
  const stamp = result.extractedAt.replace(/[:.]/g, "-");
11714
12692
  const slug = `${result.state}-${result.query}`.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
11715
- const path5 = join5(outDir, `${stamp}-${slug}-directory-workflow.csv`);
12693
+ const path5 = join6(outDir, `${stamp}-${slug}-directory-workflow.csv`);
11716
12694
  const headers = [
11717
12695
  "source_query",
11718
12696
  "source_location",
@@ -12380,14 +13358,14 @@ workflowApp.post("/cron/dispatch", async (c) => {
12380
13358
  import { Hono as Hono9 } from "hono";
12381
13359
 
12382
13360
  // src/serp-intelligence/page-snapshot-extractor.ts
12383
- import { createHash } from "crypto";
13361
+ import { createHash as createHash2 } from "crypto";
12384
13362
  import pLimit3 from "p-limit";
12385
13363
  var DEFAULT_TIMEOUT_MS = 15e3;
12386
13364
  var DEFAULT_MAX_CONCURRENCY = 2;
12387
13365
  var DEFAULT_MAX_CONTENT_CHARS = 25e4;
12388
13366
  function sha256(value) {
12389
13367
  if (!value) return null;
12390
- return createHash("sha256").update(value).digest("hex");
13368
+ return createHash2("sha256").update(value).digest("hex");
12391
13369
  }
12392
13370
  function countWords(markdown) {
12393
13371
  const matches = markdown.trim().match(/\b[\p{L}\p{N}][\p{L}\p{N}'-]*\b/gu);
@@ -12436,7 +13414,7 @@ function countLinks(html, baseUrl) {
12436
13414
  totalLinkCount: internalLinkCount + externalLinkCount
12437
13415
  };
12438
13416
  }
12439
- function withTimeout(operation, timeoutMs) {
13417
+ function withTimeout2(operation, timeoutMs) {
12440
13418
  return new Promise((resolve, reject) => {
12441
13419
  const timer = setTimeout(() => reject(new Error(`Page snapshot timed out after ${timeoutMs}ms`)), timeoutMs);
12442
13420
  operation.then(
@@ -12553,7 +13531,7 @@ async function capturePageSnapshot(target, options = {}) {
12553
13531
  });
12554
13532
  }
12555
13533
  try {
12556
- const extraction = await withTimeout(
13534
+ const extraction = await withTimeout2(
12557
13535
  extractKpo({ url: checked.parsed.href, kernelApiKey }),
12558
13536
  timeoutMs
12559
13537
  );
@@ -13486,7 +14464,7 @@ async function listReplayRows(sessionId) {
13486
14464
  }
13487
14465
 
13488
14466
  // src/services/browser-agent/browser-agent-service.ts
13489
- import Kernel3 from "@onkernel/sdk";
14467
+ import Kernel4 from "@onkernel/sdk";
13490
14468
  import { chromium as playwrightChromium } from "playwright";
13491
14469
 
13492
14470
  // src/services/fanout/cdp-capture.ts
@@ -14054,7 +15032,7 @@ function buildFanoutResult(ctx) {
14054
15032
  interceptorReady: ready,
14055
15033
  rawBytes,
14056
15034
  unmappedKeys: parsed.unmappedKeys,
14057
- 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."
15035
+ 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."
14058
15036
  };
14059
15037
  }
14060
15038
  return result;
@@ -14169,7 +15147,7 @@ async function runFanoutCapture(page, input) {
14169
15147
  }
14170
15148
  const adapter = adapterForHost(hostname);
14171
15149
  if (!adapter) {
14172
- 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.`);
15150
+ 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.`);
14173
15151
  }
14174
15152
  const text = cap.bestAnswerText();
14175
15153
  const parsed = adapter.parse(text, input.prompt || "");
@@ -14189,7 +15167,7 @@ var DEFAULT_TIMEOUT_SECONDS = 600;
14189
15167
  function client() {
14190
15168
  const apiKey = browserServiceApiKey();
14191
15169
  if (!apiKey) throw new Error("Browser backend API key is required");
14192
- return new Kernel3({ apiKey });
15170
+ return new Kernel4({ apiKey });
14193
15171
  }
14194
15172
  function isProfileConflict(err) {
14195
15173
  const message = err instanceof Error ? err.message : String(err);
@@ -15721,6 +16699,10 @@ app.get("/stats/spots", async (c) => {
15721
16699
  const taken = await countActiveUsers();
15722
16700
  return c.json({ taken, open: true });
15723
16701
  });
16702
+ app.get("/catalog", (c) => {
16703
+ c.header("Cache-Control", "public, max-age=60");
16704
+ return c.json(CATALOG);
16705
+ });
15724
16706
  app.get("/me", async (c) => {
15725
16707
  const token = getCookie(c, "session");
15726
16708
  if (!token) return c.json({ authenticated: false });
@@ -15994,7 +16976,7 @@ app.post("/extract-url", auth2, async (c) => {
15994
16976
  const raw = await c.req.json().catch(() => ({}));
15995
16977
  const bodyResult = ExtractUrlBodySchema.safeParse(raw);
15996
16978
  if (!bodyResult.success) return c.json({ error: bodyResult.error.issues[0]?.message ?? "Invalid request" }, 400);
15997
- const { url, screenshot: screenshot2, screenshotDevice, extractBranding, downloadMedia, mediaTypes, allowLocal } = bodyResult.data;
16979
+ const { url, screenshot: screenshot2, screenshotDevice, extractBranding: extractBranding2, downloadMedia, mediaTypes, allowLocal } = bodyResult.data;
15998
16980
  if (!allowLocal) {
15999
16981
  const checked = await validatePublicHttpUrl(url, { field: "URL" });
16000
16982
  if (checked.error || !checked.parsed) return c.json({ error: checked.error ?? "Invalid URL" }, 400);
@@ -16027,7 +17009,7 @@ app.post("/extract-url", auth2, async (c) => {
16027
17009
  const device = screenshotDevice === "mobile" ? "mobile" : "desktop";
16028
17010
  const [result, pageData] = await Promise.all([
16029
17011
  extractKpo({ url: canonicalUrl, kernelApiKey }),
16030
- screenshot2 || extractBranding ? capturePageData(canonicalUrl, { kernelApiKey, device, screenshot: !!screenshot2, branding: !!extractBranding }).catch((err) => {
17012
+ screenshot2 || extractBranding2 ? capturePageData(canonicalUrl, { kernelApiKey, device, screenshot: !!screenshot2, branding: !!extractBranding2 }).catch((err) => {
16031
17013
  console.error("[extract-url] capturePageData failed:", err instanceof Error ? err.message : err);
16032
17014
  return null;
16033
17015
  }) : null
@@ -16071,7 +17053,7 @@ app.post("/map-urls", auth2, async (c) => {
16071
17053
  debited = true;
16072
17054
  const result = await spiderSite({
16073
17055
  startUrl: parsed.href,
16074
- maxUrls: Math.min(2e3, Math.max(1, body.maxUrls ?? 500)),
17056
+ maxUrls: Math.min(1e4, Math.max(1, body.maxUrls ?? 500)),
16075
17057
  concurrency: Math.min(20, Math.max(1, body.concurrency ?? 12)),
16076
17058
  kernelApiKey: body.browserFallback ?? body.kernelFallback ? browserServiceApiKey() : void 0
16077
17059
  });
@@ -16108,7 +17090,43 @@ app.post("/extract-site", auth2, async (c) => {
16108
17090
  if (checked.error || !checked.parsed) return c.json({ error: checked.error ?? "Invalid URL" }, 400);
16109
17091
  const parsed = checked.parsed;
16110
17092
  const user = c.get("user");
16111
- const siteHoldMc = MC_COSTS.page_scrape * 10;
17093
+ if (body.background === true) {
17094
+ const affordablePages = Math.floor(user.balance_mc / MC_COSTS.page_scrape);
17095
+ if (affordablePages < 1) return c.json(insufficientBalanceResponse(user.balance_mc, MC_COSTS.page_scrape), 402);
17096
+ const maxPages = Math.min(Math.min(1e4, Math.max(1, body.maxPages ?? 1e4)), affordablePages);
17097
+ const heldMc = maxPages * MC_COSTS.page_scrape;
17098
+ const { ok: holdOk, balance_mc: holdBal } = await debitMc(user.id, heldMc, LedgerOperation.EXTRACT_SITE_HOLD, parsed.hostname);
17099
+ if (!holdOk) return c.json(insufficientBalanceResponse(holdBal, heldMc), 402);
17100
+ const jobId = `ext_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
17101
+ let jobCreated = false;
17102
+ try {
17103
+ await createExtractJob(jobId, user.id, parsed.href, {
17104
+ maxPages,
17105
+ concurrency: concurrencyLimitForUser(user),
17106
+ urlsPerBrowser: body.rotateProxyEvery ?? 10,
17107
+ formats: body.formats,
17108
+ heldMc
17109
+ });
17110
+ jobCreated = true;
17111
+ await inngest.send({ name: "mcp-scraper/extract.requested", data: { jobId } });
17112
+ } catch (err) {
17113
+ if (jobCreated) {
17114
+ await settleExtractJob(jobId, user.id, heldMc, 0, "enqueue failed").catch(() => {
17115
+ });
17116
+ } else {
17117
+ await creditMc(user.id, heldMc, LedgerOperation.EXTRACT_SITE_REFUND, "enqueue failed").catch(() => {
17118
+ });
17119
+ }
17120
+ await failExtractJob(jobId, err instanceof Error ? err.message : "enqueue failed").catch(() => {
17121
+ });
17122
+ return c.json({ error: "Failed to enqueue background crawl" }, 503);
17123
+ }
17124
+ return c.json({ jobId, status: "pending", statusUrl: `/extract-site/status/${jobId}` });
17125
+ }
17126
+ const affordablePagesSync = Math.floor(user.balance_mc / MC_COSTS.page_scrape);
17127
+ if (affordablePagesSync < 1) return c.json(insufficientBalanceResponse(user.balance_mc, MC_COSTS.page_scrape), 402);
17128
+ const siteMaxPages = Math.min(Math.min(1e4, Math.max(1, body.maxPages ?? 100)), affordablePagesSync);
17129
+ const siteHoldMc = siteMaxPages * MC_COSTS.page_scrape;
16112
17130
  const gate = await acquireConcurrencyGate(user, "extract_site", {
16113
17131
  reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
16114
17132
  metadata: { url: parsed.href }
@@ -16119,10 +17137,17 @@ app.post("/extract-site", auth2, async (c) => {
16119
17137
  const { ok: siteOk, balance_mc: siteBal } = await debitMc(user.id, siteHoldMc, LedgerOperation.EXTRACT_SITE_HOLD, parsed.hostname);
16120
17138
  if (!siteOk) return c.json(insufficientBalanceResponse(siteBal, siteHoldMc), 402);
16121
17139
  debited = true;
17140
+ const rotateProxies = body.rotateProxies === true;
17141
+ const wantsBranding = body.formats?.includes("branding") === true;
16122
17142
  const result = await extractSite({
16123
17143
  startUrl: parsed.href,
16124
- maxPages: Math.min(200, Math.max(1, body.maxPages ?? 100)),
16125
- kernelApiKey: body.browserFallback ?? body.kernelFallback ? browserServiceApiKey() : void 0
17144
+ maxPages: siteMaxPages,
17145
+ kernelApiKey: rotateProxies || wantsBranding || body.browserFallback || body.kernelFallback ? browserServiceApiKey() : void 0,
17146
+ formats: body.formats,
17147
+ ...rotateProxies ? {
17148
+ rotateProxyEvery: body.rotateProxyEvery ?? 30,
17149
+ parallelism: concurrencyLimitForUser(user)
17150
+ } : {}
16126
17151
  });
16127
17152
  const pageCount = result.pages?.length ?? 1;
16128
17153
  const actualSiteMc = pageCount * MC_COSTS.page_scrape;
@@ -16153,6 +17178,21 @@ app.post("/extract-site", auth2, async (c) => {
16153
17178
  await releaseConcurrencyGate(gate.lockId);
16154
17179
  }
16155
17180
  });
17181
+ app.get("/extract-site/status/:id", auth2, async (c) => {
17182
+ const user = c.get("user");
17183
+ const job = await getExtractJob(c.req.param("id"));
17184
+ if (!job || job.userId !== user.id) return c.json({ error: "Job not found" }, 404);
17185
+ return c.json({
17186
+ jobId: job.id,
17187
+ status: job.status,
17188
+ startUrl: job.startUrl,
17189
+ totalUrls: job.totalUrls,
17190
+ doneUrls: job.doneUrls,
17191
+ artifacts: job.artifacts ?? [],
17192
+ error: job.error,
17193
+ updatedAt: job.updatedAt
17194
+ });
17195
+ });
16156
17196
  app.post("/billing/checkout", requireAllowedOrigin, sessionAuth, async (c) => {
16157
17197
  try {
16158
17198
  const user = c.get("sessionUser");
@@ -16332,7 +17372,7 @@ app.get("/cron/tick", async (c) => {
16332
17372
  if (!process.env.CRON_SECRET || secret2 !== `Bearer ${process.env.CRON_SECRET}`) {
16333
17373
  return c.json({ error: "Unauthorized" }, 401);
16334
17374
  }
16335
- const { drainQueue } = await import("./worker-FG7ZWEGA.js");
17375
+ const { drainQueue } = await import("./worker-2WVKKCC7.js");
16336
17376
  const budget = { maxJobs: 10, deadlineMs: Date.now() + 28e4 };
16337
17377
  const workflowDispatchResult = await dispatchDueWorkflowSchedules(`${new URL(c.req.url).protocol}//${new URL(c.req.url).host}`);
16338
17378
  const [results, sweepResult] = await Promise.all([
@@ -16341,7 +17381,7 @@ app.get("/cron/tick", async (c) => {
16341
17381
  ]);
16342
17382
  return c.json({ drained: results.length, results, sweepResult, workflowDispatch: workflowDispatchResult });
16343
17383
  });
16344
- app.on(["GET", "POST", "PUT"], "/api/inngest", serveInngest({ client: inngest, functions: [siteAuditFn] }));
17384
+ app.on(["GET", "POST", "PUT"], "/api/inngest", serveInngest({ client: inngest, functions: [siteAuditFn, siteExtractFn] }));
16345
17385
  app.route("/api/internal/site-architecture-auditor", siteAuditApp);
16346
17386
  app.route("/youtube", youtubeApp);
16347
17387
  app.route("/screenshot", screenshotApp);
@@ -16462,4 +17502,4 @@ app.get("/blog/:slug/", (c) => {
16462
17502
  export {
16463
17503
  app
16464
17504
  };
16465
- //# sourceMappingURL=server-ILIVSPNY.js.map
17505
+ //# sourceMappingURL=server-LPVBSE2E.js.map