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.
- package/dist/bin/api-server.cjs +1991 -508
- package/dist/bin/api-server.cjs.map +1 -1
- package/dist/bin/api-server.js +3 -3
- package/dist/bin/browser-agent-stdio-server.cjs +9 -9
- package/dist/bin/browser-agent-stdio-server.cjs.map +1 -1
- package/dist/bin/browser-agent-stdio-server.js +2 -2
- package/dist/bin/mcp-scraper-cli.cjs +1 -1
- package/dist/bin/mcp-scraper-cli.cjs.map +1 -1
- package/dist/bin/mcp-scraper-cli.js +1 -1
- package/dist/bin/mcp-scraper-combined-stdio-server.cjs +428 -47
- package/dist/bin/mcp-scraper-combined-stdio-server.cjs.map +1 -1
- package/dist/bin/mcp-scraper-combined-stdio-server.js +6 -5
- package/dist/bin/mcp-scraper-combined-stdio-server.js.map +1 -1
- package/dist/bin/mcp-scraper-install.cjs +2 -2
- package/dist/bin/mcp-scraper-install.cjs.map +1 -1
- package/dist/bin/mcp-scraper-install.js +2 -2
- package/dist/bin/mcp-stdio-server.cjs +419 -38
- package/dist/bin/mcp-stdio-server.cjs.map +1 -1
- package/dist/bin/mcp-stdio-server.js +2 -2
- package/dist/chunk-2MX5WOII.js +7 -0
- package/dist/chunk-2MX5WOII.js.map +1 -0
- package/dist/{chunk-DBQDG7EH.js → chunk-5H22TOXQ.js} +28 -1
- package/dist/chunk-5H22TOXQ.js.map +1 -0
- package/dist/{chunk-KPXMPAJ3.js → chunk-7Y6JDBML.js} +2 -2
- package/dist/chunk-7Y6JDBML.js.map +1 -0
- package/dist/{chunk-QAOIH5U2.js → chunk-WJ2LWHPA.js} +10 -10
- package/dist/chunk-WJ2LWHPA.js.map +1 -0
- package/dist/{chunk-YGV2XZDT.js → chunk-WQAIOY2D.js} +424 -39
- package/dist/chunk-WQAIOY2D.js.map +1 -0
- package/dist/{chunk-AZ5PKU4F.js → chunk-ZAUMSBV3.js} +2 -2
- package/dist/{db-BE4JVB3V.js → db-P76GVIFN.js} +2 -2
- package/dist/{server-LX65PB7D.js → server-UOQ2JIUQ.js} +1106 -93
- package/dist/server-UOQ2JIUQ.js.map +1 -0
- package/dist/{worker-FG7ZWEGA.js → worker-2WVKKCC7.js} +3 -3
- package/docs/final-tooling-spec.md +206 -0
- package/docs/mcp-tool-craft-lint.generated.md +1 -1
- package/docs/mcp-tool-design-guide.md +225 -0
- package/docs/mcp-tool-manifest.generated.json +13 -13
- package/docs/seo-crawl-report-spec.md +287 -0
- package/docs/tool-catalog-spec.md +388 -0
- package/package.json +2 -1
- package/dist/chunk-BEZJ4G32.js +0 -7
- package/dist/chunk-BEZJ4G32.js.map +0 -1
- package/dist/chunk-DBQDG7EH.js.map +0 -1
- package/dist/chunk-KPXMPAJ3.js.map +0 -1
- package/dist/chunk-QAOIH5U2.js.map +0 -1
- package/dist/chunk-YGV2XZDT.js.map +0 -1
- package/dist/server-LX65PB7D.js.map +0 -1
- /package/dist/{chunk-AZ5PKU4F.js.map → chunk-ZAUMSBV3.js.map} +0 -0
- /package/dist/{db-BE4JVB3V.js.map → db-P76GVIFN.js.map} +0 -0
- /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-
|
|
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-
|
|
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
|
-
|
|
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-
|
|
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,
|
|
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
|
-
|
|
4820
|
-
|
|
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[
|
|
4824
|
-
if (abs.
|
|
4825
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
5195
|
+
const maxPages = Math.min(opts.maxPages ?? 100, 1e4);
|
|
4875
5196
|
const concurrency = Math.min(opts.concurrency ?? EXTRACT_CONCURRENCY, 10);
|
|
4876
|
-
const
|
|
4877
|
-
|
|
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 =
|
|
4885
|
-
|
|
4886
|
-
|
|
4887
|
-
|
|
4888
|
-
|
|
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
|
|
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(
|
|
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
|
|
8363
|
-
const start = fmtTs(
|
|
8364
|
-
const end = fmtTs(
|
|
8365
|
-
lines.push(`**[${start} \u2192 ${end}]** ${
|
|
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((
|
|
8867
|
-
const lidM =
|
|
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 =
|
|
9820
|
+
const statusM = chunk2.match(/^(Active|Inactive)/i);
|
|
8870
9821
|
const status = statusM ? statusM[1] : null;
|
|
8871
|
-
const startM =
|
|
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(
|
|
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(
|
|
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 =
|
|
9839
|
+
const sponsoredIdx = chunk2.search(/\bSponsored\b/i);
|
|
8889
9840
|
if (sponsoredIdx >= 0) {
|
|
8890
|
-
let afterSponsored =
|
|
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 =
|
|
9852
|
+
const di = chunk2.indexOf(domain);
|
|
8902
9853
|
if (di >= 0) {
|
|
8903
|
-
let afterDomain =
|
|
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 =
|
|
9861
|
+
const ctaIdx = chunk2.search(ctaRe2);
|
|
8911
9862
|
if (ctaIdx > 0) {
|
|
8912
|
-
const beforeCta =
|
|
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 (
|
|
9875
|
+
if (chunk2.toLowerCase().includes(lbl.toLowerCase())) {
|
|
8925
9876
|
cta = lbl;
|
|
8926
9877
|
break;
|
|
8927
9878
|
}
|
|
8928
9879
|
}
|
|
8929
|
-
const multiM =
|
|
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
|
|
8953
|
-
const detailsParts =
|
|
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
|
|
9694
|
-
const lidM =
|
|
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 =
|
|
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
|
|
9778
|
-
import { homedir as
|
|
9779
|
-
import { join as
|
|
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() ||
|
|
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 =
|
|
10211
|
-
|
|
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", (
|
|
10250
|
-
stderr += String(
|
|
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 =
|
|
10352
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(`
|
|
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
|
|
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 ||
|
|
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(
|
|
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
|
-
|
|
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:
|
|
16125
|
-
kernelApiKey: body.browserFallback
|
|
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-
|
|
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-
|
|
17478
|
+
//# sourceMappingURL=server-UOQ2JIUQ.js.map
|