webcrawl 1.2.4 → 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
|
@@ -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 {};
|