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.
Files changed (114) hide show
  1. package/dist/cli.js +3 -2
  2. package/dist/core/engine/cache.d.ts +70 -0
  3. package/dist/core/engine/cache.js +297 -0
  4. package/dist/core/engine/crawl-policy.d.ts +36 -0
  5. package/dist/core/engine/crawl-policy.js +134 -0
  6. package/dist/core/engine/crawler.d.ts +151 -0
  7. package/dist/core/engine/crawler.js +885 -0
  8. package/dist/core/engine/fetcher.d.ts +66 -0
  9. package/dist/core/engine/fetcher.js +185 -0
  10. package/dist/core/engine/index.d.ts +4 -0
  11. package/dist/core/engine/index.js +4 -0
  12. package/dist/core/engine/markdown.d.ts +33 -0
  13. package/dist/core/engine/markdown.js +334 -0
  14. package/dist/core/engine/path-scope.d.ts +21 -0
  15. package/dist/core/engine/path-scope.js +88 -0
  16. package/dist/core/engine/safe-url.d.ts +23 -0
  17. package/dist/core/engine/safe-url.js +92 -0
  18. package/dist/core/engine/stealth.d.ts +79 -0
  19. package/dist/core/engine/stealth.js +155 -0
  20. package/dist/core/engine.d.ts +1 -0
  21. package/dist/core/engine.js +1 -0
  22. package/dist/core/extract/business.d.ts +28 -0
  23. package/dist/core/extract/business.js +344 -0
  24. package/dist/core/extract/currency.d.ts +15 -0
  25. package/dist/core/extract/currency.js +259 -0
  26. package/dist/core/extract/cve-cache.d.ts +62 -0
  27. package/dist/core/extract/cve-cache.js +277 -0
  28. package/dist/core/extract/cve.d.ts +57 -0
  29. package/dist/core/extract/cve.js +397 -0
  30. package/dist/core/extract/dedupe-key.d.ts +12 -0
  31. package/dist/core/extract/dedupe-key.js +36 -0
  32. package/dist/core/extract/flights.d.ts +37 -0
  33. package/dist/core/extract/flights.js +373 -0
  34. package/dist/core/extract/hotels.d.ts +35 -0
  35. package/dist/core/extract/hotels.js +473 -0
  36. package/dist/core/extract/index.d.ts +8 -0
  37. package/dist/core/extract/index.js +8 -0
  38. package/dist/core/extract/real-estate.d.ts +53 -0
  39. package/dist/core/extract/real-estate.js +835 -0
  40. package/dist/core/extract/scaledown.d.ts +14 -0
  41. package/dist/core/extract/scaledown.js +484 -0
  42. package/dist/core/extract/shopping.d.ts +44 -0
  43. package/dist/core/extract/shopping.js +393 -0
  44. package/dist/core/image-url.d.ts +5 -0
  45. package/dist/core/image-url.js +13 -0
  46. package/dist/core/interact/driver.d.ts +49 -0
  47. package/dist/core/interact/driver.js +264 -0
  48. package/dist/core/interact/remote.d.ts +41 -0
  49. package/dist/core/interact/remote.js +133 -0
  50. package/dist/core/interact/spans.d.ts +46 -0
  51. package/dist/core/interact/spans.js +32 -0
  52. package/dist/core/interact/validate.d.ts +34 -0
  53. package/dist/core/interact/validate.js +135 -0
  54. package/dist/core/interact/vm.d.ts +42 -0
  55. package/dist/core/interact/vm.js +296 -0
  56. package/dist/core/local-engine.d.ts +35 -0
  57. package/dist/core/local-engine.js +367 -0
  58. package/dist/core/parse/detect.d.ts +29 -0
  59. package/dist/core/parse/detect.js +152 -0
  60. package/dist/core/parse/fetch.d.ts +20 -0
  61. package/dist/core/parse/fetch.js +39 -0
  62. package/dist/core/parse/image.d.ts +13 -0
  63. package/dist/core/parse/image.js +100 -0
  64. package/dist/core/parse/index.d.ts +53 -0
  65. package/dist/core/parse/index.js +153 -0
  66. package/dist/core/parse/limits.d.ts +12 -0
  67. package/dist/core/parse/limits.js +29 -0
  68. package/dist/core/parse/media.d.ts +42 -0
  69. package/dist/core/parse/media.js +238 -0
  70. package/dist/core/parse/merge.d.ts +19 -0
  71. package/dist/core/parse/merge.js +48 -0
  72. package/dist/core/parse/ocr.d.ts +25 -0
  73. package/dist/core/parse/ocr.js +121 -0
  74. package/dist/core/parse/office.d.ts +28 -0
  75. package/dist/core/parse/office.js +83 -0
  76. package/dist/core/parse/pdf.d.ts +27 -0
  77. package/dist/core/parse/pdf.js +156 -0
  78. package/dist/core/parse/types.d.ts +38 -0
  79. package/dist/core/parse/types.js +1 -0
  80. package/dist/core/parse/youtube.d.ts +30 -0
  81. package/dist/core/parse/youtube.js +264 -0
  82. package/dist/core/ranker/decision.d.ts +28 -0
  83. package/dist/core/ranker/decision.js +151 -0
  84. package/dist/core/ranker/index.d.ts +5 -0
  85. package/dist/core/ranker/index.js +361 -0
  86. package/dist/core/ranker/lexical.d.ts +82 -0
  87. package/dist/core/ranker/lexical.js +642 -0
  88. package/dist/core/ranker/types.d.ts +90 -0
  89. package/dist/core/ranker/types.js +1 -0
  90. package/dist/core/ranker.d.ts +1 -0
  91. package/dist/core/ranker.js +1 -0
  92. package/dist/core/sandbox.d.ts +29 -0
  93. package/dist/core/sandbox.js +182 -0
  94. package/dist/core/search/catalog.d.ts +174 -0
  95. package/dist/core/search/catalog.js +488 -0
  96. package/dist/core/search/index.d.ts +3 -0
  97. package/dist/core/search/index.js +3 -0
  98. package/dist/core/search/pipeline.d.ts +7 -0
  99. package/dist/core/search/pipeline.js +704 -0
  100. package/dist/core/search/providers.d.ts +62 -0
  101. package/dist/core/search/providers.js +1175 -0
  102. package/dist/core/search.d.ts +1 -0
  103. package/dist/core/search.js +1 -0
  104. package/dist/core/targets/developer.d.ts +44 -0
  105. package/dist/core/targets/developer.js +338 -0
  106. package/dist/core/targets/index.d.ts +3 -0
  107. package/dist/core/targets/index.js +3 -0
  108. package/dist/core/targets/registry.d.ts +32 -0
  109. package/dist/core/targets/registry.js +411 -0
  110. package/dist/core/targets/types.d.ts +72 -0
  111. package/dist/core/targets/types.js +8 -0
  112. package/dist/core/targets.d.ts +1 -0
  113. package/dist/core/targets.js +1 -0
  114. package/package.json +82 -4
