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