@webcrawl/mcp 1.4.1 → 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 +8 -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 +7 -0
  131. package/package.json +11 -3
@@ -0,0 +1,835 @@
1
+ import { extractDomainHost, parsePriceString, resolveCurrencyCode, } from "./currency.js";
2
+ import { resultDedupeKey } from "./dedupe-key.js";
3
+ export function inferListingType(rawUrl, rawStatus, rawPriceText) {
4
+ const combined = `${rawUrl} ${rawStatus || ""} ${rawPriceText || ""}`.toLowerCase();
5
+ if (/\b(sold|recently[\s_-]sold)\b/.test(combined) ||
6
+ /\/(sold|recently_sold)\//.test(rawUrl.toLowerCase())) {
7
+ return "sold";
8
+ }
9
+ if (/\b(for[\s_-]rent|to[\s_-]rent|rental|lease|per\s*week|\/wk|\bpw\b|\bpcm\b|\/mo)\b/.test(combined) ||
10
+ /\/(rent|for-rent|for_rent|to-rent)\//.test(rawUrl.toLowerCase())) {
11
+ return "rent";
12
+ }
13
+ if (/\b(for[\s_-]sale|sale|buy|auction|offers|guide)\b/.test(combined) ||
14
+ /\/(sale|for-sale|for_sale|buy|property-for-sale)\//.test(rawUrl.toLowerCase())) {
15
+ return "sale";
16
+ }
17
+ return "unknown";
18
+ }
19
+ export function inferPricePeriod(rawPriceText, listingType) {
20
+ const lower = rawPriceText.toLowerCase();
21
+ if (/\b(pw|p\/w|per\s*week|\/wk|\/week|weekly)\b/.test(lower)) {
22
+ return "week";
23
+ }
24
+ if (/\b(pcm|p\/m|per\s*month|\/mo|\/month|monthly)\b/.test(lower)) {
25
+ return "month";
26
+ }
27
+ if (/\b(pa|p\.a\.|per\s*annum|\/yr|\/year|yearly)\b/.test(lower)) {
28
+ return "year";
29
+ }
30
+ return listingType === "rent" ? "week" : "total";
31
+ }
32
+ export function parsePropertyPrice(rawPriceText, countryCode, explicitCurrency) {
33
+ const defaultCurrency = resolveCurrencyCode(explicitCurrency, countryCode);
34
+ if (typeof rawPriceText === "number" &&
35
+ Number.isFinite(rawPriceText) &&
36
+ rawPriceText > 0) {
37
+ return {
38
+ price: Math.round(rawPriceText),
39
+ priceText: `${defaultCurrency} ${Math.round(rawPriceText).toLocaleString("en-US")}`,
40
+ currency: defaultCurrency,
41
+ };
42
+ }
43
+ const cleaned = String(rawPriceText || "")
44
+ .replace(/\$\$/g, "$")
45
+ .replace(/\s+/g, " ")
46
+ .trim();
47
+ if (!cleaned) {
48
+ return { priceText: "Contact Agent", currency: defaultCurrency };
49
+ }
50
+ // Handle shorthand like "$880k" or "$1.45m"
51
+ const shortMatch = cleaned.match(/(?:A\$|AU\$|US\$|\$|£|€)\s*(\d+(?:\.\d+)?)\s*([km])\b/i);
52
+ if (shortMatch) {
53
+ const mult = shortMatch[2].toLowerCase() === "m" ? 1_000_000 : 1_000;
54
+ const val = Math.round(Number.parseFloat(shortMatch[1]) * mult);
55
+ const symMatch = cleaned.match(/A\$|AU\$|US\$|\$|£|€/);
56
+ const currency = resolveCurrencyCode(explicitCurrency || (symMatch ? symMatch[0] : undefined), countryCode);
57
+ return {
58
+ price: val,
59
+ priceText: cleaned,
60
+ currency,
61
+ };
62
+ }
63
+ const fullMatch = parsePriceString(cleaned, countryCode, explicitCurrency, 250_000_000);
64
+ if (fullMatch && fullMatch.price > 10) {
65
+ return {
66
+ price: fullMatch.price,
67
+ priceText: cleaned,
68
+ currency: fullMatch.currency,
69
+ };
70
+ }
71
+ return {
72
+ priceText: cleaned,
73
+ currency: defaultCurrency,
74
+ };
75
+ }
76
+ export function resolveAbsoluteUrl(href, baseUrl) {
77
+ if (!href) {
78
+ return baseUrl;
79
+ }
80
+ try {
81
+ return new URL(href, baseUrl).toString();
82
+ }
83
+ catch {
84
+ return baseUrl;
85
+ }
86
+ }
87
+ /**
88
+ * Generic recursive JSON-tree walker that discovers property listing objects inside any
89
+ * SSR hydration state (`__NEXT_DATA__`, `<script type="application/json">`, Apollo/Relay state)
90
+ * without hardcoding any portal domain or site-specific JSON path.
91
+ */
92
+ function extractPropertyListingsFromJsonTree(root, pageUrl, portal, countryCode) {
93
+ const results = [];
94
+ const visited = new Set();
95
+ const pickString = (obj, keys) => {
96
+ for (const k of keys) {
97
+ const val = obj[k];
98
+ if (typeof val === "string" && val.trim().length > 0) {
99
+ return val.trim();
100
+ }
101
+ if (typeof val === "number" && Number.isFinite(val)) {
102
+ return String(val);
103
+ }
104
+ if (val && typeof val === "object" && !Array.isArray(val)) {
105
+ const nested = val.value ??
106
+ val.formattedValue;
107
+ if (typeof nested === "string" && nested.trim().length > 0) {
108
+ return nested.trim();
109
+ }
110
+ }
111
+ }
112
+ return undefined;
113
+ };
114
+ const pickNumber = (obj, keys) => {
115
+ for (const k of keys) {
116
+ const val = obj[k];
117
+ if (typeof val === "number" && Number.isFinite(val) && val >= 0) {
118
+ return val;
119
+ }
120
+ if (typeof val === "string" && /^\d+(?:\.\d+)?$/.test(val.trim())) {
121
+ return Number.parseFloat(val.trim());
122
+ }
123
+ if (val && typeof val === "object" && !Array.isArray(val)) {
124
+ const nestedVal = val.value;
125
+ if (typeof nestedVal === "number" &&
126
+ Number.isFinite(nestedVal) &&
127
+ nestedVal >= 0) {
128
+ return nestedVal;
129
+ }
130
+ const fmt = val.formattedValue;
131
+ if (typeof fmt === "string") {
132
+ const m = fmt.match(/(\d+)/);
133
+ if (m) {
134
+ return Number.parseInt(m[1], 10);
135
+ }
136
+ }
137
+ }
138
+ }
139
+ return undefined;
140
+ };
141
+ const tryConvertCandidateObject = (node) => {
142
+ const locObj = typeof node.location === "object" && node.location !== null
143
+ ? node.location
144
+ : typeof node.address === "object" && node.address !== null
145
+ ? node.address
146
+ : undefined;
147
+ const priceObj = typeof node.price === "object" && node.price !== null
148
+ ? node.price
149
+ : undefined;
150
+ const featuresObj = typeof node.features === "object" && node.features !== null
151
+ ? node.features
152
+ : undefined;
153
+ const beds = pickNumber(node, [
154
+ "bedrooms",
155
+ "beds",
156
+ "bedroomCount",
157
+ "numberOfBedrooms",
158
+ "bed",
159
+ ]) ??
160
+ (featuresObj
161
+ ? pickNumber(featuresObj, ["bedrooms", "beds", "bed"])
162
+ : undefined);
163
+ const baths = pickNumber(node, [
164
+ "bathrooms",
165
+ "baths",
166
+ "bathroomCount",
167
+ "numberOfBathroomsTotal",
168
+ "bath",
169
+ ]) ??
170
+ (featuresObj
171
+ ? pickNumber(featuresObj, ["bathrooms", "baths", "bath"])
172
+ : undefined);
173
+ const parking = pickNumber(node, [
174
+ "carparks",
175
+ "parking",
176
+ "carSpaces",
177
+ "garages",
178
+ "cars",
179
+ "parkingSpaces",
180
+ ]) ??
181
+ (featuresObj
182
+ ? pickNumber(featuresObj, ["carparks", "parking", "cars", "garages"])
183
+ : undefined);
184
+ const hasPriceSignal = node.price !== undefined ||
185
+ node.priceText !== undefined ||
186
+ node.priceFrom !== undefined ||
187
+ node.rentPerWeek !== undefined ||
188
+ node.listPrice !== undefined ||
189
+ node.markerPrice !== undefined ||
190
+ node.displayPrice !== undefined;
191
+ if (beds === undefined && baths === undefined && !hasPriceSignal) {
192
+ return null;
193
+ }
194
+ const directAddress = pickString(node, [
195
+ "displayAddress",
196
+ "propertyAddress",
197
+ "fullAddress",
198
+ "formattedAddress",
199
+ "streetAddress",
200
+ "addressText",
201
+ ]) ||
202
+ (typeof node.address === "string" ? node.address.trim() : undefined) ||
203
+ (locObj
204
+ ? pickString(locObj, [
205
+ "displayAddress",
206
+ "fullLocation",
207
+ "formattedLocation",
208
+ "formattedAddress",
209
+ "streetAddress",
210
+ "address",
211
+ ])
212
+ : undefined);
213
+ const unitNum = pickString(node, ["unitNumber", "aptNumber", "flatNumber"]);
214
+ const streetNum = pickString(node, ["streetNumber", "houseNumber"]);
215
+ const streetName = pickString(node, ["streetName", "street", "thoroughfare"]) ||
216
+ (locObj ? pickString(locObj, ["streetName", "street"]) : undefined);
217
+ const composedStreet = [
218
+ unitNum ? `${unitNum}/${streetNum || ""}`.replace(/\/$/, "") : streetNum,
219
+ streetName,
220
+ ]
221
+ .filter(Boolean)
222
+ .join(" ")
223
+ .trim();
224
+ const suburb = pickString(node, [
225
+ "suburbName",
226
+ "suburb",
227
+ "city",
228
+ "locality",
229
+ "addressLocality",
230
+ "town",
231
+ ]) ||
232
+ (locObj
233
+ ? pickString(locObj, [
234
+ "suburbName",
235
+ "suburb",
236
+ "city",
237
+ "locality",
238
+ "addressLocality",
239
+ "neighborhood",
240
+ ])
241
+ : undefined);
242
+ const state = pickString(node, [
243
+ "state",
244
+ "stateCode",
245
+ "region",
246
+ "addressRegion",
247
+ "province",
248
+ ]) ||
249
+ (locObj
250
+ ? pickString(locObj, ["state", "stateCode", "region", "addressRegion"])
251
+ : undefined);
252
+ const postcode = pickString(node, ["postcode", "postalCode", "zipCode", "zip"]) ||
253
+ (locObj
254
+ ? pickString(locObj, ["postcode", "postalCode", "zipCode", "zip"])
255
+ : undefined);
256
+ const addressParts = [
257
+ directAddress || composedStreet,
258
+ suburb &&
259
+ (!directAddress ||
260
+ !directAddress.toLowerCase().includes(suburb.toLowerCase()))
261
+ ? suburb
262
+ : undefined,
263
+ [
264
+ state &&
265
+ (!directAddress ||
266
+ !directAddress.toLowerCase().includes(state.toLowerCase()))
267
+ ? state
268
+ : undefined,
269
+ postcode && (!directAddress || !directAddress.includes(postcode))
270
+ ? postcode
271
+ : undefined,
272
+ ]
273
+ .filter(Boolean)
274
+ .join(" "),
275
+ ].filter(Boolean);
276
+ const address = addressParts.join(", ").replace(/\s+/g, " ").trim();
277
+ if (!address || address.length < 5 || /^https?:\/\//i.test(address)) {
278
+ return null;
279
+ }
280
+ if (beds === undefined && baths === undefined && !/\d/.test(address)) {
281
+ return null;
282
+ }
283
+ const displayPrices = Array.isArray(priceObj?.displayPrices)
284
+ ? priceObj.displayPrices
285
+ : [];
286
+ const rawPriceText = pickString(node, [
287
+ "priceText",
288
+ "displayPrice",
289
+ "formattedPrice",
290
+ "priceLabel",
291
+ ]) ||
292
+ (priceObj
293
+ ? pickString(priceObj, [
294
+ "formattedPrice",
295
+ "displayPrice",
296
+ "text",
297
+ "label",
298
+ ])
299
+ : undefined) ||
300
+ (displayPrices[0]
301
+ ? pickString(displayPrices[0], ["displayPrice"])
302
+ : undefined) ||
303
+ (typeof node.price === "string" ? node.price.trim() : undefined);
304
+ const rawPriceNum = pickNumber(node, [
305
+ "price",
306
+ "priceFrom",
307
+ "markerPrice",
308
+ "listPrice",
309
+ "rentPerWeek",
310
+ "amount",
311
+ ]) ??
312
+ (priceObj
313
+ ? pickNumber(priceObj, ["amount", "price", "value", "minPrice"])
314
+ : undefined);
315
+ const explicitCurrency = pickString(node, ["currency", "currencyCode", "priceCurrency"]) ||
316
+ (priceObj
317
+ ? pickString(priceObj, ["currencyCode", "currency"])
318
+ : undefined);
319
+ const parsedPrice = parsePropertyPrice(rawPriceText ?? rawPriceNum, countryCode, explicitCurrency);
320
+ const rawHref = pickString(node, [
321
+ "url",
322
+ "propertyUrl",
323
+ "listingDetailLink",
324
+ "propertyDetailsRoutePath",
325
+ "detailUrl",
326
+ "canonicalUrl",
327
+ "permalink",
328
+ "slug",
329
+ ]) || (locObj ? pickString(locObj, ["url", "propertyUrl"]) : undefined);
330
+ const detailUrl = resolveAbsoluteUrl(rawHref && !rawHref.startsWith("http") && !rawHref.startsWith("/")
331
+ ? `/${rawHref}`
332
+ : rawHref, pageUrl);
333
+ const statusHint = pickString(node, [
334
+ "saleMethod",
335
+ "status",
336
+ "standardStatus",
337
+ "displayStatus",
338
+ "currentStatus",
339
+ "transactionType",
340
+ "listingType",
341
+ "channel",
342
+ ]);
343
+ const listingType = inferListingType(`${detailUrl} ${pageUrl}`, statusHint, parsedPrice.priceText);
344
+ const propTypesArr = Array.isArray(node.propertyTypes)
345
+ ? node.propertyTypes.map((x) => String(x))
346
+ : [];
347
+ const rawPropType = propTypesArr[0] ||
348
+ pickString(node, [
349
+ "propertyType",
350
+ "propertySubType",
351
+ "primaryPropertyType",
352
+ "propertyTypeValue",
353
+ "homeType",
354
+ "buildingType",
355
+ ]);
356
+ const propertyType = rawPropType
357
+ ? rawPropType.replace(/_/g, " ")
358
+ : undefined;
359
+ const agencyObj = typeof node.agency === "object" && node.agency !== null
360
+ ? node.agency
361
+ : typeof node.customer === "object" && node.customer !== null
362
+ ? node.customer
363
+ : typeof node.broker === "object" && node.broker !== null
364
+ ? node.broker
365
+ : undefined;
366
+ const agency = pickString(node, ["agencyName", "brokerName", "officeName"]) ||
367
+ (agencyObj
368
+ ? pickString(agencyObj, ["name", "brandTradingName", "companyName"])
369
+ : undefined);
370
+ const mediaObj = typeof node.media === "object" && node.media !== null
371
+ ? node.media
372
+ : typeof node.propertyImages === "object" &&
373
+ node.propertyImages !== null
374
+ ? node.propertyImages
375
+ : undefined;
376
+ const photosArr = Array.isArray(node.photos)
377
+ ? node.photos
378
+ : [];
379
+ const image = pickString(node, [
380
+ "heroImageUrl",
381
+ "imageUrl",
382
+ "mainImageSrc",
383
+ "thumbnailUrl",
384
+ "photoUrl",
385
+ ]) ||
386
+ (typeof node.image === "string" ? node.image : undefined) ||
387
+ (mediaObj
388
+ ? pickString(mediaObj, [
389
+ "mainImageSrc",
390
+ "heroImageUrl",
391
+ "medium",
392
+ "small",
393
+ "url",
394
+ ])
395
+ : undefined) ||
396
+ (photosArr[0]
397
+ ? pickString(photosArr[0], ["mediaUrl", "url", "src"])
398
+ : undefined);
399
+ const coordsObj = typeof locObj?.coordinates === "object" && locObj.coordinates !== null
400
+ ? locObj.coordinates
401
+ : typeof node.gis === "object" && node.gis !== null
402
+ ? node.gis
403
+ : undefined;
404
+ const latitude = pickNumber(node, ["latitude", "lat"]) ??
405
+ (locObj ? pickNumber(locObj, ["latitude", "lat"]) : undefined) ??
406
+ (coordsObj ? pickNumber(coordsObj, ["latitude", "lat"]) : undefined);
407
+ const longitude = pickNumber(node, ["longitude", "lon", "lng"]) ??
408
+ (locObj ? pickNumber(locObj, ["longitude", "lon", "lng"]) : undefined) ??
409
+ (coordsObj
410
+ ? pickNumber(coordsObj, ["longitude", "lon", "lng"])
411
+ : undefined);
412
+ const landSqm = pickNumber(node, ["landSizeInSqm", "landArea", "lotSize"]);
413
+ const landSize = pickString(node, ["landSize", "formattedLandSize"]) ||
414
+ (landSqm && landSqm > 0 ? `${landSqm} sqm` : undefined);
415
+ const sqft = pickNumber(node, ["squareFeet", "floorArea", "buildingArea"]);
416
+ const floorObj = typeof node.floorSpace === "object" && node.floorSpace !== null
417
+ ? node.floorSpace
418
+ : undefined;
419
+ const buildingSize = (floorObj ? pickString(floorObj, ["formattedDimension"]) : undefined) ||
420
+ (sqft && sqft > 0 ? `${sqft} sqft` : undefined);
421
+ return {
422
+ title: pickString(node, ["title", "headline"]) ||
423
+ `${beds ? `${beds} bed ` : ""}${propertyType ? `${propertyType} ` : ""}at ${address}`
424
+ .replace(/\s+/g, " ")
425
+ .trim(),
426
+ address,
427
+ suburb,
428
+ state,
429
+ postcode,
430
+ country: countryCode,
431
+ price: parsedPrice.price ?? rawPriceNum,
432
+ priceText: parsedPrice.priceText,
433
+ currency: parsedPrice.currency,
434
+ pricePeriod: typeof node.rentPerWeek === "number" && node.rentPerWeek > 0
435
+ ? "week"
436
+ : priceObj?.frequency === "monthly"
437
+ ? "month"
438
+ : priceObj?.frequency === "weekly"
439
+ ? "week"
440
+ : inferPricePeriod(parsedPrice.priceText, listingType),
441
+ listingType,
442
+ propertyType,
443
+ bedrooms: beds,
444
+ bathrooms: baths,
445
+ parking,
446
+ landSize,
447
+ buildingSize,
448
+ url: detailUrl,
449
+ portal,
450
+ agency,
451
+ image,
452
+ latitude,
453
+ longitude,
454
+ extractedVia: "json-state",
455
+ };
456
+ };
457
+ const walk = (node, depth) => {
458
+ if (depth > 9 ||
459
+ results.length >= 40 ||
460
+ !node ||
461
+ typeof node !== "object") {
462
+ return;
463
+ }
464
+ if (visited.has(node)) {
465
+ return;
466
+ }
467
+ visited.add(node);
468
+ if (Array.isArray(node)) {
469
+ for (const el of node) {
470
+ walk(el, depth + 1);
471
+ }
472
+ return;
473
+ }
474
+ const record = node;
475
+ const candidate = tryConvertCandidateObject(record);
476
+ if (candidate) {
477
+ results.push(candidate);
478
+ return;
479
+ }
480
+ for (const val of Object.values(record)) {
481
+ if (val && typeof val === "object") {
482
+ walk(val, depth + 1);
483
+ }
484
+ }
485
+ };
486
+ walk(root, 0);
487
+ return results;
488
+ }
489
+ /**
490
+ * Extract structured real-estate property listings from any HTML page via:
491
+ * 1. Generic recursive JSON-tree discovery over `<script id="__NEXT_DATA__">` and `<script type="application/json">`
492
+ * 2. Next.js App Router Flight RSC payload (`self.__next_f.push`)
493
+ * 3. Schema.org JSON-LD (`RealEstateListing`, `Residence`, `SingleFamilyResidence`, `Apartment`, `House`)
494
+ */
495
+ export function extractPropertyListingsFromHtml(html, pageUrl, countryCode) {
496
+ const listings = [];
497
+ const portal = extractDomainHost(pageUrl) || "unknown";
498
+ // 1. Generic SSR JSON state extraction (`__NEXT_DATA__` or any `<script type="application/json">`)
499
+ const jsonScriptRegex = /<script[^>]+(?:id=["']__NEXT_DATA__["']|type=["']application\/json["'])[^>]*>([\s\S]*?)<\/script>/gi;
500
+ let scriptMatch;
501
+ while ((scriptMatch = jsonScriptRegex.exec(html)) !== null) {
502
+ const rawJson = scriptMatch[1]?.trim();
503
+ if (!rawJson || rawJson.length < 50) {
504
+ continue;
505
+ }
506
+ try {
507
+ const parsedJson = JSON.parse(rawJson);
508
+ const discovered = extractPropertyListingsFromJsonTree(parsedJson, pageUrl, portal, countryCode);
509
+ if (discovered.length > 0) {
510
+ listings.push(...discovered);
511
+ }
512
+ }
513
+ catch {
514
+ // Ignore non-JSON script blocks
515
+ }
516
+ }
517
+ // 2. Next.js App Router RSC payload (`self.__next_f.push`), e.g. Homely.com.au
518
+ if (listings.length === 0 && html.includes('\\"canonicalUri\\":\\"/homes/')) {
519
+ try {
520
+ const rscUnescaped = html.replace(/\\"/g, '"').replace(/\\\\/g, "\\");
521
+ const cardRegex = /\{"id":\d+,"statusType":"[^"]+","listingType":"([^"]+)"[\s\S]*?"canonicalUri":"(\/homes\/[^"]+)"[\s\S]*?"priceDetails":\{"longDescription":"([^"]*)"[\s\S]*?"address":\{"streetAddress":"([^"]*)","longAddress":"([^"]*)"[\s\S]*?"features":\{"bathrooms":(\d+|null),"bedrooms":(\d+|null),"cars":(\d+|null)/g;
522
+ for (const m of rscUnescaped.matchAll(cardRegex)) {
523
+ const [, rawListingType, canonicalUri, rawLongPrice, , longAddress, bathsStr, bedsStr, carsStr,] = m;
524
+ if (!longAddress) {
525
+ continue;
526
+ }
527
+ const cleanedPriceText = rawLongPrice.replace(/\$\$/g, "$").trim();
528
+ const parsedPrice = parsePropertyPrice(cleanedPriceText, countryCode || "AU", "AUD");
529
+ const detailUrl = resolveAbsoluteUrl(canonicalUri, pageUrl);
530
+ // Photos sit after `features` in the same card object: media.photos[].webDefaultURI.
531
+ // Stop at the next card so one listing never borrows another's photo.
532
+ const tailStart = (m.index ?? 0) + m[0].length;
533
+ const nextCard = rscUnescaped
534
+ .slice(tailStart, tailStart + 4000)
535
+ .search(/\{"id":\d+,"statusType":/);
536
+ const tail = rscUnescaped.slice(tailStart, tailStart + (nextCard >= 0 ? nextCard : 4000));
537
+ const photo = /"webDefaultURI":"(https:[^"]+)"/.exec(tail)?.[1]
538
+ ?.replace(/\\u0026/g, "&");
539
+ const listingType = inferListingType(detailUrl, rawListingType, cleanedPriceText);
540
+ listings.push({
541
+ title: longAddress,
542
+ address: longAddress,
543
+ country: countryCode || "AU",
544
+ price: parsedPrice.price,
545
+ priceText: parsedPrice.priceText,
546
+ currency: parsedPrice.currency,
547
+ pricePeriod: inferPricePeriod(cleanedPriceText, listingType),
548
+ listingType,
549
+ bedrooms: bedsStr !== "null" ? Number.parseInt(bedsStr, 10) : undefined,
550
+ bathrooms: bathsStr !== "null" ? Number.parseInt(bathsStr, 10) : undefined,
551
+ parking: carsStr !== "null" ? Number.parseInt(carsStr, 10) : undefined,
552
+ url: detailUrl,
553
+ image: photo,
554
+ portal,
555
+ extractedVia: "rsc-payload",
556
+ });
557
+ }
558
+ }
559
+ catch {
560
+ // Ignore RSC regex errors
561
+ }
562
+ }
563
+ // 3. Schema.org JSON-LD (`Residence`, `RealEstateListing`, `SingleFamilyResidence`, `Apartment`, `House`, `Place`)
564
+ if (listings.length === 0) {
565
+ const jsonLdScriptRegex = /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
566
+ const ldBlocks = [];
567
+ let ldMatch;
568
+ while ((ldMatch = jsonLdScriptRegex.exec(html)) !== null) {
569
+ try {
570
+ ldBlocks.push(JSON.parse(ldMatch[1].trim()));
571
+ }
572
+ catch {
573
+ // ignore invalid JSON-LD
574
+ }
575
+ }
576
+ const flattenLd = (item) => {
577
+ if (!item || typeof item !== "object") {
578
+ return [];
579
+ }
580
+ if (Array.isArray(item)) {
581
+ // When a JSON-LD block is a small tuple array pairing a Residence/RealEstateListing
582
+ // with a sibling Product/Offer object (e.g. `[{ "@type": "SingleFamilyResidence", ... }, { "@type": "Product", "offers": { "price": ... } }]`),
583
+ // propagate the sibling's `offers` / `price` onto the residence object.
584
+ const objs = item.filter((x) => Boolean(x && typeof x === "object" && !Array.isArray(x)));
585
+ const offerHolder = objs.find((x) => x.offers !== undefined || x.price !== undefined);
586
+ if (offerHolder && objs.length <= 4) {
587
+ return objs.flatMap((x) => flattenLd({
588
+ ...x,
589
+ offers: x.offers ?? offerHolder.offers,
590
+ price: x.price ?? offerHolder.price,
591
+ }));
592
+ }
593
+ return item.flatMap(flattenLd);
594
+ }
595
+ const obj = item;
596
+ const out = [obj];
597
+ if (Array.isArray(obj["@graph"])) {
598
+ out.push(...obj["@graph"].flatMap(flattenLd));
599
+ }
600
+ if (Array.isArray(obj.itemListElement)) {
601
+ for (const el of obj.itemListElement) {
602
+ if (el && typeof el === "object") {
603
+ const inner = el.item ?? el;
604
+ out.push(...flattenLd(inner));
605
+ }
606
+ }
607
+ }
608
+ if (obj.mainEntity &&
609
+ typeof obj.mainEntity === "object" &&
610
+ !Array.isArray(obj.mainEntity)) {
611
+ const mergedChild = {
612
+ ...obj.mainEntity,
613
+ };
614
+ if (mergedChild.offers === undefined && obj.offers !== undefined) {
615
+ mergedChild.offers = obj.offers;
616
+ }
617
+ if (mergedChild.price === undefined && obj.price !== undefined) {
618
+ mergedChild.price = obj.price;
619
+ }
620
+ if (mergedChild.url === undefined && obj.url !== undefined) {
621
+ mergedChild.url = obj.url;
622
+ }
623
+ if (mergedChild.image === undefined && obj.image !== undefined) {
624
+ mergedChild.image = obj.image;
625
+ }
626
+ out.push(...flattenLd(mergedChild));
627
+ }
628
+ else if (obj.mainEntity && typeof obj.mainEntity === "object") {
629
+ out.push(...flattenLd(obj.mainEntity));
630
+ }
631
+ return out;
632
+ };
633
+ // Some portals (rent.com.au) only attach the photo to a sibling node, e.g. an
634
+ // "Inspection Time" Event whose `url` / `location.url` points at the listing.
635
+ const imageByUrl = new Map();
636
+ for (const root of ldBlocks) {
637
+ for (const node of flattenLd(root)) {
638
+ const img = ldImage(node.image);
639
+ if (!img) {
640
+ continue;
641
+ }
642
+ const loc = typeof node.location === "object" && node.location !== null
643
+ ? node.location
644
+ : undefined;
645
+ for (const u of [node.url, loc?.url]) {
646
+ if (typeof u === "string") {
647
+ const abs = resolveAbsoluteUrl(u, pageUrl);
648
+ if (!imageByUrl.has(abs)) {
649
+ imageByUrl.set(abs, img);
650
+ }
651
+ }
652
+ }
653
+ }
654
+ }
655
+ for (const root of ldBlocks) {
656
+ const nodes = flattenLd(root);
657
+ for (const node of nodes) {
658
+ const rawType = node["@type"];
659
+ const types = Array.isArray(rawType)
660
+ ? rawType.map((t) => String(t))
661
+ : rawType
662
+ ? [String(rawType)]
663
+ : [];
664
+ const isPropertyType = types.some((t) => /^(RealEstateListing|Residence|SingleFamilyResidence|Apartment|House|Accommodation|GatedResidenceCommunity)$/i.test(t));
665
+ if (!isPropertyType) {
666
+ continue;
667
+ }
668
+ const name = typeof node.name === "string" ? node.name.trim() : "";
669
+ const addrObj = typeof node.address === "object" && node.address !== null
670
+ ? node.address
671
+ : undefined;
672
+ const street = typeof addrObj?.streetAddress === "string" &&
673
+ addrObj.streetAddress !== "property"
674
+ ? addrObj.streetAddress
675
+ : "";
676
+ const locality = typeof addrObj?.addressLocality === "string"
677
+ ? addrObj.addressLocality
678
+ : undefined;
679
+ const region = typeof addrObj?.addressRegion === "string"
680
+ ? addrObj.addressRegion
681
+ : undefined;
682
+ const postalCode = typeof addrObj?.postalCode === "string"
683
+ ? addrObj.postalCode
684
+ : undefined;
685
+ const address = [street, locality, [region, postalCode].filter(Boolean).join(" ")]
686
+ .filter(Boolean)
687
+ .join(", ") || name;
688
+ if (!address || address.length < 5) {
689
+ continue;
690
+ }
691
+ const itemUrl = typeof node.url === "string"
692
+ ? resolveAbsoluteUrl(node.url, pageUrl)
693
+ : pageUrl;
694
+ const rawOffers = node.offers;
695
+ const offersNode = Array.isArray(rawOffers)
696
+ ? rawOffers[0]
697
+ : typeof rawOffers === "object" && rawOffers !== null
698
+ ? rawOffers
699
+ : undefined;
700
+ const parsedPrice = parsePropertyPrice(offersNode?.price ||
701
+ node.price, countryCode, typeof offersNode?.priceCurrency === "string"
702
+ ? offersNode.priceCurrency
703
+ : undefined);
704
+ const geo = typeof node.geo === "object" && node.geo !== null
705
+ ? node.geo
706
+ : undefined;
707
+ const listingType = inferListingType(`${itemUrl} ${pageUrl}`, typeof node.description === "string" ? node.description : undefined, parsedPrice.priceText);
708
+ listings.push({
709
+ title: name || address,
710
+ address,
711
+ suburb: locality,
712
+ state: region,
713
+ postcode: postalCode,
714
+ country: typeof addrObj?.addressCountry === "string"
715
+ ? addrObj.addressCountry
716
+ : countryCode,
717
+ price: parsedPrice.price,
718
+ priceText: parsedPrice.priceText,
719
+ currency: parsedPrice.currency,
720
+ pricePeriod: inferPricePeriod(parsedPrice.priceText, listingType),
721
+ listingType,
722
+ propertyType: types[0],
723
+ bedrooms: typeof node.numberOfBedrooms === "number"
724
+ ? node.numberOfBedrooms
725
+ : typeof node.numberOfRooms === "number"
726
+ ? node.numberOfRooms
727
+ : undefined,
728
+ bathrooms: typeof node.numberOfBathroomsTotal === "number"
729
+ ? node.numberOfBathroomsTotal
730
+ : undefined,
731
+ url: itemUrl,
732
+ portal,
733
+ image: (() => {
734
+ const img = ldImage(node.image) ?? imageByUrl.get(itemUrl);
735
+ return img ? resolveAbsoluteUrl(img, pageUrl) : undefined;
736
+ })(),
737
+ latitude: typeof geo?.latitude === "number" ? geo.latitude : undefined,
738
+ longitude: typeof geo?.longitude === "number" ? geo.longitude : undefined,
739
+ extractedVia: "json-ld",
740
+ });
741
+ }
742
+ }
743
+ }
744
+ return listings;
745
+ }
746
+ /**
747
+ * Schema.org `image` may be a URL string, an ImageObject (`url` / `contentUrl`),
748
+ * or an array of either. Returns the first usable URL.
749
+ */
750
+ export function ldImage(v) {
751
+ if (typeof v === "string") {
752
+ // Some sites leak CSS into the value: "https://…jpg?w=1600), url(/fallback.svg)…"
753
+ const u = (v.trim().split(/\s/)[0] ?? "").replace(/\)?[,;]?$/, "");
754
+ return u || undefined;
755
+ }
756
+ if (Array.isArray(v)) {
757
+ for (const x of v) {
758
+ const u = ldImage(x);
759
+ if (u) {
760
+ return u;
761
+ }
762
+ }
763
+ return undefined;
764
+ }
765
+ if (v && typeof v === "object") {
766
+ const o = v;
767
+ return ldImage(o.url) ?? ldImage(o.contentUrl) ?? ldImage(o.thumbnailUrl);
768
+ }
769
+ return undefined;
770
+ }
771
+ /**
772
+ * Fallback extractor for SERP result snippets when a real-estate result describes a property
773
+ * or suburb listing summary in its title/description.
774
+ */
775
+ export function extractPropertyListingFromSerpResult(title, description, url, countryCode) {
776
+ const combined = `${title} ${description}`;
777
+ const bedsMatch = combined.match(/(\d+)\s*(?:bed(?:room)?s?|bd|br)\b/i);
778
+ const bathsMatch = combined.match(/(\d+)\s*(?:bath(?:room)?s?|ba)\b/i);
779
+ const carsMatch = combined.match(/(\d+)\s*(?:car(?:\s*spaces?)?|parking|garage)\b/i);
780
+ const priceMatch = combined.match(/((?:A\$|AU\$|US\$|\$|£|€)\s?\d[\d,]*(?:\.\d+)?(?:\s*(?:k|m|pw|per\s*week|\/wk|pcm|\/mo))?)/i);
781
+ // Require either a price or bed+bath mention to qualify as a property listing
782
+ if (!priceMatch && !(bedsMatch && bathsMatch)) {
783
+ return null;
784
+ }
785
+ const portal = extractDomainHost(url);
786
+ if (!portal) {
787
+ return null;
788
+ }
789
+ const parsedPrice = parsePropertyPrice(priceMatch?.[1], countryCode);
790
+ const listingType = inferListingType(url, title, parsedPrice.priceText);
791
+ const propTypeMatch = combined.match(/\b(house|apartment|unit|flat|townhouse|villa|terrace|penthouse|studio|duplex|land|acreage)\b/i);
792
+ return {
793
+ title: title.trim(),
794
+ address: title.replace(/\s*[|-]\s*[^|-]+$/, "").trim(),
795
+ country: countryCode,
796
+ price: parsedPrice.price,
797
+ priceText: parsedPrice.priceText,
798
+ currency: parsedPrice.currency,
799
+ pricePeriod: inferPricePeriod(parsedPrice.priceText, listingType),
800
+ listingType,
801
+ propertyType: propTypeMatch ? propTypeMatch[1] : undefined,
802
+ bedrooms: bedsMatch ? Number.parseInt(bedsMatch[1], 10) : undefined,
803
+ bathrooms: bathsMatch ? Number.parseInt(bathsMatch[1], 10) : undefined,
804
+ parking: carsMatch ? Number.parseInt(carsMatch[1], 10) : undefined,
805
+ url,
806
+ portal,
807
+ extractedVia: "serp-snippet",
808
+ };
809
+ }
810
+ export function dedupePropertyListings(listings, maxListings = 30) {
811
+ const sourceOrder = {
812
+ "json-state": 0,
813
+ "rsc-payload": 1,
814
+ "json-ld": 2,
815
+ "scaledown-extract-1": 3,
816
+ "serp-snippet": 4,
817
+ };
818
+ const sorted = [...listings].sort((a, b) => sourceOrder[a.extractedVia] - sourceOrder[b.extractedVia]);
819
+ const seen = new Set();
820
+ const out = [];
821
+ for (const item of sorted) {
822
+ // Same listing seen by two extractors shares URL + address; a different URL on the
823
+ // same portal is a different listing and is kept.
824
+ const key = resultDedupeKey(item.url, item.address);
825
+ if (seen.has(key)) {
826
+ continue;
827
+ }
828
+ seen.add(key);
829
+ out.push(item);
830
+ if (out.length >= maxListings) {
831
+ break;
832
+ }
833
+ }
834
+ return out;
835
+ }