@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.
- package/README.md +1 -1
- package/dist/bin.js +5 -7
- package/dist/core/engine/awswaf.d.ts +59 -0
- package/dist/core/engine/awswaf.js +164 -0
- package/dist/core/engine/cache.d.ts +70 -0
- package/dist/core/engine/cache.js +297 -0
- package/dist/core/engine/chrome.d.ts +14 -0
- package/dist/core/engine/chrome.js +140 -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 +152 -0
- package/dist/core/engine/crawler.js +918 -0
- package/dist/core/engine/fetcher.d.ts +92 -0
- package/dist/core/engine/fetcher.js +494 -0
- package/dist/core/engine/index.d.ts +7 -0
- package/dist/core/engine/index.js +7 -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/render.d.ts +41 -0
- package/dist/core/engine/render.js +138 -0
- package/dist/core/engine/safe-url.d.ts +29 -0
- package/dist/core/engine/safe-url.js +109 -0
- package/dist/core/engine/session.d.ts +93 -0
- package/dist/core/engine/session.js +250 -0
- package/dist/core/engine/stealth.d.ts +83 -0
- package/dist/core/engine/stealth.js +222 -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 +9 -0
- package/dist/core/extract/index.js +9 -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/extract/structured.d.ts +14 -0
- package/dist/core/extract/structured.js +144 -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 +72 -0
- package/dist/core/interact/driver.js +384 -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 +43 -0
- package/dist/core/interact/vm.js +338 -0
- package/dist/core/local-engine.d.ts +56 -0
- package/dist/core/local-engine.js +667 -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 +643 -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 +222 -0
- package/dist/core/search/catalog.d.ts +176 -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 +719 -0
- package/dist/core/search/providers.d.ts +72 -0
- package/dist/core/search/providers.js +1270 -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/dist/format.js +8 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/load-local-engine.d.ts +7 -0
- package/dist/load-local-engine.js +22 -0
- package/dist/server.js +7 -0
- 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
|
+
}
|