@@ -0,0 +1,344 @@
1
+ import { extractDomainHost } from "./currency.js";
2
+ import { normalizeIdentity, resultDedupeKey } from "./dedupe-key.js";
3
+ import { resolveAbsoluteUrl } from "./real-estate.js";
4
+ const BUSINESS_SCHEMA_TYPES = new Set([
5
+ "localbusiness",
6
+ "organization",
7
+ "corporation",
8
+ "restaurant",
9
+ "foodestablishment",
10
+ "cafeorcoffeeshop",
11
+ "bakery",
12
+ "barorpub",
13
+ "store",
14
+ "autodealer",
15
+ "autorepair",
16
+ "medicalbusiness",
17
+ "dentist",
18
+ "physician",
19
+ "pharmacy",
20
+ "hospital",
21
+ "healthandbeautybusiness",
22
+ "dayspa",
23
+ "hairsalon",
24
+ "legalservice",
25
+ "attorney",
26
+ "accountingservice",
27
+ "financialservice",
28
+ "insuranceagency",
29
+ "realestateagent",
30
+ "homeandconstructionbusiness",
31
+ "plumber",
32
+ "electrician",
33
+ "hvacbusiness",
34
+ "generalcontractor",
35
+ "locksmith",
36
+ "movingcompany",
37
+ "professionalservice",
38
+ "employmentagency",
39
+ "travelagency",
40
+ "sportsactivitylocation",
41
+ "exerciseGym",
42
+ ]);
43
+ function isBusinessSchemaType(rawType) {
44
+ const clean = rawType.toLowerCase().trim();
45
+ if (BUSINESS_SCHEMA_TYPES.has(clean)) {
46
+ return true;
47
+ }
48
+ return (clean.endsWith("business") ||
49
+ clean.endsWith("store") ||
50
+ clean.endsWith("service") ||
51
+ clean.endsWith("shop") ||
52
+ clean.endsWith("clinic") ||
53
+ clean.endsWith("agency") ||
54
+ clean.endsWith("contractor"));
55
+ }
56
+ function parseAddressObject(addr) {
57
+ if (!addr) {
58
+ return {};
59
+ }
60
+ if (typeof addr === "string") {
61
+ return { address: addr.trim() };
62
+ }
63
+ if (typeof addr === "object") {
64
+ const a = addr;
65
+ const street = typeof a.streetAddress === "string"
66
+ ? a.streetAddress.trim()
67
+ : typeof a.address1 === "string"
68
+ ? a.address1.trim()
69
+ : "";
70
+ const city = typeof a.addressLocality === "string"
71
+ ? a.addressLocality.trim()
72
+ : typeof a.city === "string"
73
+ ? a.city.trim()
74
+ : "";
75
+ const state = typeof a.addressRegion === "string"
76
+ ? a.addressRegion.trim()
77
+ : typeof a.state === "string"
78
+ ? a.state.trim()
79
+ : "";
80
+ const postcode = typeof a.postalCode === "string" || typeof a.postalCode === "number"
81
+ ? String(a.postalCode).trim()
82
+ : typeof a.zipCode === "string"
83
+ ? a.zipCode.trim()
84
+ : "";
85
+ const country = typeof a.addressCountry === "string"
86
+ ? a.addressCountry.trim()
87
+ : typeof a.addressCountry
88
+ ?.name === "string"
89
+ ? String(a.addressCountry.name).trim()
90
+ : undefined;
91
+ const full = [street, city, state, postcode].filter(Boolean).join(", ");
92
+ return {
93
+ address: full ||
94
+ (typeof a.formattedAddress === "string"
95
+ ? a.formattedAddress
96
+ : undefined),
97
+ city: city || undefined,
98
+ state: state || undefined,
99
+ postcode: postcode || undefined,
100
+ country,
101
+ };
102
+ }
103
+ return {};
104
+ }
105
+ function walkBusinessJsonNode(node, pageUrl, portal, countryCode, extractedVia, out, depth = 0) {
106
+ if (depth > 12 || out.length >= 45 || !node || typeof node !== "object") {
107
+ return;
108
+ }
109
+ if (Array.isArray(node)) {
110
+ for (const item of node) {
111
+ walkBusinessJsonNode(item, pageUrl, portal, countryCode, extractedVia, out, depth + 1);
112
+ }
113
+ return;
114
+ }
115
+ const obj = node;
116
+ const rawTypeVal = Array.isArray(obj["@type"])
117
+ ? String(obj["@type"][0] || "")
118
+ : String(obj["@type"] || obj.type || "");
119
+ const name = typeof obj.name === "string"
120
+ ? obj.name.trim()
121
+ : typeof obj.businessName === "string"
122
+ ? obj.businessName.trim()
123
+ : typeof obj.companyName === "string"
124
+ ? obj.companyName.trim()
125
+ : "";
126
+ const phone = typeof obj.telephone === "string"
127
+ ? obj.telephone.trim()
128
+ : typeof obj.phone === "string"
129
+ ? obj.phone.trim()
130
+ : typeof obj.displayPhone === "string"
131
+ ? obj.displayPhone.trim()
132
+ : undefined;
133
+ const email = typeof obj.email === "string"
134
+ ? obj.email.replace(/^mailto:/i, "").trim()
135
+ : undefined;
136
+ const addrInfo = parseAddressObject(obj.address || obj.location);
137
+ const aggRating = obj.aggregateRating;
138
+ const ratingRaw = aggRating?.ratingValue ?? obj.rating ?? obj.averageRating;
139
+ const ratingNum = typeof ratingRaw === "number" || typeof ratingRaw === "string"
140
+ ? Number.parseFloat(String(ratingRaw))
141
+ : undefined;
142
+ const reviewCountRaw = aggRating?.reviewCount ??
143
+ aggRating?.ratingCount ??
144
+ obj.reviewCount ??
145
+ obj.numberOfReviews;
146
+ const reviewCountNum = typeof reviewCountRaw === "number" || typeof reviewCountRaw === "string"
147
+ ? Number.parseInt(String(reviewCountRaw).replace(/,/g, ""), 10)
148
+ : undefined;
149
+ const isGenericOrgOnly = /^(organization|corporation|brand|newsmediaorganization)$/i.test(rawTypeVal);
150
+ const hasBusinessSignals = (isBusinessSchemaType(rawTypeVal) &&
151
+ (!isGenericOrgOnly ||
152
+ Boolean(addrInfo.address || phone || ratingNum !== undefined))) ||
153
+ Boolean(name.length >= 2 &&
154
+ name.length <= 120 &&
155
+ (phone ||
156
+ (addrInfo.address && (ratingNum !== undefined || obj.priceRange)) ||
157
+ (obj.businessId !== undefined && addrInfo.address)));
158
+ // Skip generic site-level WebSite / SearchAction / BreadcrumbList nodes
159
+ if (hasBusinessSignals &&
160
+ name &&
161
+ !/^(website|webpage|breadcrumblist|searchaction|itemlist|faqpage)$/i.test(rawTypeVal)) {
162
+ const geoObj = obj.geo;
163
+ const lat = typeof geoObj?.latitude === "number"
164
+ ? geoObj.latitude
165
+ : typeof obj.latitude === "number"
166
+ ? obj.latitude
167
+ : undefined;
168
+ const lng = typeof geoObj?.longitude === "number"
169
+ ? geoObj.longitude
170
+ : typeof obj.longitude === "number"
171
+ ? obj.longitude
172
+ : undefined;
173
+ let category = typeof obj.category === "string"
174
+ ? obj.category
175
+ : Array.isArray(obj.categories) && typeof obj.categories[0] === "string"
176
+ ? obj.categories[0]
177
+ : typeof obj.servesCuisine === "string"
178
+ ? obj.servesCuisine
179
+ : rawTypeVal && !/^(organization|localbusiness)$/i.test(rawTypeVal)
180
+ ? rawTypeVal
181
+ : undefined;
182
+ if (Array.isArray(obj.categories) &&
183
+ obj.categories[0] &&
184
+ typeof obj.categories[0] === "object") {
185
+ const firstCat = obj.categories[0];
186
+ if (typeof firstCat.title === "string") {
187
+ category = firstCat.title;
188
+ }
189
+ else if (typeof firstCat.name === "string") {
190
+ category = firstCat.name;
191
+ }
192
+ }
193
+ let openingHours;
194
+ if (typeof obj.openingHours === "string") {
195
+ openingHours = obj.openingHours;
196
+ }
197
+ else if (Array.isArray(obj.openingHours)) {
198
+ openingHours = obj.openingHours
199
+ .filter((h) => typeof h === "string")
200
+ .slice(0, 7);
201
+ }
202
+ const foundingDate = typeof obj.foundingDate === "string" ||
203
+ typeof obj.foundingDate === "number"
204
+ ? String(obj.foundingDate)
205
+ : undefined;
206
+ const foundedMatch = foundingDate?.match(/\b(18\d{2}|19\d{2}|20\d{2})\b/);
207
+ const itemUrl = typeof obj.url === "string"
208
+ ? resolveAbsoluteUrl(obj.url, pageUrl)
209
+ : pageUrl;
210
+ if (addrInfo.address ||
211
+ phone ||
212
+ email ||
213
+ ratingNum !== undefined ||
214
+ typeof obj.description === "string") {
215
+ out.push({
216
+ name,
217
+ category,
218
+ description: typeof obj.description === "string"
219
+ ? obj.description.trim().slice(0, 280)
220
+ : undefined,
221
+ address: addrInfo.address,
222
+ city: addrInfo.city,
223
+ state: addrInfo.state,
224
+ postcode: addrInfo.postcode,
225
+ country: addrInfo.country || countryCode,
226
+ phone,
227
+ email,
228
+ website: itemUrl,
229
+ rating: ratingNum && ratingNum > 0 && ratingNum <= 10
230
+ ? Number(ratingNum.toFixed(1))
231
+ : undefined,
232
+ reviewCount: reviewCountNum &&
233
+ Number.isFinite(reviewCountNum) &&
234
+ reviewCountNum > 0
235
+ ? reviewCountNum
236
+ : undefined,
237
+ priceRange: typeof obj.priceRange === "string"
238
+ ? obj.priceRange.trim()
239
+ : undefined,
240
+ openingHours,
241
+ foundedYear: foundedMatch
242
+ ? Number.parseInt(foundedMatch[1], 10)
243
+ : undefined,
244
+ latitude: lat,
245
+ longitude: lng,
246
+ url: itemUrl,
247
+ sourceDomain: extractDomainHost(itemUrl) || portal,
248
+ extractedVia,
249
+ });
250
+ }
251
+ }
252
+ for (const val of Object.values(obj)) {
253
+ if (val && typeof val === "object") {
254
+ walkBusinessJsonNode(val, pageUrl, portal, countryCode, extractedVia, out, depth + 1);
255
+ }
256
+ }
257
+ }
258
+ export function extractBusinessListingsFromHtml(html, pageUrl, countryCode) {
259
+ const out = [];
260
+ const portal = extractDomainHost(pageUrl) || "unknown";
261
+ // Tier 1: Schema.org JSON-LD
262
+ const jsonLdRegex = /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
263
+ let match;
264
+ while ((match = jsonLdRegex.exec(html)) !== null) {
265
+ try {
266
+ const parsed = JSON.parse(match[1].trim());
267
+ walkBusinessJsonNode(parsed, pageUrl, portal, countryCode, "json-ld", out);
268
+ }
269
+ catch {
270
+ // Ignore malformed JSON-LD
271
+ }
272
+ }
273
+ // Tier 2: Embedded JSON state (__NEXT_DATA__, application/json)
274
+ if (out.length < 3) {
275
+ const stateRegex = /<script[^>]+(?:id=["']__NEXT_DATA__["']|type=["']application\/json["'])[^>]*>([\s\S]*?)<\/script>/gi;
276
+ while ((match = stateRegex.exec(html)) !== null) {
277
+ try {
278
+ const raw = match[1].trim();
279
+ if (raw.length > 20 && raw.length < 2_000_000) {
280
+ const parsed = JSON.parse(raw);
281
+ walkBusinessJsonNode(parsed, pageUrl, portal, countryCode, "json-state", out);
282
+ }
283
+ }
284
+ catch {
285
+ // Ignore
286
+ }
287
+ }
288
+ }
289
+ return dedupeBusinessListings(out);
290
+ }
291
+ export function extractBusinessListingFromSerpResult(title, description, url, countryCode) {
292
+ const combined = `${title} ${description}`;
293
+ const cleanName = title.replace(/\s*[|\-–\u2014•:]\s*.*$/, "").trim();
294
+ if (!cleanName ||
295
+ cleanName.length < 2 ||
296
+ /\b(the\s+\d+\s+best|best\s+\d+|top\s+\d+|best\s+.*\s+in\b|places?\s+to\b|where\s+to\b|guide\s+to\b|directory\b|yellow\s+pages|yelp|tripadvisor|timeout|broadsheet|urban\s+list)\b/i.test(cleanName)) {
297
+ return null;
298
+ }
299
+ const phoneMatch = combined.match(/(?:\+?\d{1,3}[\s.-]?)?\(?\d{2,4}\)?[\s.-]\d{3,4}[\s.-]\d{3,4}\b/);
300
+ const ratingMatch = combined.match(/\b([1-5]\.\d)\s*(?:\/\s*5|stars?|★)/i) ||
301
+ combined.match(/\brating[:\s]+([1-5]\.\d)\b/i);
302
+ const reviewMatch = combined.match(/\b(\d[\d,]{0,6})\s+reviews?\b/i);
303
+ const priceRangeMatch = combined.match(/(?:^|\s)(\${1,4})(?:\s|[·•,]|$)/);
304
+ const addrMatch = description.match(/\b(\d{1,5}\s+[A-Z][a-zA-Z0-9\s.]{2,30}\s+(?:St|Street|Rd|Road|Ave|Avenue|Blvd|Boulevard|Dr|Drive|Ln|Lane|Way|Pl|Place|Pde|Parade|Hwy|Highway)(?:,\s*[A-Za-z\s]+)?)/);
305
+ // Require at least one concrete business attribute (address, phone, or rating) in the SERP snippet
306
+ if (!addrMatch && !phoneMatch && !ratingMatch) {
307
+ return null;
308
+ }
309
+ const sourceDomain = extractDomainHost(url) || "unknown";
310
+ return {
311
+ name: cleanName,
312
+ description: description.trim().slice(0, 240) || undefined,
313
+ address: addrMatch ? addrMatch[1].trim() : undefined,
314
+ country: countryCode,
315
+ phone: phoneMatch ? phoneMatch[0].trim() : undefined,
316
+ website: url,
317
+ rating: ratingMatch ? Number.parseFloat(ratingMatch[1]) : undefined,
318
+ reviewCount: reviewMatch
319
+ ? Number.parseInt(reviewMatch[1].replace(/,/g, ""), 10)
320
+ : undefined,
321
+ priceRange: priceRangeMatch ? priceRangeMatch[1] : undefined,
322
+ url,
323
+ sourceDomain,
324
+ extractedVia: "serp-snippet",
325
+ };
326
+ }
327
+ export function dedupeBusinessListings(businesses, maxItems = 30) {
328
+ const seen = new Set();
329
+ const out = [];
330
+ for (const b of businesses) {
331
+ const normName = normalizeIdentity(b.name);
332
+ // Same name on different directory pages / URLs is kept; only exact repeats dropped.
333
+ const key = resultDedupeKey(b.url || b.website, normName);
334
+ if (!normName || normName.length < 2 || seen.has(key)) {
335
+ continue;
336
+ }
337
+ seen.add(key);
338
+ out.push(b);
339
+ if (out.length >= maxItems) {
340
+ break;
341
+ }
342
+ }
343
+ return out;
344
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Derive ISO 4217 currency code from a raw price string and/or 2-letter ISO country code.
3
+ * Uses standard currency symbols and ISO country conventions without hardcoded retailer lists.
4
+ */
5
+ export declare function resolveCurrencyCode(rawPriceOrCurrency?: string, countryCode?: string): string;
6
+ /**
7
+ * Parse a numeric price and currency from a human price string such as
8
+ * "$360.41", "A$399.00", "£173.49", "1.299,00 €", "₹1,19,900", "₹1.45 Crores", "S$429", "7.490.000 ₫".
9
+ */
10
+ export declare function parsePriceString(raw: string | number | undefined, countryCode?: string, explicitCurrency?: string, maxAllowedPrice?: number): {
11
+ price: number;
12
+ rawPrice: string;
13
+ currency: string;
14
+ } | null;
15
+ export declare function extractDomainHost(url: string): string;
@@ -0,0 +1,259 @@
1
+ const VALID_ISO_CURRENCIES = new Set([
2
+ "USD",
3
+ "AUD",
4
+ "GBP",
5
+ "EUR",
6
+ "CAD",
7
+ "NZD",
8
+ "SGD",
9
+ "HKD",
10
+ "JPY",
11
+ "CNY",
12
+ "INR",
13
+ "CHF",
14
+ "SEK",
15
+ "NOK",
16
+ "DKK",
17
+ "ZAR",
18
+ "BRL",
19
+ "MXN",
20
+ "AED",
21
+ "SAR",
22
+ "THB",
23
+ "MYR",
24
+ "IDR",
25
+ "PHP",
26
+ "VND",
27
+ "KRW",
28
+ "PLN",
29
+ "TRY",
30
+ "TWD",
31
+ ]);
32
+ /**
33
+ * Derive ISO 4217 currency code from a raw price string and/or 2-letter ISO country code.
34
+ * Uses standard currency symbols and ISO country conventions without hardcoded retailer lists.
35
+ */
36
+ export function resolveCurrencyCode(rawPriceOrCurrency, countryCode) {
37
+ const s = (rawPriceOrCurrency || "").trim();
38
+ const isoMatches = s.match(/\b([A-Z]{3})\b/g);
39
+ if (isoMatches) {
40
+ for (const code of isoMatches) {
41
+ if (VALID_ISO_CURRENCIES.has(code)) {
42
+ return code;
43
+ }
44
+ }
45
+ }
46
+ if (/A\$|AU\$/i.test(s)) {
47
+ return "AUD";
48
+ }
49
+ if (/NZ\$/i.test(s)) {
50
+ return "NZD";
51
+ }
52
+ if (/CA\$|C\$/i.test(s)) {
53
+ return "CAD";
54
+ }
55
+ if (/S\$|SG\$/i.test(s)) {
56
+ return "SGD";
57
+ }
58
+ if (/HK\$/i.test(s)) {
59
+ return "HKD";
60
+ }
61
+ if (/NT\$/i.test(s)) {
62
+ return "TWD";
63
+ }
64
+ if (s.includes("£")) {
65
+ return "GBP";
66
+ }
67
+ if (s.includes("€")) {
68
+ return "EUR";
69
+ }
70
+ if (s.includes("¥")) {
71
+ return (countryCode || "").toUpperCase() === "CN" ? "CNY" : "JPY";
72
+ }
73
+ if (s.includes("₹") ||
74
+ /\bRs\.?(?:\s|\d)/i.test(s) ||
75
+ /\b(?:crores?|cr|lakhs?|lacs?)\b/i.test(s)) {
76
+ return "INR";
77
+ }
78
+ if (s.includes("₫") || /\b(?:đồng|vnd|triệu|tỷ)\b/i.test(s)) {
79
+ return "VND";
80
+ }
81
+ if (s.includes("฿")) {
82
+ return "THB";
83
+ }
84
+ if (s.includes("₱")) {
85
+ return "PHP";
86
+ }
87
+ if (s.includes("₩")) {
88
+ return "KRW";
89
+ }
90
+ if (/\bzł\b/i.test(s)) {
91
+ return "PLN";
92
+ }
93
+ if (/\bRM\s*\d/i.test(s)) {
94
+ return "MYR";
95
+ }
96
+ if (/\bRp\.?\s*\d/i.test(s)) {
97
+ return "IDR";
98
+ }
99
+ const cc = (countryCode || "").trim().toUpperCase();
100
+ // Bare "$" in non-dollar countries (e.g. EU, GB, IN, VN, JP) indicates USD pricing
101
+ if (s.includes("$") &&
102
+ !["US", "AU", "CA", "NZ", "SG", "HK", "TW", "MX", ""].includes(cc)) {
103
+ return "USD";
104
+ }
105
+ const COUNTRY_TO_CURRENCY = {
106
+ AU: "AUD",
107
+ GB: "GBP",
108
+ UK: "GBP",
109
+ CA: "CAD",
110
+ NZ: "NZD",
111
+ SG: "SGD",
112
+ IN: "INR",
113
+ VN: "VND",
114
+ JP: "JPY",
115
+ CN: "CNY",
116
+ HK: "HKD",
117
+ TW: "TWD",
118
+ KR: "KRW",
119
+ TH: "THB",
120
+ MY: "MYR",
121
+ ID: "IDR",
122
+ PH: "PHP",
123
+ CH: "CHF",
124
+ SE: "SEK",
125
+ NO: "NOK",
126
+ DK: "DKK",
127
+ PL: "PLN",
128
+ CZ: "CZK",
129
+ HU: "HUF",
130
+ RO: "RON",
131
+ TR: "TRY",
132
+ AE: "AED",
133
+ SA: "SAR",
134
+ ZA: "ZAR",
135
+ BR: "BRL",
136
+ MX: "MXN",
137
+ };
138
+ if (COUNTRY_TO_CURRENCY[cc]) {
139
+ return COUNTRY_TO_CURRENCY[cc];
140
+ }
141
+ if ([
142
+ "DE",
143
+ "FR",
144
+ "IT",
145
+ "ES",
146
+ "NL",
147
+ "IE",
148
+ "AT",
149
+ "BE",
150
+ "PT",
151
+ "FI",
152
+ "GR",
153
+ "LU",
154
+ "SK",
155
+ "SI",
156
+ "EE",
157
+ "LV",
158
+ "LT",
159
+ "HR",
160
+ "CY",
161
+ "MT",
162
+ "EU",
163
+ ].includes(cc)) {
164
+ return "EUR";
165
+ }
166
+ return "USD";
167
+ }
168
+ /**
169
+ * Parse a numeric price and currency from a human price string such as
170
+ * "$360.41", "A$399.00", "£173.49", "1.299,00 €", "₹1,19,900", "₹1.45 Crores", "S$429", "7.490.000 ₫".
171
+ */
172
+ export function parsePriceString(raw, countryCode, explicitCurrency, maxAllowedPrice = 1_000_000) {
173
+ if (typeof raw === "number") {
174
+ if (!Number.isFinite(raw) || raw <= 0) {
175
+ return null;
176
+ }
177
+ const numCurrency = resolveCurrencyCode(explicitCurrency, countryCode);
178
+ return {
179
+ price: Number(raw.toFixed(2)),
180
+ rawPrice: `${numCurrency} ${raw.toFixed(2)}`,
181
+ currency: numCurrency,
182
+ };
183
+ }
184
+ if (!raw || typeof raw !== "string") {
185
+ return null;
186
+ }
187
+ const cleaned = raw.replace(/\s+/g, " ").trim();
188
+ if (!cleaned) {
189
+ return null;
190
+ }
191
+ const currency = resolveCurrencyCode(explicitCurrency || cleaned, countryCode);
192
+ // High-denomination currencies (VND, IDR, KRW, JPY, INR) allow much higher nominal numeric caps
193
+ const highDenomMultiplier = currency === "VND" || currency === "IDR"
194
+ ? 25_000
195
+ : currency === "KRW" || currency === "JPY" || currency === "INR"
196
+ ? 500
197
+ : 1;
198
+ const effectiveMaxPrice = maxAllowedPrice * highDenomMultiplier;
199
+ // Handle Indian Crore / Lakh and Vietnamese tỷ / triệu multipliers
200
+ const unitMatch = cleaned.match(/(\d[\d,.]*)\s*(crores?|cr|lakhs?|lacs?|tỷ|triệu)\b/i);
201
+ if (unitMatch) {
202
+ const baseNum = Number.parseFloat(unitMatch[1].replace(/,/g, ""));
203
+ const unit = unitMatch[2].toLowerCase();
204
+ const mult = unit.startsWith("cr")
205
+ ? 10_000_000
206
+ : unit.startsWith("la")
207
+ ? 100_000
208
+ : unit === "tỷ"
209
+ ? 1_000_000_000
210
+ : 1_000_000;
211
+ const total = Math.round(baseNum * mult);
212
+ if (Number.isFinite(total) && total > 0 && total <= effectiveMaxPrice) {
213
+ return {
214
+ price: total,
215
+ rawPrice: cleaned,
216
+ currency,
217
+ };
218
+ }
219
+ }
220
+ const numMatch = cleaned.match(/(\d[\d,.]*)/);
221
+ if (!numMatch) {
222
+ return null;
223
+ }
224
+ let numStr = numMatch[1].replace(/[.,]$/, "");
225
+ // Handle multi-dot thousands (e.g. "7.490.000" in VND/EUR/IDR) and European decimal comma ("194,99" or "1.299,00")
226
+ if (/^\d{1,3}(?:\.\d{3}){2,}$/.test(numStr)) {
227
+ numStr = numStr.replace(/\./g, "");
228
+ }
229
+ else if (/^\d{1,3}\.\d{3}$/.test(numStr) &&
230
+ (currency === "VND" || currency === "IDR" || currency === "EUR")) {
231
+ numStr = numStr.replace(/\./g, "");
232
+ }
233
+ else if (/\.\d{3},\d{1,2}$/.test(numStr)) {
234
+ numStr = numStr.replace(/\./g, "").replace(",", ".");
235
+ }
236
+ else if (/^\d+,\d{1,2}$/.test(numStr)) {
237
+ numStr = numStr.replace(",", ".");
238
+ }
239
+ else {
240
+ numStr = numStr.replace(/,/g, "");
241
+ }
242
+ const value = Number.parseFloat(numStr);
243
+ if (!Number.isFinite(value) || value <= 0 || value > effectiveMaxPrice) {
244
+ return null;
245
+ }
246
+ return {
247
+ price: Number(value.toFixed(2)),
248
+ rawPrice: cleaned,
249
+ currency,
250
+ };
251
+ }
252
+ export function extractDomainHost(url) {
253
+ try {
254
+ return new URL(url).hostname.replace(/^www\./i, "").toLowerCase();
255
+ }
256
+ catch {
257
+ return "";
258
+ }
259
+ }
@@ -0,0 +1,62 @@
1
+ import { type CveRecord } from "./cve.js";
2
+ /** Only CVEs published (or numbered) in this year or later are stored. */
3
+ export declare const CVE_STORE_SINCE = 2020;
4
+ /** A cached keyword result is trusted for this long before NVD is asked again. */
5
+ export declare const QUERY_TTL_MS: number;
6
+ export interface CachedCves {
7
+ records: CveRecord[];
8
+ /** Ids served from cache that are due a background refresh. */
9
+ stale: string[];
10
+ }
11
+ export interface CveCacheStore {
12
+ get(ids: string[]): Promise<CachedCves>;
13
+ getQuery(keyword: string): Promise<string[] | null>;
14
+ putQuery(keyword: string, ids: string[]): Promise<void>;
15
+ search(keyword: string, limit: number): Promise<CveRecord[]>;
16
+ upsert(records: CveRecord[]): Promise<number>;
17
+ }
18
+ /** True when a record falls inside the stored window (published year, else id year). */
19
+ export declare function isCacheableCve(r: CveRecord): boolean;
20
+ export declare function isStale(fetchedAt: Date, published: string | undefined, now?: number): boolean;
21
+ export declare function cveQueryKey(keyword: string): string;
22
+ /** One row of the batched upsert; exported so the SQL shape is unit-testable. */
23
+ export declare function cveRow(r: CveRecord): unknown[];
24
+ /**
25
+ * INSERT … ON CONFLICT for `n` rows. An existing row is only replaced when the
26
+ * incoming record is at least as new (`modified`) and actually differs, so cache
27
+ * hits written back unchanged cost no row churn.
28
+ */
29
+ export declare function buildUpsertSql(n: number): string;
30
+ interface PgPoolLike {
31
+ query(sql: string, params?: unknown[]): Promise<{
32
+ rows: Array<Record<string, any>>;
33
+ rowCount?: number | null;
34
+ }>;
35
+ }
36
+ /**
37
+ * Registers an external Postgres pool & schema (e.g., Supabase Postgres in webcrawl.app cloud)
38
+ * when the engine is consumed as an installed npm package (`webcrawl/extract/cve-cache`).
39
+ */
40
+ export declare function registerExternalCveDb(mods: {
41
+ getPgPool?: () => Promise<PgPoolLike | null>;
42
+ CVE_SCHEMA_SQL?: string;
43
+ } | null): void;
44
+ /** Swap the backing store (tests). Pass null to restore Postgres. */
45
+ export declare function setCveCacheStoreForTests(store: CveCacheStore | null): void;
46
+ /** Cached records for exact ids (CVE or alias such as GHSA). Never throws. */
47
+ export declare function getCachedCves(ids: string[]): Promise<CachedCves>;
48
+ /** Ids NVD returned for this keyword within QUERY_TTL_MS, or null. Never throws. */
49
+ export declare function getCachedQuery(keyword: string): Promise<string[] | null>;
50
+ export declare function rememberQuery(keyword: string, ids: string[]): void;
51
+ /** Full-text search over cached rows; the fallback when NVD is down or rate limited. */
52
+ export declare function searchCachedCves(keyword: string, limit: number): Promise<CveRecord[]>;
53
+ /** Store records in the window; resolves to rows written (0 on any failure). */
54
+ export declare function upsertCves(records: CveRecord[]): Promise<number>;
55
+ /** Background write that outlives the response on Vercel (no-op elsewhere). */
56
+ export declare function runInBackground(p: Promise<unknown>): void;
57
+ /**
58
+ * Background refresh of stale ids: fetch live, merge over the cached copy (keeps
59
+ * OSV fixed versions the live NVD record lacks), write back.
60
+ */
61
+ export declare function refreshStaleCves(stale: string[], cached: CveRecord[], fetchLive: (ids: string[]) => Promise<CveRecord[]>): void;
62
+ export {};