@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.
Files changed (131) hide show
  1. package/README.md +1 -1
  2. package/dist/bin.js +5 -7
  3. package/dist/core/engine/awswaf.d.ts +59 -0
  4. package/dist/core/engine/awswaf.js +164 -0
  5. package/dist/core/engine/cache.d.ts +70 -0
  6. package/dist/core/engine/cache.js +297 -0
  7. package/dist/core/engine/chrome.d.ts +14 -0
  8. package/dist/core/engine/chrome.js +140 -0
  9. package/dist/core/engine/crawl-policy.d.ts +36 -0
  10. package/dist/core/engine/crawl-policy.js +134 -0
  11. package/dist/core/engine/crawler.d.ts +152 -0
  12. package/dist/core/engine/crawler.js +918 -0
  13. package/dist/core/engine/fetcher.d.ts +92 -0
  14. package/dist/core/engine/fetcher.js +494 -0
  15. package/dist/core/engine/index.d.ts +7 -0
  16. package/dist/core/engine/index.js +7 -0
  17. package/dist/core/engine/markdown.d.ts +33 -0
  18. package/dist/core/engine/markdown.js +334 -0
  19. package/dist/core/engine/path-scope.d.ts +21 -0
  20. package/dist/core/engine/path-scope.js +88 -0
  21. package/dist/core/engine/render.d.ts +41 -0
  22. package/dist/core/engine/render.js +138 -0
  23. package/dist/core/engine/safe-url.d.ts +29 -0
  24. package/dist/core/engine/safe-url.js +109 -0
  25. package/dist/core/engine/session.d.ts +93 -0
  26. package/dist/core/engine/session.js +250 -0
  27. package/dist/core/engine/stealth.d.ts +83 -0
  28. package/dist/core/engine/stealth.js +222 -0
  29. package/dist/core/engine.d.ts +1 -0
  30. package/dist/core/engine.js +1 -0
  31. package/dist/core/extract/business.d.ts +28 -0
  32. package/dist/core/extract/business.js +344 -0
  33. package/dist/core/extract/currency.d.ts +15 -0
  34. package/dist/core/extract/currency.js +259 -0
  35. package/dist/core/extract/cve-cache.d.ts +62 -0
  36. package/dist/core/extract/cve-cache.js +277 -0
  37. package/dist/core/extract/cve.d.ts +57 -0
  38. package/dist/core/extract/cve.js +397 -0
  39. package/dist/core/extract/dedupe-key.d.ts +12 -0
  40. package/dist/core/extract/dedupe-key.js +36 -0
  41. package/dist/core/extract/flights.d.ts +37 -0
  42. package/dist/core/extract/flights.js +373 -0
  43. package/dist/core/extract/hotels.d.ts +35 -0
  44. package/dist/core/extract/hotels.js +473 -0
  45. package/dist/core/extract/index.d.ts +9 -0
  46. package/dist/core/extract/index.js +9 -0
  47. package/dist/core/extract/real-estate.d.ts +53 -0
  48. package/dist/core/extract/real-estate.js +835 -0
  49. package/dist/core/extract/scaledown.d.ts +14 -0
  50. package/dist/core/extract/scaledown.js +484 -0
  51. package/dist/core/extract/shopping.d.ts +44 -0
  52. package/dist/core/extract/shopping.js +393 -0
  53. package/dist/core/extract/structured.d.ts +14 -0
  54. package/dist/core/extract/structured.js +144 -0
  55. package/dist/core/image-url.d.ts +5 -0
  56. package/dist/core/image-url.js +13 -0
  57. package/dist/core/interact/driver.d.ts +72 -0
  58. package/dist/core/interact/driver.js +384 -0
  59. package/dist/core/interact/remote.d.ts +41 -0
  60. package/dist/core/interact/remote.js +133 -0
  61. package/dist/core/interact/spans.d.ts +46 -0
  62. package/dist/core/interact/spans.js +32 -0
  63. package/dist/core/interact/validate.d.ts +34 -0
  64. package/dist/core/interact/validate.js +135 -0
  65. package/dist/core/interact/vm.d.ts +43 -0
  66. package/dist/core/interact/vm.js +338 -0
  67. package/dist/core/local-engine.d.ts +56 -0
  68. package/dist/core/local-engine.js +667 -0
  69. package/dist/core/parse/detect.d.ts +29 -0
  70. package/dist/core/parse/detect.js +152 -0
  71. package/dist/core/parse/fetch.d.ts +20 -0
  72. package/dist/core/parse/fetch.js +39 -0
  73. package/dist/core/parse/image.d.ts +13 -0
  74. package/dist/core/parse/image.js +100 -0
  75. package/dist/core/parse/index.d.ts +53 -0
  76. package/dist/core/parse/index.js +153 -0
  77. package/dist/core/parse/limits.d.ts +12 -0
  78. package/dist/core/parse/limits.js +29 -0
  79. package/dist/core/parse/media.d.ts +42 -0
  80. package/dist/core/parse/media.js +238 -0
  81. package/dist/core/parse/merge.d.ts +19 -0
  82. package/dist/core/parse/merge.js +48 -0
  83. package/dist/core/parse/ocr.d.ts +25 -0
  84. package/dist/core/parse/ocr.js +121 -0
  85. package/dist/core/parse/office.d.ts +28 -0
  86. package/dist/core/parse/office.js +83 -0
  87. package/dist/core/parse/pdf.d.ts +27 -0
  88. package/dist/core/parse/pdf.js +156 -0
  89. package/dist/core/parse/types.d.ts +38 -0
  90. package/dist/core/parse/types.js +1 -0
  91. package/dist/core/parse/youtube.d.ts +30 -0
  92. package/dist/core/parse/youtube.js +264 -0
  93. package/dist/core/ranker/decision.d.ts +28 -0
  94. package/dist/core/ranker/decision.js +151 -0
  95. package/dist/core/ranker/index.d.ts +5 -0
  96. package/dist/core/ranker/index.js +361 -0
  97. package/dist/core/ranker/lexical.d.ts +82 -0
  98. package/dist/core/ranker/lexical.js +643 -0
  99. package/dist/core/ranker/types.d.ts +90 -0
  100. package/dist/core/ranker/types.js +1 -0
  101. package/dist/core/ranker.d.ts +1 -0
  102. package/dist/core/ranker.js +1 -0
  103. package/dist/core/sandbox.d.ts +29 -0
  104. package/dist/core/sandbox.js +222 -0
  105. package/dist/core/search/catalog.d.ts +176 -0
  106. package/dist/core/search/catalog.js +488 -0
  107. package/dist/core/search/index.d.ts +3 -0
  108. package/dist/core/search/index.js +3 -0
  109. package/dist/core/search/pipeline.d.ts +7 -0
  110. package/dist/core/search/pipeline.js +719 -0
  111. package/dist/core/search/providers.d.ts +72 -0
  112. package/dist/core/search/providers.js +1270 -0
  113. package/dist/core/search.d.ts +1 -0
  114. package/dist/core/search.js +1 -0
  115. package/dist/core/targets/developer.d.ts +44 -0
  116. package/dist/core/targets/developer.js +338 -0
  117. package/dist/core/targets/index.d.ts +3 -0
  118. package/dist/core/targets/index.js +3 -0
  119. package/dist/core/targets/registry.d.ts +32 -0
  120. package/dist/core/targets/registry.js +411 -0
  121. package/dist/core/targets/types.d.ts +72 -0
  122. package/dist/core/targets/types.js +8 -0
  123. package/dist/core/targets.d.ts +1 -0
  124. package/dist/core/targets.js +1 -0
  125. package/dist/format.js +5 -1
  126. package/dist/index.d.ts +1 -0
  127. package/dist/index.js +1 -0
  128. package/dist/load-local-engine.d.ts +7 -0
  129. package/dist/load-local-engine.js +22 -0
  130. package/dist/server.js +3 -0
  131. package/package.json +11 -3
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Stealth mode: read pages a bot check refuses to plain HTTP clients.
3
+ *
4
+ * One real Chromium session (the interact browser: Vercel Sandbox in prod,
5
+ * local Playwright in dev) opens the entry page, waits until the check clears,
6
+ * then reads every requested URL with that cleared session. Raw documents
7
+ * (robots.txt, sitemaps) are read with fetch() from inside the page so they
8
+ * carry the same cookies, IP and TLS fingerprint as the browser.
9
+ *
10
+ * Never fabricates content: a page still showing a check comes back `blocked`.
11
+ */
12
+ import { isAntiBotChallenge } from "./fetcher.js";
13
+ import { setDomainCookie, setDomainUserAgent } from "./fetcher.js";
14
+ import { cookieHeaderFrom, getWarmSession, saveWarmSession, WARM_TTL_MS } from "./session.js";
15
+ import { remoteStealthProvider } from "../interact/remote.js";
16
+ /**
17
+ * True when a real browser can run here: our own (sandbox in prod, local
18
+ * Playwright in dev) or a remote provider. A remote browser still needs the
19
+ * sandbox or local Playwright to run the step script against it.
20
+ */
21
+ export async function stealthAvailable() {
22
+ if (process.env.WEBCRAWL_STEALTH === "false")
23
+ return false;
24
+ const { interactBrowserAvailable } = await import("../interact/driver.js");
25
+ return interactBrowserAvailable();
26
+ }
27
+ /** True when this fetched document is a bot check, not the real content. */
28
+ export function isBlockedResponse(status, body) {
29
+ return isAntiBotChallenge(body ?? "", status ?? 200);
30
+ }
31
+ /**
32
+ * Opens `entryUrl` in a real browser, waits for the bot check to clear, then
33
+ * reads `items` in order through that session. One browser per call.
34
+ */
35
+ export async function stealthFetch(entryUrl, items, opts = {}) {
36
+ const t0 = Date.now();
37
+ if (!(await stealthAvailable())) {
38
+ return { solved: false, entry: null, docs: [], durationMs: 0, error: "Stealth browser not configured" };
39
+ }
40
+ const { startBrowser, runBrowserStep, stopBrowser } = await import("../interact/driver.js");
41
+ const provider = opts.remote === false ? null : remoteStealthProvider();
42
+ const domain = hostOf(entryUrl);
43
+ const proxyUrl = opts.proxy ?? null;
44
+ let proxyUser = "";
45
+ let proxyPass = "";
46
+ if (proxyUrl) {
47
+ try {
48
+ const parsed = new URL(proxyUrl);
49
+ proxyUser = decodeURIComponent(parsed.username);
50
+ proxyPass = decodeURIComponent(parsed.password);
51
+ }
52
+ catch {
53
+ /* bad proxy URL: browser launch will surface the error */
54
+ }
55
+ }
56
+ // A warm session for this domain means the challenge is already solved: short
57
+ // solve budget. A cold solve needs the full budget. Proxy runs never reuse
58
+ // warm sessions (their cookies were earned from a datacenter egress IP).
59
+ const hadWarm = provider || proxyUrl ? null : await getWarmSession(domain).catch(() => null);
60
+ const tag = provider ? { provider } : { warm: Boolean(hadWarm) };
61
+ let handle = null;
62
+ // Filled in by `finally` once the provider reports the session cost.
63
+ const out = {};
64
+ let capturedCookies = [];
65
+ let capturedUa = "";
66
+ try {
67
+ const budgetMs = opts.budgetMs ?? 90_000;
68
+ handle = await startBrowser({
69
+ timeoutMs: Math.min(5 * 60_000, budgetMs + 90_000),
70
+ remote: provider,
71
+ domain: provider || proxyUrl ? undefined : domain,
72
+ proxy: proxyUrl ?? undefined,
73
+ });
74
+ const res = await runBrowserStep(handle, {
75
+ type: "fetchMany",
76
+ entry: entryUrl,
77
+ items: items.slice(0, 40),
78
+ budgetMs,
79
+ solveMs: hadWarm ? Math.min(opts.solveMs ?? 20_000, 8_000) : (opts.solveMs ?? 20_000),
80
+ followSitemaps: opts.followSitemaps ?? 0,
81
+ ...(proxyUser ? { proxyUser, proxyPass, proxyByteCap: 6_000_000 } : {}),
82
+ });
83
+ if (!res.ok) {
84
+ out.result = { solved: false, entry: null, docs: [], durationMs: 0, error: res.error || "Stealth browser step failed", ...tag };
85
+ }
86
+ else {
87
+ const value = (res.data?.value ?? {});
88
+ capturedCookies = Array.isArray(value.cookies) ? value.cookies : [];
89
+ capturedUa = value.userAgent ?? "";
90
+ if (capturedUa)
91
+ setDomainUserAgent(domain, capturedUa);
92
+ if (capturedCookies.length > 0) {
93
+ setDomainCookie(domain, cookieHeaderFrom(capturedCookies));
94
+ }
95
+ out.result = {
96
+ solved: Boolean(value.solved),
97
+ entry: value.entry ?? null,
98
+ docs: Array.isArray(value.docs) ? value.docs : [],
99
+ durationMs: 0,
100
+ ...tag,
101
+ };
102
+ }
103
+ }
104
+ catch (err) {
105
+ out.result = { solved: false, entry: null, docs: [], durationMs: 0, error: err instanceof Error ? err.message : String(err), ...tag };
106
+ }
107
+ finally {
108
+ const solved = out.result?.solved === true;
109
+ const keepWarm = !provider && solved && (opts.keepWarm ?? true) && Boolean(handle && handle.kind === "sandbox" && capturedUa);
110
+ const cost = handle
111
+ ? await stopBrowser(handle, {
112
+ keepWarm,
113
+ domain,
114
+ userAgent: capturedUa,
115
+ cookieHeader: capturedCookies.length > 0 ? cookieHeaderFrom(capturedCookies) : "",
116
+ }).catch(() => null)
117
+ : null;
118
+ if (out.result && cost)
119
+ out.result.providerCostUsd = cost.costUsd;
120
+ if (!keepWarm && solved && capturedCookies.length > 0) {
121
+ // Even without a warm sandbox, remember the solved cookies for replay on
122
+ // the lightweight tier (UA-paired; rejection there just escalates).
123
+ await saveWarmSession({ domain, sandboxId: "", userAgent: capturedUa, cookieHeader: cookieHeaderFrom(capturedCookies), expiresAt: Date.now() + WARM_TTL_MS })
124
+ .catch(() => { });
125
+ }
126
+ console.info("[stealth] session result", {
127
+ domain,
128
+ solved,
129
+ warm: Boolean(hadWarm),
130
+ keepWarm,
131
+ cookieCount: capturedCookies.length,
132
+ durationMs: Date.now() - t0,
133
+ });
134
+ }
135
+ const result = out.result;
136
+ result.durationMs = Date.now() - t0;
137
+ if (provider) {
138
+ console.info("[stealth] remote session", {
139
+ provider,
140
+ solved: result.solved,
141
+ docs: result.docs.length,
142
+ costUsd: result.providerCostUsd,
143
+ durationMs: result.durationMs,
144
+ error: result.error,
145
+ });
146
+ }
147
+ return result;
148
+ }
149
+ /** Hostname of a URL, or "" when unparseable. */
150
+ function hostOf(url) {
151
+ try {
152
+ return new URL(url).hostname;
153
+ }
154
+ catch {
155
+ return "";
156
+ }
157
+ }
158
+ /** Extracts `<loc>` entries from a sitemap or sitemap index. */
159
+ export function parseSitemapLocs(xml) {
160
+ const pages = [];
161
+ const sitemaps = [];
162
+ const isIndex = /<sitemapindex[\s>]/i.test(xml);
163
+ for (const m of xml.matchAll(/<loc>\s*(?:<!\[CDATA\[)?([^<\]]+?)(?:\]\]>)?\s*<\/loc>/gi)) {
164
+ const loc = m[1]
165
+ .trim()
166
+ .replace(/&amp;/g, "&")
167
+ .replace(/&lt;/g, "<")
168
+ .replace(/&gt;/g, ">")
169
+ .replace(/&quot;/g, '"')
170
+ .replace(/&#39;|&apos;/g, "'");
171
+ if (isIndex || /\.xml(\.gz)?(\?|$)/i.test(loc))
172
+ sitemaps.push(loc);
173
+ else
174
+ pages.push(loc);
175
+ }
176
+ return { pages, sitemaps };
177
+ }
178
+ /** Sitemap URLs declared in robots.txt. */
179
+ export function sitemapsFromRobots(robots) {
180
+ return Array.from(robots.matchAll(/^\s*Sitemap:\s*(\S+)/gim)).map((m) => m[1].trim());
181
+ }
182
+ /**
183
+ * Finds a blocked site's pages through one stealth session: robots.txt and the
184
+ * usual sitemap paths, following robots-declared and child sitemaps in the same
185
+ * session. Also returns the rendered entry page so its links count when there
186
+ * is no sitemap.
187
+ */
188
+ export async function stealthDiscover(entryUrl, opts = {}) {
189
+ const origin = new URL(entryUrl).origin;
190
+ const first = await stealthFetch(entryUrl, [
191
+ ...(opts.sitemapUrls ?? []).map((url) => ({ url, raw: true })),
192
+ { url: `${origin}/robots.txt`, raw: true },
193
+ { url: `${origin}/sitemap.xml`, raw: true },
194
+ { url: `${origin}/sitemap_index.xml`, raw: true },
195
+ { url: `${origin}/sitemap-index.xml`, raw: true },
196
+ ], { budgetMs: opts.budgetMs ?? 75_000, followSitemaps: 12, remote: opts.remote });
197
+ const { pages, sitemapsRead } = collectSitemapPages(first.docs);
198
+ return {
199
+ solved: first.solved,
200
+ sitemapPages: pages,
201
+ sitemapsRead,
202
+ entry: first.entry,
203
+ durationMs: first.durationMs,
204
+ ...(first.error ? { error: first.error } : {}),
205
+ ...(first.providerCostUsd !== undefined ? { providerCostUsd: first.providerCostUsd } : {}),
206
+ };
207
+ }
208
+ /** Page URLs from every readable sitemap among `docs` (robots.txt and blocked docs are skipped). */
209
+ export function collectSitemapPages(docs) {
210
+ const pages = new Set();
211
+ const sitemapsRead = [];
212
+ for (const doc of docs) {
213
+ if (!doc.ok || !doc.body || doc.blocked || (doc.status ?? 200) >= 400)
214
+ continue;
215
+ if (/\/robots\.txt(\?|$)/i.test(doc.url) || !doc.body.includes("<loc>"))
216
+ continue;
217
+ sitemapsRead.push(doc.url);
218
+ for (const u of parseSitemapLocs(doc.body).pages)
219
+ pages.add(u);
220
+ }
221
+ return { pages: Array.from(pages), sitemapsRead };
222
+ }
@@ -0,0 +1 @@
1
+ export * from "./engine/index.js";
@@ -0,0 +1 @@
1
+ export * from "./engine/index.js";
@@ -0,0 +1,28 @@
1
+ export interface BusinessListing {
2
+ name: string;
3
+ category?: string;
4
+ description?: string;
5
+ address?: string;
6
+ city?: string;
7
+ state?: string;
8
+ postcode?: string;
9
+ country?: string;
10
+ phone?: string;
11
+ email?: string;
12
+ website?: string;
13
+ rating?: number;
14
+ reviewCount?: number;
15
+ priceRange?: string;
16
+ openingHours?: string | string[];
17
+ foundedYear?: number;
18
+ employeeCount?: string;
19
+ headquarters?: string;
20
+ latitude?: number;
21
+ longitude?: number;
22
+ url: string;
23
+ sourceDomain: string;
24
+ extractedVia: "json-state" | "rsc-payload" | "json-ld" | "scaledown-extract-1" | "serp-snippet";
25
+ }
26
+ export declare function extractBusinessListingsFromHtml(html: string, pageUrl: string, countryCode?: string): BusinessListing[];
27
+ export declare function extractBusinessListingFromSerpResult(title: string, description: string, url: string, countryCode?: string): BusinessListing | null;
28
+ export declare function dedupeBusinessListings(businesses: BusinessListing[], maxItems?: number): BusinessListing[];
@@ -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;