webcrawl 1.2.3 → 1.2.5
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/cli.js +3 -2
- package/dist/core/engine/cache.d.ts +70 -0
- package/dist/core/engine/cache.js +297 -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 +151 -0
- package/dist/core/engine/crawler.js +885 -0
- package/dist/core/engine/fetcher.d.ts +66 -0
- package/dist/core/engine/fetcher.js +185 -0
- package/dist/core/engine/index.d.ts +4 -0
- package/dist/core/engine/index.js +4 -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/safe-url.d.ts +23 -0
- package/dist/core/engine/safe-url.js +92 -0
- package/dist/core/engine/stealth.d.ts +79 -0
- package/dist/core/engine/stealth.js +155 -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 +8 -0
- package/dist/core/extract/index.js +8 -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/image-url.d.ts +5 -0
- package/dist/core/image-url.js +13 -0
- package/dist/core/interact/driver.d.ts +49 -0
- package/dist/core/interact/driver.js +264 -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 +42 -0
- package/dist/core/interact/vm.js +296 -0
- package/dist/core/local-engine.d.ts +35 -0
- package/dist/core/local-engine.js +367 -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 +642 -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 +182 -0
- package/dist/core/search/catalog.d.ts +174 -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 +704 -0
- package/dist/core/search/providers.d.ts +62 -0
- package/dist/core/search/providers.js +1175 -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/package.json +82 -4
package/dist/cli.js
CHANGED
|
@@ -241,8 +241,9 @@ async function resolveBackendClient(cfg, deps) {
|
|
|
241
241
|
});
|
|
242
242
|
}
|
|
243
243
|
try {
|
|
244
|
-
const
|
|
245
|
-
const
|
|
244
|
+
const packagedSpec = ["./core/local-engine.js"].join("");
|
|
245
|
+
const fallbackSpec = ["..", "..", "..", "src", "local-engine.js"].join("/");
|
|
246
|
+
const localMod = (await import(packagedSpec).catch(() => import(fallbackSpec)));
|
|
246
247
|
return localMod.createLocalEngineClient();
|
|
247
248
|
}
|
|
248
249
|
catch {
|
|
@@ -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,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crawl politeness helpers: robots.txt rules, adaptive concurrency (AIMD) and retries.
|
|
3
|
+
* Pure and dependency-free so they are easy to test.
|
|
4
|
+
*/
|
|
5
|
+
export interface RobotsRules {
|
|
6
|
+
allows(url: string): boolean;
|
|
7
|
+
/** Crawl-delay in ms for our agent (or `*`), 0 when unset. */
|
|
8
|
+
crawlDelayMs: number;
|
|
9
|
+
}
|
|
10
|
+
/** Parses robots.txt. Our own group wins over `*`; longest matching rule wins; Allow beats Disallow on ties. */
|
|
11
|
+
export declare function parseRobots(text: string | null | undefined): RobotsRules;
|
|
12
|
+
/** Additive-increase / multiplicative-decrease concurrency controller. */
|
|
13
|
+
export declare class AdaptiveConcurrency {
|
|
14
|
+
private readonly min;
|
|
15
|
+
private readonly max;
|
|
16
|
+
private target;
|
|
17
|
+
constructor(min?: number, max?: number, initial?: number);
|
|
18
|
+
get current(): number;
|
|
19
|
+
/** Feed the outcome of a finished wave. */
|
|
20
|
+
record(outcome: {
|
|
21
|
+
ok: number;
|
|
22
|
+
failed: number;
|
|
23
|
+
throttled: boolean;
|
|
24
|
+
}): number;
|
|
25
|
+
}
|
|
26
|
+
export declare const sleep: (ms: number) => Promise<void>;
|
|
27
|
+
export declare function backoffDelayMs(attempt: number, baseMs?: number, capMs?: number, rand?: () => number): number;
|
|
28
|
+
export declare function isRetryableStatus(status: number): boolean;
|
|
29
|
+
/** Retries transient failures (network errors, 408/425/429/5xx) with jittered exponential backoff. */
|
|
30
|
+
export declare function withRetries<T extends {
|
|
31
|
+
status: number;
|
|
32
|
+
}>(run: () => Promise<T>, opts?: {
|
|
33
|
+
retries?: number;
|
|
34
|
+
baseMs?: number;
|
|
35
|
+
sleepFn?: (ms: number) => Promise<void>;
|
|
36
|
+
}): Promise<T>;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crawl politeness helpers: robots.txt rules, adaptive concurrency (AIMD) and retries.
|
|
3
|
+
* Pure and dependency-free so they are easy to test.
|
|
4
|
+
*/
|
|
5
|
+
const ROBOT_TOKEN = "webcrawl";
|
|
6
|
+
function patternToRegExp(path) {
|
|
7
|
+
const anchored = path.endsWith("$");
|
|
8
|
+
const body = (anchored ? path.slice(0, -1) : path)
|
|
9
|
+
.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
|
|
10
|
+
.replace(/\*/g, ".*");
|
|
11
|
+
return new RegExp(`^${body}${anchored ? "$" : ""}`);
|
|
12
|
+
}
|
|
13
|
+
/** Parses robots.txt. Our own group wins over `*`; longest matching rule wins; Allow beats Disallow on ties. */
|
|
14
|
+
export function parseRobots(text) {
|
|
15
|
+
const groups = [];
|
|
16
|
+
let current = null;
|
|
17
|
+
let lastWasAgent = false;
|
|
18
|
+
for (const rawLine of (text ?? "").split(/\r?\n/)) {
|
|
19
|
+
const line = rawLine.replace(/#.*$/, "").trim();
|
|
20
|
+
if (!line)
|
|
21
|
+
continue;
|
|
22
|
+
const idx = line.indexOf(":");
|
|
23
|
+
if (idx < 0)
|
|
24
|
+
continue;
|
|
25
|
+
const key = line.slice(0, idx).trim().toLowerCase();
|
|
26
|
+
const value = line.slice(idx + 1).trim();
|
|
27
|
+
if (key === "user-agent") {
|
|
28
|
+
if (!current || !lastWasAgent) {
|
|
29
|
+
current = { agents: [], rules: [], delay: 0 };
|
|
30
|
+
groups.push(current);
|
|
31
|
+
}
|
|
32
|
+
current.agents.push(value.toLowerCase());
|
|
33
|
+
lastWasAgent = true;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
lastWasAgent = false;
|
|
37
|
+
if (!current)
|
|
38
|
+
continue;
|
|
39
|
+
if (key === "disallow" || key === "allow") {
|
|
40
|
+
if (!value && key === "disallow")
|
|
41
|
+
continue; // empty Disallow = allow all
|
|
42
|
+
current.rules.push({
|
|
43
|
+
allow: key === "allow",
|
|
44
|
+
pattern: patternToRegExp(value),
|
|
45
|
+
length: value.length,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
else if (key === "crawl-delay") {
|
|
49
|
+
const n = Number(value);
|
|
50
|
+
if (Number.isFinite(n) && n > 0)
|
|
51
|
+
current.delay = Math.min(n, 10) * 1000;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const mine = groups.filter((g) => g.agents.some((a) => a !== "*" && ROBOT_TOKEN.includes(a)));
|
|
55
|
+
const chosen = mine.length ? mine : groups.filter((g) => g.agents.includes("*"));
|
|
56
|
+
const rules = chosen.flatMap((g) => g.rules);
|
|
57
|
+
const crawlDelayMs = Math.max(0, ...chosen.map((g) => g.delay));
|
|
58
|
+
return {
|
|
59
|
+
crawlDelayMs,
|
|
60
|
+
allows(url) {
|
|
61
|
+
let path;
|
|
62
|
+
try {
|
|
63
|
+
const u = new URL(url);
|
|
64
|
+
path = u.pathname + u.search;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
let best = null;
|
|
70
|
+
for (const r of rules) {
|
|
71
|
+
if (!r.pattern.test(path))
|
|
72
|
+
continue;
|
|
73
|
+
if (!best || r.length > best.length || (r.length === best.length && r.allow))
|
|
74
|
+
best = r;
|
|
75
|
+
}
|
|
76
|
+
return best ? best.allow : true;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** Additive-increase / multiplicative-decrease concurrency controller. */
|
|
81
|
+
export class AdaptiveConcurrency {
|
|
82
|
+
min;
|
|
83
|
+
max;
|
|
84
|
+
target;
|
|
85
|
+
constructor(min = 1, max = 16, initial = 4) {
|
|
86
|
+
this.min = min;
|
|
87
|
+
this.max = max;
|
|
88
|
+
this.target = Math.min(Math.max(initial, min), max);
|
|
89
|
+
}
|
|
90
|
+
get current() {
|
|
91
|
+
return this.target;
|
|
92
|
+
}
|
|
93
|
+
/** Feed the outcome of a finished wave. */
|
|
94
|
+
record(outcome) {
|
|
95
|
+
const total = outcome.ok + outcome.failed;
|
|
96
|
+
if (outcome.throttled || (total > 0 && outcome.failed / total > 0.3)) {
|
|
97
|
+
this.target = Math.max(this.min, Math.floor(this.target / 2));
|
|
98
|
+
}
|
|
99
|
+
else if (outcome.ok > 0) {
|
|
100
|
+
this.target = Math.min(this.max, this.target + 1);
|
|
101
|
+
}
|
|
102
|
+
return this.target;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
106
|
+
export function backoffDelayMs(attempt, baseMs = 250, capMs = 4000, rand = Math.random) {
|
|
107
|
+
const exp = Math.min(capMs, baseMs * 2 ** attempt);
|
|
108
|
+
return Math.round(exp / 2 + rand() * (exp / 2));
|
|
109
|
+
}
|
|
110
|
+
export function isRetryableStatus(status) {
|
|
111
|
+
return status === 408 || status === 425 || status === 429 || status >= 500;
|
|
112
|
+
}
|
|
113
|
+
/** Retries transient failures (network errors, 408/425/429/5xx) with jittered exponential backoff. */
|
|
114
|
+
export async function withRetries(run, opts = {}) {
|
|
115
|
+
const retries = opts.retries ?? 2;
|
|
116
|
+
const wait = opts.sleepFn ?? sleep;
|
|
117
|
+
let lastErr;
|
|
118
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
119
|
+
try {
|
|
120
|
+
const res = await run();
|
|
121
|
+
if (!isRetryableStatus(res.status) || attempt === retries)
|
|
122
|
+
return res;
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
if (err?.name === "UnsafeUrlError")
|
|
126
|
+
throw err;
|
|
127
|
+
lastErr = err;
|
|
128
|
+
if (attempt === retries)
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
await wait(backoffDelayMs(attempt, opts.baseMs));
|
|
132
|
+
}
|
|
133
|
+
throw lastErr;
|
|
134
|
+
}
|