mcp-scraper 0.3.13 → 0.3.15

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