@webcrawl/mcp 1.4.2 → 1.4.3
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/README.md +1 -1
- package/dist/bin.js +5 -7
- package/dist/core/engine/awswaf.d.ts +59 -0
- package/dist/core/engine/awswaf.js +164 -0
- package/dist/core/engine/cache.d.ts +70 -0
- package/dist/core/engine/cache.js +297 -0
- package/dist/core/engine/chrome.d.ts +14 -0
- package/dist/core/engine/chrome.js +140 -0
- package/dist/core/engine/crawl-policy.d.ts +36 -0
- package/dist/core/engine/crawl-policy.js +134 -0
- package/dist/core/engine/crawler.d.ts +152 -0
- package/dist/core/engine/crawler.js +918 -0
- package/dist/core/engine/fetcher.d.ts +92 -0
- package/dist/core/engine/fetcher.js +494 -0
- package/dist/core/engine/index.d.ts +7 -0
- package/dist/core/engine/index.js +7 -0
- package/dist/core/engine/markdown.d.ts +33 -0
- package/dist/core/engine/markdown.js +334 -0
- package/dist/core/engine/path-scope.d.ts +21 -0
- package/dist/core/engine/path-scope.js +88 -0
- package/dist/core/engine/render.d.ts +41 -0
- package/dist/core/engine/render.js +138 -0
- package/dist/core/engine/safe-url.d.ts +29 -0
- package/dist/core/engine/safe-url.js +109 -0
- package/dist/core/engine/session.d.ts +93 -0
- package/dist/core/engine/session.js +250 -0
- package/dist/core/engine/stealth.d.ts +83 -0
- package/dist/core/engine/stealth.js +222 -0
- package/dist/core/engine.d.ts +1 -0
- package/dist/core/engine.js +1 -0
- package/dist/core/extract/business.d.ts +28 -0
- package/dist/core/extract/business.js +344 -0
- package/dist/core/extract/currency.d.ts +15 -0
- package/dist/core/extract/currency.js +259 -0
- package/dist/core/extract/cve-cache.d.ts +62 -0
- package/dist/core/extract/cve-cache.js +277 -0
- package/dist/core/extract/cve.d.ts +57 -0
- package/dist/core/extract/cve.js +397 -0
- package/dist/core/extract/dedupe-key.d.ts +12 -0
- package/dist/core/extract/dedupe-key.js +36 -0
- package/dist/core/extract/flights.d.ts +37 -0
- package/dist/core/extract/flights.js +373 -0
- package/dist/core/extract/hotels.d.ts +35 -0
- package/dist/core/extract/hotels.js +473 -0
- package/dist/core/extract/index.d.ts +9 -0
- package/dist/core/extract/index.js +9 -0
- package/dist/core/extract/real-estate.d.ts +53 -0
- package/dist/core/extract/real-estate.js +835 -0
- package/dist/core/extract/scaledown.d.ts +14 -0
- package/dist/core/extract/scaledown.js +484 -0
- package/dist/core/extract/shopping.d.ts +44 -0
- package/dist/core/extract/shopping.js +393 -0
- package/dist/core/extract/structured.d.ts +14 -0
- package/dist/core/extract/structured.js +144 -0
- package/dist/core/image-url.d.ts +5 -0
- package/dist/core/image-url.js +13 -0
- package/dist/core/interact/driver.d.ts +72 -0
- package/dist/core/interact/driver.js +384 -0
- package/dist/core/interact/remote.d.ts +41 -0
- package/dist/core/interact/remote.js +133 -0
- package/dist/core/interact/spans.d.ts +46 -0
- package/dist/core/interact/spans.js +32 -0
- package/dist/core/interact/validate.d.ts +34 -0
- package/dist/core/interact/validate.js +135 -0
- package/dist/core/interact/vm.d.ts +43 -0
- package/dist/core/interact/vm.js +338 -0
- package/dist/core/local-engine.d.ts +56 -0
- package/dist/core/local-engine.js +667 -0
- package/dist/core/parse/detect.d.ts +29 -0
- package/dist/core/parse/detect.js +152 -0
- package/dist/core/parse/fetch.d.ts +20 -0
- package/dist/core/parse/fetch.js +39 -0
- package/dist/core/parse/image.d.ts +13 -0
- package/dist/core/parse/image.js +100 -0
- package/dist/core/parse/index.d.ts +53 -0
- package/dist/core/parse/index.js +153 -0
- package/dist/core/parse/limits.d.ts +12 -0
- package/dist/core/parse/limits.js +29 -0
- package/dist/core/parse/media.d.ts +42 -0
- package/dist/core/parse/media.js +238 -0
- package/dist/core/parse/merge.d.ts +19 -0
- package/dist/core/parse/merge.js +48 -0
- package/dist/core/parse/ocr.d.ts +25 -0
- package/dist/core/parse/ocr.js +121 -0
- package/dist/core/parse/office.d.ts +28 -0
- package/dist/core/parse/office.js +83 -0
- package/dist/core/parse/pdf.d.ts +27 -0
- package/dist/core/parse/pdf.js +156 -0
- package/dist/core/parse/types.d.ts +38 -0
- package/dist/core/parse/types.js +1 -0
- package/dist/core/parse/youtube.d.ts +30 -0
- package/dist/core/parse/youtube.js +264 -0
- package/dist/core/ranker/decision.d.ts +28 -0
- package/dist/core/ranker/decision.js +151 -0
- package/dist/core/ranker/index.d.ts +5 -0
- package/dist/core/ranker/index.js +361 -0
- package/dist/core/ranker/lexical.d.ts +82 -0
- package/dist/core/ranker/lexical.js +643 -0
- package/dist/core/ranker/types.d.ts +90 -0
- package/dist/core/ranker/types.js +1 -0
- package/dist/core/ranker.d.ts +1 -0
- package/dist/core/ranker.js +1 -0
- package/dist/core/sandbox.d.ts +29 -0
- package/dist/core/sandbox.js +222 -0
- package/dist/core/search/catalog.d.ts +176 -0
- package/dist/core/search/catalog.js +488 -0
- package/dist/core/search/index.d.ts +3 -0
- package/dist/core/search/index.js +3 -0
- package/dist/core/search/pipeline.d.ts +7 -0
- package/dist/core/search/pipeline.js +719 -0
- package/dist/core/search/providers.d.ts +72 -0
- package/dist/core/search/providers.js +1270 -0
- package/dist/core/search.d.ts +1 -0
- package/dist/core/search.js +1 -0
- package/dist/core/targets/developer.d.ts +44 -0
- package/dist/core/targets/developer.js +338 -0
- package/dist/core/targets/index.d.ts +3 -0
- package/dist/core/targets/index.js +3 -0
- package/dist/core/targets/registry.d.ts +32 -0
- package/dist/core/targets/registry.js +411 -0
- package/dist/core/targets/types.d.ts +72 -0
- package/dist/core/targets/types.js +8 -0
- package/dist/core/targets.d.ts +1 -0
- package/dist/core/targets.js +1 -0
- package/dist/format.js +5 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/load-local-engine.d.ts +7 -0
- package/dist/load-local-engine.js +22 -0
- package/dist/server.js +3 -0
- package/package.json +11 -3
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ Add to `.cursor/mcp.json`, `~/.claude.json`, or your MCP client config:
|
|
|
32
32
|
|
|
33
33
|
### Optional: webcrawl.app cloud
|
|
34
34
|
|
|
35
|
-
Local mode is the default and needs nothing else. To run the same tools on the hosted service, either:
|
|
35
|
+
Local mode is the default and needs nothing else. The packaged server loads the local engine from `dist/core`; if that import fails it exits with an error instead of silently calling webcrawl.app. To run the same tools on the hosted service, either:
|
|
36
36
|
|
|
37
37
|
- add `"WEBCRAWL_API_KEY": "wc_live_..."` to `env` above (get a key at [webcrawl.app](https://webcrawl.app)), or
|
|
38
38
|
- connect your client to the hosted endpoint `https://webcrawl.app/mcp` and sign in in the browser. No local install is needed.
|
package/dist/bin.js
CHANGED
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
3
|
import { WebcrawlClient, resolveConfig } from "@webcrawl/client";
|
|
4
|
+
import { loadLocalEngineClient } from "./load-local-engine.js";
|
|
4
5
|
import { createServer, resolveSchemaProfile } from "./server.js";
|
|
5
6
|
const cfg = resolveConfig();
|
|
6
7
|
let client;
|
|
7
8
|
if (cfg.effectiveMode === "local") {
|
|
8
9
|
try {
|
|
9
|
-
|
|
10
|
-
const localEngineSpec = ["..", "..", "..", "src", "local-engine.js"].join("/");
|
|
11
|
-
const localMod = (await import(localEngineSpec));
|
|
12
|
-
client = localMod.createLocalEngineClient();
|
|
10
|
+
client = await loadLocalEngineClient();
|
|
13
11
|
process.stderr.write("webcrawl-mcp: running in zero-key LOCAL engine mode. Set WEBCRAWL_API_KEY (or run `webcrawl login`) to upgrade to webcrawl.app cloud.\n");
|
|
14
12
|
}
|
|
15
|
-
catch {
|
|
16
|
-
process.stderr.write(
|
|
17
|
-
|
|
13
|
+
catch (e) {
|
|
14
|
+
process.stderr.write(`${e instanceof Error ? e.message : String(e)}\n`);
|
|
15
|
+
process.exit(1);
|
|
18
16
|
}
|
|
19
17
|
}
|
|
20
18
|
else {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS WAF challenge handling (local prototype).
|
|
3
|
+
*
|
|
4
|
+
* `webcrawl`'s bot-check detector (`isAntiBotChallenge`) only matches
|
|
5
|
+
* Cloudflare and Vercel Security Checkpoint signatures. AWS WAF serves
|
|
6
|
+
* its invisible JS challenge as **HTTP 202** with an empty `<title>` and
|
|
7
|
+
* none of those markers, so such pages come back as
|
|
8
|
+
* `Empty page (HTTP 202)` with `blocked: false` and never reach the
|
|
9
|
+
* stealth browser. This module adds the missing detection plus a local
|
|
10
|
+
* stealth-routing path so those pages can be solved and read anyway.
|
|
11
|
+
*
|
|
12
|
+
* Detection markers are layered (no single one is proof):
|
|
13
|
+
* - status 202 (documented Challenge interstitial status)
|
|
14
|
+
* - `awsWafCookieDomainList` / `AwsWafIntegration` (documented JS API)
|
|
15
|
+
* - `aws-waf-token` cookie reference
|
|
16
|
+
* - `<div id="challenge-container">` + `/__challenge_<id>/.../challenge.js`
|
|
17
|
+
*
|
|
18
|
+
* The detection itself now lives in the shared detector
|
|
19
|
+
* (`isAwsWafChallenge` in `./fetcher.js`, also folded into
|
|
20
|
+
* `isAntiBotChallenge`); this module re-exports it and adds the local
|
|
21
|
+
* stealth-routing path so challenge pages can be solved and read anyway.
|
|
22
|
+
*/
|
|
23
|
+
import { isAwsWafChallenge } from "./fetcher.js";
|
|
24
|
+
export { isAwsWafChallenge };
|
|
25
|
+
/** True when a fetch response is any known bot check (upstream detector + AWS WAF). */
|
|
26
|
+
export declare function isBlockedLike(html: string, status?: number): boolean;
|
|
27
|
+
export interface AwsWafPage {
|
|
28
|
+
url: string;
|
|
29
|
+
title: string;
|
|
30
|
+
markdown: string;
|
|
31
|
+
linksCount: number;
|
|
32
|
+
cached: false;
|
|
33
|
+
cacheLayer: "miss";
|
|
34
|
+
structuredData: null;
|
|
35
|
+
statusCode?: number;
|
|
36
|
+
blocked?: boolean;
|
|
37
|
+
error?: string;
|
|
38
|
+
awsWaf?: boolean;
|
|
39
|
+
stealth?: boolean;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Opens `url` in a real local Chromium, waits out the AWS WAF challenge
|
|
43
|
+
* (the interstitial reloads itself once `AwsWafIntegration.getToken()`
|
|
44
|
+
* resolves), then returns the rendered page as a CrawledPage-shaped result.
|
|
45
|
+
* Never fabricates content: a page still showing the challenge comes back
|
|
46
|
+
* `blocked: true` with an honest error.
|
|
47
|
+
*/
|
|
48
|
+
export declare function awsWafStealthFetch(url: string, opts?: {
|
|
49
|
+
timeoutMs?: number;
|
|
50
|
+
}): Promise<AwsWafPage>;
|
|
51
|
+
/**
|
|
52
|
+
* Reads one page: plain TLS-impersonated fetch first; when the site answers
|
|
53
|
+
* with an AWS WAF challenge (which the upstream detector misses), routes
|
|
54
|
+
* through the local stealth browser instead.
|
|
55
|
+
*/
|
|
56
|
+
export declare function crawlPageWithAwsWaf(url: string, opts?: {
|
|
57
|
+
bypassCache?: boolean;
|
|
58
|
+
stealthTimeoutMs?: number;
|
|
59
|
+
}): Promise<AwsWafPage>;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS WAF challenge handling (local prototype).
|
|
3
|
+
*
|
|
4
|
+
* `webcrawl`'s bot-check detector (`isAntiBotChallenge`) only matches
|
|
5
|
+
* Cloudflare and Vercel Security Checkpoint signatures. AWS WAF serves
|
|
6
|
+
* its invisible JS challenge as **HTTP 202** with an empty `<title>` and
|
|
7
|
+
* none of those markers, so such pages come back as
|
|
8
|
+
* `Empty page (HTTP 202)` with `blocked: false` and never reach the
|
|
9
|
+
* stealth browser. This module adds the missing detection plus a local
|
|
10
|
+
* stealth-routing path so those pages can be solved and read anyway.
|
|
11
|
+
*
|
|
12
|
+
* Detection markers are layered (no single one is proof):
|
|
13
|
+
* - status 202 (documented Challenge interstitial status)
|
|
14
|
+
* - `awsWafCookieDomainList` / `AwsWafIntegration` (documented JS API)
|
|
15
|
+
* - `aws-waf-token` cookie reference
|
|
16
|
+
* - `<div id="challenge-container">` + `/__challenge_<id>/.../challenge.js`
|
|
17
|
+
*
|
|
18
|
+
* The detection itself now lives in the shared detector
|
|
19
|
+
* (`isAwsWafChallenge` in `./fetcher.js`, also folded into
|
|
20
|
+
* `isAntiBotChallenge`); this module re-exports it and adds the local
|
|
21
|
+
* stealth-routing path so challenge pages can be solved and read anyway.
|
|
22
|
+
*/
|
|
23
|
+
import { fetchWithTlsImpersonation, isAntiBotChallenge, isAwsWafChallenge } from "./fetcher.js";
|
|
24
|
+
import { fastHtmlToMarkdown } from "./markdown.js";
|
|
25
|
+
export { isAwsWafChallenge };
|
|
26
|
+
/** True when a fetch response is any known bot check (upstream detector + AWS WAF). */
|
|
27
|
+
export function isBlockedLike(html, status) {
|
|
28
|
+
return isAntiBotChallenge(html ?? "", status ?? 200) || isAwsWafChallenge(html ?? "", status);
|
|
29
|
+
}
|
|
30
|
+
const MAC_CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
|
31
|
+
const CHALLENGE_TIMEOUT_MS = 45_000;
|
|
32
|
+
/** Waits for a dynamically-installed playwright-core (avoids a hard dep). */
|
|
33
|
+
async function loadPlaywrightCore() {
|
|
34
|
+
// Non-literal specifier: playwright-core stays a soft, runtime-only dependency
|
|
35
|
+
// so `tsc --noEmit` passes without the package installed.
|
|
36
|
+
const specifier = "playwright-core";
|
|
37
|
+
return (await import(specifier));
|
|
38
|
+
}
|
|
39
|
+
function chromePath() {
|
|
40
|
+
return process.env.INTERACT_CHROME_PATH || MAC_CHROME;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Opens `url` in a real local Chromium, waits out the AWS WAF challenge
|
|
44
|
+
* (the interstitial reloads itself once `AwsWafIntegration.getToken()`
|
|
45
|
+
* resolves), then returns the rendered page as a CrawledPage-shaped result.
|
|
46
|
+
* Never fabricates content: a page still showing the challenge comes back
|
|
47
|
+
* `blocked: true` with an honest error.
|
|
48
|
+
*/
|
|
49
|
+
export async function awsWafStealthFetch(url, opts = {}) {
|
|
50
|
+
const fail = (error, blocked = true) => ({
|
|
51
|
+
url,
|
|
52
|
+
title: url,
|
|
53
|
+
markdown: "",
|
|
54
|
+
linksCount: 0,
|
|
55
|
+
cached: false,
|
|
56
|
+
cacheLayer: "miss",
|
|
57
|
+
structuredData: null,
|
|
58
|
+
blocked,
|
|
59
|
+
error,
|
|
60
|
+
awsWaf: true,
|
|
61
|
+
stealth: true,
|
|
62
|
+
});
|
|
63
|
+
const { chromium } = await loadPlaywrightCore();
|
|
64
|
+
const executablePath = chromePath();
|
|
65
|
+
const browser = await chromium.launch({
|
|
66
|
+
executablePath,
|
|
67
|
+
headless: true,
|
|
68
|
+
args: [
|
|
69
|
+
"--no-sandbox",
|
|
70
|
+
"--disable-gpu",
|
|
71
|
+
"--disable-dev-shm-usage",
|
|
72
|
+
"--no-first-run",
|
|
73
|
+
"--no-default-browser-check",
|
|
74
|
+
// Hide navigator.webdriver, which bot checks look for.
|
|
75
|
+
"--disable-blink-features=AutomationControlled",
|
|
76
|
+
`--window-size=1280,800`,
|
|
77
|
+
],
|
|
78
|
+
});
|
|
79
|
+
try {
|
|
80
|
+
const ctx = await browser.newContext({
|
|
81
|
+
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
82
|
+
viewport: { width: 1280, height: 800 },
|
|
83
|
+
locale: "en-US",
|
|
84
|
+
});
|
|
85
|
+
await ctx.addInitScript("Object.defineProperty(Navigator.prototype,'webdriver',{get:()=>false});");
|
|
86
|
+
const page = await ctx.newPage();
|
|
87
|
+
let navStatus = null;
|
|
88
|
+
page.on("response", (r) => {
|
|
89
|
+
try {
|
|
90
|
+
if (r.request().isNavigationRequest() && r.frame() === page.mainFrame())
|
|
91
|
+
navStatus = r.status();
|
|
92
|
+
}
|
|
93
|
+
catch { }
|
|
94
|
+
});
|
|
95
|
+
// The interstitial reloads itself after getToken(); domcontentloaded +
|
|
96
|
+
// marker polling survives that reload race.
|
|
97
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: Math.min(30_000, opts.timeoutMs ?? 30_000) });
|
|
98
|
+
const deadline = Date.now() + (opts.timeoutMs ?? CHALLENGE_TIMEOUT_MS);
|
|
99
|
+
let html = "";
|
|
100
|
+
for (;;) {
|
|
101
|
+
html = await page.content().catch(() => "");
|
|
102
|
+
if (!isAwsWafChallenge(html) && !isAntiBotChallenge(html, 200))
|
|
103
|
+
break;
|
|
104
|
+
if (Date.now() > deadline)
|
|
105
|
+
break;
|
|
106
|
+
await page.waitForTimeout(700).catch(() => { });
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
await page.waitForLoadState("networkidle", { timeout: 2500 });
|
|
110
|
+
}
|
|
111
|
+
catch { }
|
|
112
|
+
html = await page.content().catch(() => html);
|
|
113
|
+
if (isAwsWafChallenge(html) || isAntiBotChallenge(html, 200)) {
|
|
114
|
+
return { ...fail("Still blocked by the site's AWS WAF challenge in local stealth mode"), statusCode: navStatus ?? undefined };
|
|
115
|
+
}
|
|
116
|
+
const finalUrl = page.url();
|
|
117
|
+
const parsed = fastHtmlToMarkdown(html, finalUrl || url, { retainImages: "all" });
|
|
118
|
+
if (parsed.markdown.length === 0) {
|
|
119
|
+
return {
|
|
120
|
+
...fail(`Empty page after challenge (HTTP ${navStatus ?? "?"})`, false),
|
|
121
|
+
statusCode: navStatus ?? undefined,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
url,
|
|
126
|
+
title: parsed.title || finalUrl || url,
|
|
127
|
+
markdown: parsed.markdown,
|
|
128
|
+
linksCount: parsed.links.length,
|
|
129
|
+
cached: false,
|
|
130
|
+
cacheLayer: "miss",
|
|
131
|
+
structuredData: null,
|
|
132
|
+
statusCode: navStatus ?? 200,
|
|
133
|
+
awsWaf: true,
|
|
134
|
+
stealth: true,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
await browser.close().catch(() => { });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Reads one page: plain TLS-impersonated fetch first; when the site answers
|
|
143
|
+
* with an AWS WAF challenge (which the upstream detector misses), routes
|
|
144
|
+
* through the local stealth browser instead.
|
|
145
|
+
*/
|
|
146
|
+
export async function crawlPageWithAwsWaf(url, opts = {}) {
|
|
147
|
+
const fetched = await fetchWithTlsImpersonation(url, 12_000);
|
|
148
|
+
if (!isAwsWafChallenge(fetched.html, fetched.status)) {
|
|
149
|
+
const parsed = fastHtmlToMarkdown(fetched.html, url, { retainImages: "all" });
|
|
150
|
+
return {
|
|
151
|
+
url,
|
|
152
|
+
title: parsed.title || url,
|
|
153
|
+
markdown: parsed.markdown,
|
|
154
|
+
linksCount: parsed.links.length,
|
|
155
|
+
cached: false,
|
|
156
|
+
cacheLayer: "miss",
|
|
157
|
+
structuredData: null,
|
|
158
|
+
statusCode: fetched.status,
|
|
159
|
+
...(parsed.markdown.length === 0 ? { error: `Empty page (HTTP ${fetched.status})` } : {}),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
console.info("[awswaf] challenge detected, routing through local stealth browser", { url, status: fetched.status });
|
|
163
|
+
return awsWafStealthFetch(url, { timeoutMs: opts.stealthTimeoutMs });
|
|
164
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
interface ExternalCacheDb {
|
|
2
|
+
getCrawledPage(urlHash: string): Promise<{
|
|
3
|
+
title: string;
|
|
4
|
+
markdown: string;
|
|
5
|
+
links: string[];
|
|
6
|
+
structuredData?: Record<string, unknown> | null;
|
|
7
|
+
statusCode: number;
|
|
8
|
+
contentHash: string;
|
|
9
|
+
etag?: string;
|
|
10
|
+
lastModified?: string;
|
|
11
|
+
crawledAt: string | Date;
|
|
12
|
+
expiresAt: string | Date;
|
|
13
|
+
} | null | undefined>;
|
|
14
|
+
upsertCrawledPage(entry: Record<string, unknown>): Promise<unknown>;
|
|
15
|
+
getCrawledSite(cacheKeyHash: string): Promise<{
|
|
16
|
+
links: string[];
|
|
17
|
+
source: string;
|
|
18
|
+
pagesCrawled: number;
|
|
19
|
+
crawledAt: string | Date;
|
|
20
|
+
expiresAt: string | Date;
|
|
21
|
+
} | null | undefined>;
|
|
22
|
+
upsertCrawledSite(entry: Record<string, unknown>): Promise<unknown>;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Registers an external L3 cache database store (e.g., Supabase Postgres in webcrawl.app cloud)
|
|
26
|
+
* when the engine is consumed as an installed npm package (`webcrawl/engine/cache`).
|
|
27
|
+
*/
|
|
28
|
+
export declare function registerExternalCacheDb(db: ExternalCacheDb | null): void;
|
|
29
|
+
export interface CacheEntry {
|
|
30
|
+
title: string;
|
|
31
|
+
markdown: string;
|
|
32
|
+
links: string[];
|
|
33
|
+
structuredData?: Record<string, unknown> | null;
|
|
34
|
+
statusCode: number;
|
|
35
|
+
contentHash: string;
|
|
36
|
+
etag?: string;
|
|
37
|
+
lastModified?: string;
|
|
38
|
+
cacheLayer?: "memory" | "disk" | "postgres";
|
|
39
|
+
cachedAt: number;
|
|
40
|
+
expiresAt: number;
|
|
41
|
+
}
|
|
42
|
+
export interface MapCacheEntry {
|
|
43
|
+
links: string[];
|
|
44
|
+
source: string;
|
|
45
|
+
pagesCrawled: number;
|
|
46
|
+
cacheLayer?: "memory" | "disk" | "postgres";
|
|
47
|
+
cachedAt: number;
|
|
48
|
+
expiresAt: number;
|
|
49
|
+
}
|
|
50
|
+
export declare const CACHE_TTL_MS: number;
|
|
51
|
+
export declare function sha256(content: string): string;
|
|
52
|
+
export declare function normalizeCacheUrl(rawUrl: string): {
|
|
53
|
+
normalizedUrl: string;
|
|
54
|
+
domain: string;
|
|
55
|
+
};
|
|
56
|
+
export declare function clearLocalCacheForTests(): void;
|
|
57
|
+
export declare function getCachedPage(url: string, retainImages: string): CacheEntry | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* 3-tier read-through lookup: L1 Memory -> L2 /tmp Disk -> L3 Postgres `crawled_pages`.
|
|
60
|
+
* Promotes L3 Postgres hits back into L1 and L2 so repeat requests never re-crawl the target website.
|
|
61
|
+
*/
|
|
62
|
+
export declare function getCachedPageAsync(url: string, retainImages: string): Promise<CacheEntry | undefined>;
|
|
63
|
+
export declare function setCachedPage(url: string, retainImages: string, entry: Omit<CacheEntry, "expiresAt" | "cachedAt">, ttlMs?: number): void;
|
|
64
|
+
export declare function getCachedMap(cacheKey: string): MapCacheEntry | undefined;
|
|
65
|
+
/**
|
|
66
|
+
* 3-tier read-through lookup for website maps/crawls: L1 Memory -> L2 Disk -> L3 Postgres `crawled_sites`.
|
|
67
|
+
*/
|
|
68
|
+
export declare function getCachedMapAsync(cacheKey: string): Promise<MapCacheEntry | undefined>;
|
|
69
|
+
export declare function setCachedMap(cacheKey: string, entry: Omit<MapCacheEntry, "cachedAt" | "expiresAt">, ttlMs?: number, rootUrl?: string): void;
|
|
70
|
+
export {};
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
let cachedDbPromise = null;
|
|
5
|
+
/**
|
|
6
|
+
* Registers an external L3 cache database store (e.g., Supabase Postgres in webcrawl.app cloud)
|
|
7
|
+
* when the engine is consumed as an installed npm package (`webcrawl/engine/cache`).
|
|
8
|
+
*/
|
|
9
|
+
export function registerExternalCacheDb(db) {
|
|
10
|
+
cachedDbPromise = Promise.resolve(db);
|
|
11
|
+
}
|
|
12
|
+
function getOptionalDb() {
|
|
13
|
+
if (!cachedDbPromise) {
|
|
14
|
+
const dbModulePath = "../db/index.js";
|
|
15
|
+
cachedDbPromise = import(/* @vite-ignore */ dbModulePath)
|
|
16
|
+
.then((mod) => mod?.db ?? null)
|
|
17
|
+
.catch(() => null);
|
|
18
|
+
}
|
|
19
|
+
return cachedDbPromise;
|
|
20
|
+
}
|
|
21
|
+
// 24-hour cache (L1 in-memory + L2 /tmp disk + L3 Postgres shared URL/website store)
|
|
22
|
+
export const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
23
|
+
const MAX_CACHE_ENTRIES = 2500;
|
|
24
|
+
const TMP_CACHE_DIR = "/tmp/webcrawl-cache-v1";
|
|
25
|
+
const pageCache = new Map();
|
|
26
|
+
const mapCache = new Map();
|
|
27
|
+
try {
|
|
28
|
+
fs.mkdirSync(TMP_CACHE_DIR, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// ignore if read-only filesystem
|
|
32
|
+
}
|
|
33
|
+
export function sha256(content) {
|
|
34
|
+
return crypto.createHash("sha256").update(content).digest("hex");
|
|
35
|
+
}
|
|
36
|
+
export function normalizeCacheUrl(rawUrl) {
|
|
37
|
+
try {
|
|
38
|
+
const u = new URL(rawUrl.trim());
|
|
39
|
+
u.hash = "";
|
|
40
|
+
for (const key of Array.from(u.searchParams.keys())) {
|
|
41
|
+
if (/^(utm_|fbclid|gclid|msclkid|_ga|_gl|ref$)/i.test(key)) {
|
|
42
|
+
u.searchParams.delete(key);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const normalizedUrl = u.toString().replace(/\/$/, "") || u.origin;
|
|
46
|
+
const domain = u.hostname.toLowerCase().replace(/^www\./, "");
|
|
47
|
+
return { normalizedUrl, domain };
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return {
|
|
51
|
+
normalizedUrl: rawUrl.trim(),
|
|
52
|
+
domain: "unknown",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function clearLocalCacheForTests() {
|
|
57
|
+
pageCache.clear();
|
|
58
|
+
mapCache.clear();
|
|
59
|
+
try {
|
|
60
|
+
if (fs.existsSync(TMP_CACHE_DIR)) {
|
|
61
|
+
for (const f of fs.readdirSync(TMP_CACHE_DIR)) {
|
|
62
|
+
try {
|
|
63
|
+
fs.unlinkSync(path.join(TMP_CACHE_DIR, f));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// ignore
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// ignore
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export function getCachedPage(url, retainImages) {
|
|
76
|
+
const { normalizedUrl } = normalizeCacheUrl(url);
|
|
77
|
+
const key = `${normalizedUrl}::${retainImages}`;
|
|
78
|
+
const entry = pageCache.get(key);
|
|
79
|
+
if (entry) {
|
|
80
|
+
if (Date.now() > entry.expiresAt) {
|
|
81
|
+
pageCache.delete(key);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
return { ...entry, cacheLayer: "memory" };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Check L2 /tmp disk cache for 24-hour persistence across warm/cold workers
|
|
88
|
+
try {
|
|
89
|
+
const diskPath = path.join(TMP_CACHE_DIR, `page_${sha256(key)}.json`);
|
|
90
|
+
if (fs.existsSync(diskPath)) {
|
|
91
|
+
const parsed = JSON.parse(fs.readFileSync(diskPath, "utf8"));
|
|
92
|
+
if (Date.now() <= parsed.expiresAt) {
|
|
93
|
+
pageCache.set(key, parsed);
|
|
94
|
+
return { ...parsed, cacheLayer: "disk" };
|
|
95
|
+
}
|
|
96
|
+
fs.unlinkSync(diskPath);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// ignore disk read errors
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* 3-tier read-through lookup: L1 Memory -> L2 /tmp Disk -> L3 Postgres `crawled_pages`.
|
|
106
|
+
* Promotes L3 Postgres hits back into L1 and L2 so repeat requests never re-crawl the target website.
|
|
107
|
+
*/
|
|
108
|
+
export async function getCachedPageAsync(url, retainImages) {
|
|
109
|
+
const localHit = getCachedPage(url, retainImages);
|
|
110
|
+
if (localHit) {
|
|
111
|
+
return localHit;
|
|
112
|
+
}
|
|
113
|
+
const { normalizedUrl } = normalizeCacheUrl(url);
|
|
114
|
+
const key = `${normalizedUrl}::${retainImages}`;
|
|
115
|
+
const urlHash = sha256(key);
|
|
116
|
+
try {
|
|
117
|
+
const db = await getOptionalDb();
|
|
118
|
+
const pgHit = await db?.getCrawledPage(urlHash);
|
|
119
|
+
if (pgHit) {
|
|
120
|
+
const cachedAt = new Date(pgHit.crawledAt).getTime();
|
|
121
|
+
const expiresAt = new Date(pgHit.expiresAt).getTime();
|
|
122
|
+
if (expiresAt > Date.now()) {
|
|
123
|
+
const promoted = {
|
|
124
|
+
title: pgHit.title,
|
|
125
|
+
markdown: pgHit.markdown,
|
|
126
|
+
links: pgHit.links,
|
|
127
|
+
structuredData: pgHit.structuredData ?? null,
|
|
128
|
+
statusCode: pgHit.statusCode,
|
|
129
|
+
contentHash: pgHit.contentHash,
|
|
130
|
+
etag: pgHit.etag,
|
|
131
|
+
lastModified: pgHit.lastModified,
|
|
132
|
+
cacheLayer: "postgres",
|
|
133
|
+
cachedAt,
|
|
134
|
+
expiresAt,
|
|
135
|
+
};
|
|
136
|
+
pageCache.set(key, promoted);
|
|
137
|
+
try {
|
|
138
|
+
const diskPath = path.join(TMP_CACHE_DIR, `page_${urlHash}.json`);
|
|
139
|
+
fs.writeFileSync(diskPath, JSON.stringify(promoted));
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// ignore
|
|
143
|
+
}
|
|
144
|
+
return promoted;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// ignore DB lookup errors
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
export function setCachedPage(url, retainImages, entry, ttlMs = CACHE_TTL_MS) {
|
|
154
|
+
const { normalizedUrl, domain } = normalizeCacheUrl(url);
|
|
155
|
+
const key = `${normalizedUrl}::${retainImages}`;
|
|
156
|
+
const urlHash = sha256(key);
|
|
157
|
+
if (pageCache.size >= MAX_CACHE_ENTRIES) {
|
|
158
|
+
const oldestKey = pageCache.keys().next().value;
|
|
159
|
+
if (oldestKey) {
|
|
160
|
+
pageCache.delete(oldestKey);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const now = Date.now();
|
|
164
|
+
const fullEntry = {
|
|
165
|
+
...entry,
|
|
166
|
+
cacheLayer: "memory",
|
|
167
|
+
cachedAt: now,
|
|
168
|
+
expiresAt: now + ttlMs,
|
|
169
|
+
};
|
|
170
|
+
pageCache.set(key, fullEntry);
|
|
171
|
+
try {
|
|
172
|
+
const diskPath = path.join(TMP_CACHE_DIR, `page_${urlHash}.json`);
|
|
173
|
+
fs.writeFileSync(diskPath, JSON.stringify(fullEntry));
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// ignore disk write errors
|
|
177
|
+
}
|
|
178
|
+
// Write-through to L3 Postgres `crawled_pages` store (when available)
|
|
179
|
+
void getOptionalDb()
|
|
180
|
+
.then((db) => db?.upsertCrawledPage({
|
|
181
|
+
urlHash,
|
|
182
|
+
normalizedUrl,
|
|
183
|
+
domain,
|
|
184
|
+
title: entry.title,
|
|
185
|
+
markdown: entry.markdown,
|
|
186
|
+
links: entry.links || [],
|
|
187
|
+
structuredData: entry.structuredData ?? null,
|
|
188
|
+
statusCode: entry.statusCode,
|
|
189
|
+
contentHash: entry.contentHash,
|
|
190
|
+
etag: entry.etag,
|
|
191
|
+
lastModified: entry.lastModified,
|
|
192
|
+
retainImages,
|
|
193
|
+
fetchCount: 1,
|
|
194
|
+
crawledAt: new Date(now).toISOString(),
|
|
195
|
+
expiresAt: new Date(now + ttlMs).toISOString(),
|
|
196
|
+
}))
|
|
197
|
+
.catch(() => { });
|
|
198
|
+
}
|
|
199
|
+
export function getCachedMap(cacheKey) {
|
|
200
|
+
const entry = mapCache.get(cacheKey);
|
|
201
|
+
if (entry) {
|
|
202
|
+
if (Date.now() > entry.expiresAt) {
|
|
203
|
+
mapCache.delete(cacheKey);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
return { ...entry, cacheLayer: "memory" };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
const diskPath = path.join(TMP_CACHE_DIR, `map_${sha256(cacheKey)}.json`);
|
|
211
|
+
if (fs.existsSync(diskPath)) {
|
|
212
|
+
const parsed = JSON.parse(fs.readFileSync(diskPath, "utf8"));
|
|
213
|
+
if (Date.now() <= parsed.expiresAt) {
|
|
214
|
+
mapCache.set(cacheKey, parsed);
|
|
215
|
+
return { ...parsed, cacheLayer: "disk" };
|
|
216
|
+
}
|
|
217
|
+
fs.unlinkSync(diskPath);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// ignore
|
|
222
|
+
}
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* 3-tier read-through lookup for website maps/crawls: L1 Memory -> L2 Disk -> L3 Postgres `crawled_sites`.
|
|
227
|
+
*/
|
|
228
|
+
export async function getCachedMapAsync(cacheKey) {
|
|
229
|
+
const localHit = getCachedMap(cacheKey);
|
|
230
|
+
if (localHit) {
|
|
231
|
+
return localHit;
|
|
232
|
+
}
|
|
233
|
+
const cacheKeyHash = sha256(cacheKey);
|
|
234
|
+
try {
|
|
235
|
+
const db = await getOptionalDb();
|
|
236
|
+
const pgHit = await db?.getCrawledSite(cacheKeyHash);
|
|
237
|
+
if (pgHit) {
|
|
238
|
+
const cachedAt = new Date(pgHit.crawledAt).getTime();
|
|
239
|
+
const expiresAt = new Date(pgHit.expiresAt).getTime();
|
|
240
|
+
if (expiresAt > Date.now()) {
|
|
241
|
+
const promoted = {
|
|
242
|
+
links: pgHit.links,
|
|
243
|
+
source: pgHit.source,
|
|
244
|
+
pagesCrawled: pgHit.pagesCrawled,
|
|
245
|
+
cacheLayer: "postgres",
|
|
246
|
+
cachedAt,
|
|
247
|
+
expiresAt,
|
|
248
|
+
};
|
|
249
|
+
mapCache.set(cacheKey, promoted);
|
|
250
|
+
try {
|
|
251
|
+
const diskPath = path.join(TMP_CACHE_DIR, `map_${cacheKeyHash}.json`);
|
|
252
|
+
fs.writeFileSync(diskPath, JSON.stringify(promoted));
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
// ignore
|
|
256
|
+
}
|
|
257
|
+
return promoted;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
// ignore
|
|
263
|
+
}
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
export function setCachedMap(cacheKey, entry, ttlMs = CACHE_TTL_MS, rootUrl) {
|
|
267
|
+
const now = Date.now();
|
|
268
|
+
const cacheKeyHash = sha256(cacheKey);
|
|
269
|
+
const fullEntry = {
|
|
270
|
+
...entry,
|
|
271
|
+
cacheLayer: "memory",
|
|
272
|
+
cachedAt: now,
|
|
273
|
+
expiresAt: now + ttlMs,
|
|
274
|
+
};
|
|
275
|
+
mapCache.set(cacheKey, fullEntry);
|
|
276
|
+
try {
|
|
277
|
+
const diskPath = path.join(TMP_CACHE_DIR, `map_${cacheKeyHash}.json`);
|
|
278
|
+
fs.writeFileSync(diskPath, JSON.stringify(fullEntry));
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
// ignore
|
|
282
|
+
}
|
|
283
|
+
const rawRoot = rootUrl || cacheKey.split("::")[0] || "";
|
|
284
|
+
const { normalizedUrl, domain } = normalizeCacheUrl(rawRoot);
|
|
285
|
+
void getOptionalDb()
|
|
286
|
+
.then((db) => db?.upsertCrawledSite({
|
|
287
|
+
cacheKeyHash,
|
|
288
|
+
rootUrl: normalizedUrl,
|
|
289
|
+
domain,
|
|
290
|
+
links: entry.links || [],
|
|
291
|
+
source: entry.source,
|
|
292
|
+
pagesCrawled: entry.pagesCrawled,
|
|
293
|
+
crawledAt: new Date(now).toISOString(),
|
|
294
|
+
expiresAt: new Date(now + ttlMs).toISOString(),
|
|
295
|
+
}))
|
|
296
|
+
.catch(() => { });
|
|
297
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path to a real installed Chrome/Chromium, or null.
|
|
3
|
+
* Priority: WEBCRAWL_CHROME_PATH, INTERACT_CHROME_PATH, CHROME_PATH, then per-OS
|
|
4
|
+
* defaults (same locations `webcrawl doctor` checks).
|
|
5
|
+
*/
|
|
6
|
+
export declare function installedChromePath(): string | null;
|
|
7
|
+
export declare function isLocalChromeAvailable(): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Render `url` in local Chrome and return the hydrated HTML, or null when
|
|
10
|
+
* Chrome is missing, times out, or produces nothing.
|
|
11
|
+
*/
|
|
12
|
+
export declare function renderViaLocalChrome(url: string, opts?: {
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
}): Promise<string | null>;
|