NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name RealEstate.com.au Enhanced Buy Listings & Filters
// @namespace http://tampermonkey.net/
// @version 3.6
// @description Adds descriptions, walking time, and address/description keyword filters. Adds sorting by smallest bedroom size and walking distance.
// @author Chris Malone
// @license MIT
// @match https://www.realestate.com.au/buy/*
// @match https://www.realestate.com.au/property-*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @connect nominatim.openstreetmap.org
// @connect overpass-api.de
// @connect api.openrouteservice.org
// @connect i2.au.reastatic.net
// @require https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js
// @require https://docs.opencv.org/master/opencv.js
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
const MAX_OCR_SIZE = 1600;
const OCR_WHITELIST = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.xX ';
const DISALLOWED_ROOM_NAMES = ["CAR SPACE", "LIVING", "BATH", "BATHROOM", "STREET", "KITCHEN", "DINING", "LAUNDRY", "LDRY", "BALCONY", "CAR", "CAR PARK", "CARPARK", "FOYER", "SHOWER", "LOUNGE", "LOUNGEROOM", "COURTYARD", "L'DRY", "KIT'N", "AREA", "HALLWAY", "ENTERTAINING", "ENTERTAINMENT", "SUNROOM", "ENTRY", "MEALS", "FAMILY", "GARAGE", "VERANDA"];
const BEDROOM_KEYWORDS = ["MASTER BEDROOM", "MASTER BED", "MAIN BEDROOM", "MAIN BED", "PRIMARY BEDROOM", "PRIMARY BED", "1ST BEDROOM", "2ND BEDROOM", "BED", "BEDROOM", "MASTER", "PRIMARY", "RIMARY", "BR"];
const STUDY_KEYWORDS = ["STUDY NOOK", "STUDY", "STDY", "NOOK"];
const STORAGE_KEYWORDS = ["STORAGE CAGE", "STORAGE", "STOARAGE", "STORE"];
const ALL_ROOM_KEYWORDS = [...BEDROOM_KEYWORDS, ...STUDY_KEYWORDS, ...STORAGE_KEYWORDS];
const SPECIAL_ROOM_KEYWORDS_REGEX = new RegExp(`(${([]).concat(STORAGE_KEYWORDS, STUDY_KEYWORDS).join('|')})`, 'i');
let ORS_API_KEY = '';
let allListingsData = [];
let domListingsMap = new Map();
const WALKING_TIME_ENABLED_KEY = 'rea_walking_time_enabled';
const OCR_ENABLED_KEY = 'rea_ocr_enabled';
const ROOM_SIZE_SORT_ENABLED_KEY = 'rea_room_sort_enabled';
const TRANSPORT_SORT_ENABLED_KEY = 'rea_transport_sort_enabled';
const HIDE_NO_PRICE_ENABLED_KEY = 'rea_hide_no_price_enabled';
const DEFAULT_ADDRESS_KEYWORDS = "";
const DEFAULT_DESCRIPTION_KEYWORDS = "";
GM_addStyle(`
.surrounding-results-title {
display: none;
}
.rea-custom-description {
font-size: 0.9em;
color: #555;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #eee;
line-height: 1.4;
}
.rea-custom-description br {
margin-bottom: 0.5em;
display: block;
content: "";
}
.rea-ocr-container {
font-size: 0.9em;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #eee;
line-height: 1.4;
}
.ocr-status.scanning {
font-style: italic;
color: #888;
}
.ocr-status.success {
color: #333;
}
.ocr-status.success b {
color: #007882;
}
.ocr-status.error {
font-style: italic;
color: #999;
font-size: 0.9em;
}
.rea-custom-filter-container {
display: flex;
flex-wrap: wrap;
gap: 15px;
align-items: center;
margin-top: 10px;
margin-bottom: 10px;
padding: 10px;
background-color: #f9f9f9;
border: 1px solid #ddd;
border-radius: 4px;
}
.rea-custom-filter-group {
display: flex;
flex-direction: column;
flex-grow: 1;
min-width: 200px;
}
.rea-custom-filter-group label {
font-weight: bold;
margin-bottom: 5px;
font-size: 0.9em;
color: #333;
}
.rea-custom-filter-group input[type="text"] {
padding: 8px;
border: 1px solid #ccc;
border-radius: 3px;
font-size: 0.9em;
}
#reaBruteForceButton {
padding: 8px 12px;
border: 1px solid #007882;
background-color: #e0f2f1;
color: #007882;
cursor: pointer;
border-radius: 3px;
font-weight: bold;
}
#reaBruteForceButton:disabled {
background-color: #ccc;
cursor: not-allowed;
border-color: #999;
color: #666;
}
.listing-hidden-by-filter {
opacity: 0.3;
border-left: 5px solid red;
padding-left: 5px;
}
.rea-checkbox-group {
padding-top: 20px;
}
.rea-checkbox-group label {
display: flex;
align-items: center;
cursor: pointer;
font-weight: bold;
font-size: 0.9em;
color: #333;
}
.rea-checkbox-group input[type="checkbox"] {
margin-right: 8px;
}
.residential-card__address-heading {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.rea-walking-time-container {
display: flex;
align-items: center;
margin-left: 8px;
position: relative;
z-index: 2;
}
.walking-time-status {
white-space: nowrap;
}
.walking-time-status.checking {
font-style: italic;
color: #888;
}
.walking-time-status.success {
font-weight: bold;
color: #007882;
}
.walking-time-status.error {
font-style: italic;
color: #999;
font-size: 0.9em;
}
.transport-toggle-btn {
background: none;
border: none;
padding: 0;
font-family: inherit;
font-size: 0.85em;
margin-left: 8px;
color: #005A64;
text-decoration: none;
cursor: pointer;
border-bottom: 1px dotted #005A64;
white-space: nowrap;
}
.residential-card {
cursor: revert !important;
}
.rea-ad-hidden {
display: none !important;
}
`);
function promptForApiKey() {
if (document.getElementById('rea-api-key-modal')) return;
GM_addStyle(`
.rea-modal-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background-color: rgba(0,0,0,0.6); z-index: 99999;
display: flex; align-items: center; justify-content: center;
}
.rea-modal-content {
background-color: white; padding: 25px; border-radius: 8px;
max-width: 550px; box-shadow: 0 4px 15px rgba(0,0,0,0.2);
font-family: sans-serif; text-align: left;
}
.rea-modal-content h3 { margin-top: 0; color: #333; }
.rea-modal-content p { line-height: 1.6; color: #555; }
.rea-modal-content a { color: #007882; text-decoration: none; font-weight: bold; }
.rea-modal-content a:hover { text-decoration: underline; }
.rea-modal-content input[type="text"] { width: 100%; padding: 10px; margin-top: 10px; box-sizing: border-box; border: 1px solid #ccc; border-radius: 4px; }
.rea-modal-content button {
background-color: #007882; color: white; border: none;
padding: 10px 20px; border-radius: 4px; cursor: pointer;
margin-top: 15px; font-weight: bold; font-size: 1em;
}
.rea-modal-content button:hover { background-color: #005A64; }
`);
const overlay = document.createElement('div');
overlay.id = 'rea-api-key-modal';
overlay.className = 'rea-modal-overlay';
const content = document.createElement('div');
content.className = 'rea-modal-content';
content.innerHTML = `
<h3>API Key Required for Walking Times</h3>
<p>
To calculate walking times to public transport, this script uses the
<a href="https://openrouteservice.org" target="_blank" rel="noopener noreferrer">OpenRouteService</a> API.
<a href="https://openrouteservice.org/dev/#/signup" target="_blank" rel="noopener noreferrer">Click here to sign up</a>.
</p>
<p>
1. Click the link above to sign up.
<br>
2. After signing up, click the <b>copy button</b> next to "Basic Key".
<br>
3. Paste the key below and click Save. The page will reload.
</p>
`;
const input = document.createElement('input');
input.type = 'text';
input.placeholder = 'Paste your OpenRouteService API key here';
const saveButton = document.createElement('button');
saveButton.textContent = 'Save Key and Reload';
content.appendChild(input);
content.appendChild(saveButton);
overlay.appendChild(content);
document.body.appendChild(overlay);
saveButton.onclick = async () => {
const key = input.value.trim();
if (key) {
await GM_setValue('ORS_API_KEY', key);
ORS_API_KEY = key;
document.body.removeChild(overlay);
location.reload();
} else {
alert('Please enter a valid API key.');
}
};
overlay.onclick = function(e) {
if (e.target === overlay) {
document.body.removeChild(overlay);
}
};
}
const apiCache = {
async get(key) {
try {
const value = await GM_getValue(key, null);
return value ? JSON.parse(value) : null;
} catch { return null; }
},
async set(key, value) {
await GM_setValue(key, JSON.stringify(value));
}
};
function robustRequest(options) {
return new Promise(resolve => {
options.onload = (response) => {
if (response.status >= 200 && response.status < 300) {
try { resolve({ data: JSON.parse(response.responseText), error: null }); }
catch (e) { resolve({ data: null, error: 'Parse error' }); }
} else { resolve({ data: null, error: `HTTP ${response.status}` }); }
};
options.onerror = () => resolve({ data: null, error: 'Network error' });
options.ontimeout = () => resolve({ data: null, error: 'Timeout' });
GM_xmlhttpRequest(options);
});
}
function haversineDistance(lat1, lon1, lat2, lon2) {
const R = 6371; const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
return R * (2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)));
}
async function performGeocodeRequest(address) {
let cleanedAddress = address.replace(/^\s*([a-z0-9-]+\/|unit\s+[a-z0-9-]+)\s*/i, '');
cleanedAddress = cleanedAddress.replace(/^(\d+)-(\d+)\s/, '$2 ');
const { data } = await robustRequest({
method: "GET", url: `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(cleanedAddress)}&format=json&limit=1`
});
return (data && data.length > 0) ? { lat: parseFloat(data[0].lat), lon: parseFloat(data[0].lon) } : null;
}
async function geocodeAddress(address, statusElement) {
const cacheKey = 'geocode_' + address;
const cached = await apiCache.get(cacheKey);
if (cached && cached.coords) {
return { result: cached.coords, fromCache: true };
}
let coords = await performGeocodeRequest(address);
if (!coords) {
if (statusElement) statusElement.textContent = 'Retrying...';
await new Promise(res => setTimeout(res, 5000));
coords = await performGeocodeRequest(address);
}
if (coords) {
await apiCache.set(cacheKey, { coords });
}
return { result: coords, fromCache: false };
}
async function findNearestStop(mode, lat, lon) {
const cacheKey = `${mode}_${lat.toFixed(4)}_${lon.toFixed(4)}`;
const cached = await apiCache.get(cacheKey);
if (cached && cached.stop) return { result: cached.stop, fromCache: true };
const query = mode === 'train'
? `[out:json];node(around:5000,${lat},${lon})[railway=station];out;`
: `[out:json];(node(around:2000,${lat},${lon})[highway=bus_stop];node(around:2000,${lat},${lon})[amenity=bus_station];);out;`;
const { data } = await robustRequest({
method: "GET", url: `https://overpass-api.de/api/interpreter?data=${encodeURIComponent(query)}`
});
if (!data || !data.elements || data.elements.length === 0) {
return { result: null, fromCache: false };
}
const closest = data.elements.reduce((closest, current) => {
const dist = haversineDistance(lat, lon, current.lat, current.lon);
return (dist < closest.distance) ? { stop: current, distance: dist } : closest;
}, { stop: null, distance: Infinity });
if(closest.stop) await apiCache.set(cacheKey, { stop: closest.stop });
return { result: closest.stop, fromCache: false };
}
async function getWalkingTime(mode, startCoords, stopCoords) {
const cacheKey = `walk_${mode}_${startCoords.lat.toFixed(4)}_${startCoords.lon.toFixed(4)}_to_${stopCoords.lat.toFixed(4)}_${stopCoords.lon.toFixed(4)}`;
const cached = await apiCache.get(cacheKey);
if (cached && typeof cached.time === 'number') return { result: cached.time, fromCache: true };
if (!ORS_API_KEY) {
console.error("[REA Enhancer] Missing API Key for OpenRouteService.");
return { result: null, fromCache: false };
}
const { data } = await robustRequest({
method: "POST", url: "https://api.openrouteservice.org/v2/directions/foot-walking/json",
headers: { "Authorization": ORS_API_KEY, "Content-Type": "application/json" },
data: JSON.stringify({ "coordinates": [[startCoords.lon, startCoords.lat], [stopCoords.lon, stopCoords.lat]] })
});
const time = (data && typeof data.routes?.[0]?.summary?.duration === 'number')
? Math.round(data.routes[0].summary.duration / 60)
: null;
if (typeof time === 'number') await apiCache.set(cacheKey, { time });
return { result: time, fromCache: false };
}
async function updateWalkingTime(listingData, parentElement, mode) {
if (!parentElement) return;
parentElement.querySelector('.rea-walking-time-container')?.remove();
const container = document.createElement('span');
container.className = 'rea-walking-time-container';
const statusElement = document.createElement('span');
statusElement.className = 'walking-time-status checking';
statusElement.textContent = `Checking ${mode}...`;
container.appendChild(statusElement);
parentElement.appendChild(container);
try {
const fullAddress = listingData.address?.display?.fullAddress;
if (!fullAddress) {
throw new Error('Address unavailable');
}
const throttleIfNeeded = (fromCache) => !fromCache ? new Promise(res => setTimeout(res, 300)) : Promise.resolve();
const { result: coords, fromCache: cCache } = await geocodeAddress(fullAddress, statusElement);
await throttleIfNeeded(cCache);
if (!coords) {
throw new Error('Could not locate address');
}
const { result: stop, fromCache: sCache } = await findNearestStop(mode, coords.lat, coords.lon);
await throttleIfNeeded(sCache);
if (!stop) {
throw new Error(`No nearby ${mode} stop found`);
}
const { result: time, fromCache: tCache } = await getWalkingTime(mode, coords, { lat: stop.lat, lon: stop.lon });
await throttleIfNeeded(tCache);
if (typeof time !== 'number') {
throw new Error('Walking time unavailable');
}
const stopName = stop.tags?.name || `${mode} stop`;
const icon = mode === 'train' ? '🚆' : '🚌';
statusElement.textContent = `🚶 ${time} min to ${icon} ${stopName}`;
statusElement.className = 'walking-time-status success';
listingData.walkingTime = time;
const otherMode = mode === 'train' ? 'bus' : 'train';
const otherIcon = otherMode === 'train' ? '🚆' : '🚌';
const toggleButton = document.createElement('button');
toggleButton.className = 'transport-toggle-btn';
toggleButton.textContent = `(Switch to ${otherIcon})`;
toggleButton.onclick = (e) => {
e.preventDefault(); e.stopPropagation();
updateWalkingTime(listingData, parentElement, otherMode);
};
container.appendChild(toggleButton);
} catch (error) {
statusElement.textContent = error.message;
statusElement.className = 'walking-time-status error';
listingData.walkingTime = Infinity;
} finally {
if (await GM_getValue(TRANSPORT_SORT_ENABLED_KEY, true)) {
handleSortUiChange();
}
}
}
function displayOcrResults(element, rooms) {
if (!rooms || rooms.length === 0) {
element.textContent = 'No room dimensions found on floorplan.';
element.className = 'ocr-status error';
} else {
const uniqueRooms = [...new Map(rooms.map(b => [JSON.stringify([b.label, b.area]), b])).values()];
element.innerHTML = '<b>Floorplan Areas:</b> ' + uniqueRooms.map(b => {
if (b.area) {
return `${b.label} → ${b.width}×${b.length}m = <b>${b.area} m²</b>`;
} else {
return `${b.label} (Dimensions not found)`;
}
}).join('; ');
element.className = 'ocr-status success';
}
}
function binarizeCanvasOtsu(canvas) {
const resultCanvas = document.createElement('canvas');
resultCanvas.width = canvas.width;
resultCanvas.height = canvas.height;
const src = cv.imread(canvas);
const dst = new cv.Mat();
cv.cvtColor(src, dst, cv.COLOR_RGBA2GRAY, 0);
cv.threshold(dst, dst, 0, 255, cv.THRESH_BINARY + cv.THRESH_OTSU);
cv.imshow(resultCanvas, dst);
src.delete();
dst.delete();
return resultCanvas;
}
function binarizeCanvasAdaptive(canvas, blockSize, C) {
const resultCanvas = document.createElement('canvas');
resultCanvas.width = canvas.width;
resultCanvas.height = canvas.height;
const src = cv.imread(canvas);
const dst = new cv.Mat();
cv.cvtColor(src, dst, cv.COLOR_RGBA2GRAY, 0);
cv.adaptiveThreshold(dst, dst, 255, cv.ADAPTIVE_THRESH_GAUSSIAN_C, cv.THRESH_BINARY, blockSize, C);
cv.imshow(resultCanvas, dst);
src.delete();
dst.delete();
return resultCanvas;
}
function mergeBboxes(boxes) {
if (!boxes || boxes.length === 0) return null;
return boxes.reduce((acc, box) => {
return {
x0: Math.min(acc.x0, box.x0),
y0: Math.min(acc.y0, box.y0),
x1: Math.max(acc.x1, box.x1),
y1: Math.max(acc.y1, box.y1),
};
});
}
function parseDimensionValue(part1, part2) {
if (part2) return parseFloat(`${part1}.${part2}`);
if (part1.length >= 2 && !part1.includes('.')) return parseFloat(`${part1[0]}.${part1.substring(1)}`);
return parseFloat(part1);
}
function isZoneDuplicate(newZone, existingZones) {
for (const existingZone of existingZones) {
const xOverlap = Math.max(0, Math.min(newZone.left + newZone.width, existingZone.left + existingZone.width) - Math.max(newZone.left, existingZone.left));
const yOverlap = Math.max(0, Math.min(newZone.top + newZone.height, existingZone.top + existingZone.height) - Math.max(newZone.top, existingZone.top));
const intersectionArea = xOverlap * yOverlap;
if (intersectionArea === 0) continue;
const areaNew = newZone.width * newZone.height;
const areaExisting = existingZone.width * existingZone.height;
const unionArea = areaNew + areaExisting - intersectionArea;
const iou = unionArea > 0 ? intersectionArea / unionArea : 0;
const smallerArea = Math.min(areaNew, areaExisting);
const containment = smallerArea > 0 ? intersectionArea / smallerArea : 0;
if (iou > 0.01 || containment > 0.8) {
return true;
}
}
return false;
}
function calculateAdaptiveZone(labelBbox, canvasWidth, canvasHeight) {
const labelWidth = labelBbox.x1 - labelBbox.x0;
const labelHeight = labelBbox.y1 - labelBbox.y0;
const avgDimensionWidth = 120;
const horizontalPadding = 120;
const targetWidth = Math.max(labelWidth, avgDimensionWidth) + horizontalPadding;
const labelCenterX = labelBbox.x0 + labelWidth / 2;
const zoneLeft = labelCenterX - targetWidth / 2;
const zoneHeight = labelHeight * 3;
const zoneTop = labelBbox.y0;
return {
left: Math.max(0, zoneLeft),
top: Math.max(0, zoneTop),
width: Math.min(canvasWidth - Math.max(0, zoneLeft), targetWidth),
height: Math.min(canvasHeight - Math.max(0, zoneTop), zoneHeight),
};
}
function findKeywordMatches(words, keywordList) {
const sortedKeywords = keywordList
.map(k => k.toUpperCase())
.sort((a, b) => b.split(' ').length - a.split(' ').length);
const matches = [];
const usedWordIndices = new Set();
for (let i = 0; i < words.length; i++) {
if (usedWordIndices.has(i)) continue;
for (const keyword of sortedKeywords) {
const keywordParts = keyword.split(' ');
if (i + keywordParts.length > words.length) continue;
let potentialMatchWords = [];
let matchFound = true;
for (let j = 0; j < keywordParts.length; j++) {
const wordIndex = i + j;
const currentWord = words[wordIndex];
const currentWordText = currentWord.text.toUpperCase();
if (!currentWordText.replace(/['’]/g, '').startsWith(keywordParts[j])) {
matchFound = false;
break;
}
if (j > 0) {
const prevWord = words[wordIndex - 1];
const distance = currentWord.bbox.x0 - prevWord.bbox.x1;
if (distance > 30) { // Max 30px gap between words of a phrase
matchFound = false;
break;
}
}
potentialMatchWords.push({ ...currentWord, index: wordIndex });
}
if (matchFound) {
const matchText = potentialMatchWords.map(w => w.text).join(' ');
const numberWord = words[i + keywordParts.length];
if (numberWord && /^\d+$/.test(numberWord.text) && (numberWord.bbox.x0 - potentialMatchWords[potentialMatchWords.length - 1].bbox.x1 < 30)) {
potentialMatchWords.push({ ...numberWord, index: i + keywordParts.length });
}
matches.push({
text: potentialMatchWords.map(w => w.text).join(' '),
words: potentialMatchWords,
bbox: mergeBboxes(potentialMatchWords.map(w => w.bbox))
});
potentialMatchWords.forEach(w => usedWordIndices.add(w.index));
i += potentialMatchWords.length - 1; // Move index past the matched phrase
break; // Move to next starting word
}
}
}
return matches;
}
async function findAndProcessRooms(worker, processedCanvas, words) {
const allFoundRooms = [];
const allFoundZones = [];
const usedWordIndices = new Set();
// --- Dimension-First Method ---
const dimensionBlocks = [];
const dimensionSingleWordRegex = /^\d{1,2}(?:\.\d{1,2})?[xX×,]\d{1,2}(?:\.\d{1,2})?m?$/;
const dimensionPartRegex = /^\d{1,2}(?:\.\d{1,2})?m?$/;
for (let i = 0; i < words.length; i++) {
if (usedWordIndices.has(i)) continue;
const word = words[i];
let block = null;
let blockIndices = [];
if (dimensionSingleWordRegex.test(word.text)) {
block = { words: [word], indices: [i] };
} else if (i <= words.length - 3) {
const w = words[i], x = words[i+1], l = words[i+2];
if (dimensionPartRegex.test(w.text) && x.text.toUpperCase().replace(/[×,]/g, 'X') === 'X' && dimensionPartRegex.test(l.text)) {
block = { words: [w, x, l], indices: [i, i+1, i+2] };
}
}
if (block) {
block.bbox = mergeBboxes(block.words.map(w => w.bbox));
dimensionBlocks.push(block);
}
}
for (const block of dimensionBlocks) {
const searchHeight = block.bbox.y1 - block.bbox.y0 + 40;
const horizontalPadding = 30;
const wordsAbove = words.map((w, i) => ({...w, index: i})).filter(w => {
if (usedWordIndices.has(w.index)) return false;
const isAbove = w.bbox.y1 < block.bbox.y0 + 10;
const isCloseVertically = (block.bbox.y0 - w.bbox.y1) < searchHeight;
const isAlignedHorizontally = (w.bbox.x0 < block.bbox.x1 + horizontalPadding) && (w.bbox.x1 > block.bbox.x0 - horizontalPadding);
return isAbove && isCloseVertically && isAlignedHorizontally;
});
const labelMatches = findKeywordMatches(wordsAbove, ALL_ROOM_KEYWORDS);
if (labelMatches.length > 0) {
const bestMatch = labelMatches[0]; // findKeywordMatches returns one match per start position
const labelText = bestMatch.text.toUpperCase();
if (DISALLOWED_ROOM_NAMES.some(disallowed => labelText.includes(disallowed))) continue;
const allRoomWords = [...bestMatch.words, ...block.words];
const tightBbox = mergeBboxes(allRoomWords.map(w => w.bbox));
const PADDING = 10;
const zone = {
left: Math.max(0, tightBbox.x0 - PADDING), top: Math.max(0, tightBbox.y0 - PADDING),
width: Math.min(processedCanvas.width - tightBbox.x0, (tightBbox.x1 - tightBbox.x0) + 2 * PADDING),
height: Math.min(processedCanvas.height - tightBbox.y0, (tightBbox.y1 - tightBbox.y0) + 2 * PADDING),
};
const cropCanvas = document.createElement('canvas');
cropCanvas.width = zone.width; cropCanvas.height = zone.height;
cropCanvas.getContext('2d').drawImage(processedCanvas, zone.left, zone.top, zone.width, zone.height, 0, 0, zone.width, zone.height);
const { data } = await worker.recognize(cropCanvas, {tesseract: {psm: '7', tessedit_char_whitelist: OCR_WHITELIST}});
const dimensionRegex = /(\d{1,2})\.?(\d{1,2})?\s*[mM]?\s*[xX×,*]\s*(\d{1,2})\.?(\d{1,2})?/;
const cleanedText = data.text.replace(/\s+/g, '');
const specialCaseMatch = cleanedText.match(/(\d\.\d)\.(\d{2})\.(\d)m?$/i);
const dimMatch = data.text.match(dimensionRegex);
let width = NaN, length = NaN;
if (dimMatch) {
width = parseDimensionValue(dimMatch[1], dimMatch[2]);
length = parseDimensionValue(dimMatch[3], dimMatch[4]);
if (specialCaseMatch) {
const correctedLength = parseDimensionValue(specialCaseMatch[2].slice(-1) + specialCaseMatch[3], null);
if (!isNaN(correctedLength) && correctedLength > 0.5) length = correctedLength;
}
} else if (specialCaseMatch) {
width = parseFloat(specialCaseMatch[1]);
length = parseDimensionValue(specialCaseMatch[2].slice(-1) + specialCaseMatch[3], null);
}
const isSpecialRoom = SPECIAL_ROOM_KEYWORDS_REGEX.test(labelText);
const minDim = isSpecialRoom ? 0.5 : 1.5;
if (!isNaN(width) && !isNaN(length) && width > minDim && length > minDim && width < 10 && length < 10) {
allFoundRooms.push({
label: labelText.replace(/\s+/g, ' ').trim().toUpperCase(), width, length, area: +(width * length).toFixed(2)
});
allFoundZones.push(zone);
bestMatch.words.forEach(p => usedWordIndices.add(p.index));
block.indices.forEach(i => usedWordIndices.add(i));
}
}
}
const unusedWords = words.map((w, i) => ({...w, index: i})).filter(w => !usedWordIndices.has(w.index));
const allLabelMatches = findKeywordMatches(unusedWords, ALL_ROOM_KEYWORDS);
for (const match of allLabelMatches) {
const labelText = match.text.toUpperCase();
if (DISALLOWED_ROOM_NAMES.some(disallowed => labelText.includes(disallowed))) continue;
const zone = calculateAdaptiveZone(match.bbox, processedCanvas.width, processedCanvas.height);
if (zone.width > 0 && zone.height > 0) {
const cropCanvas = document.createElement('canvas');
cropCanvas.width = zone.width; cropCanvas.height = zone.height;
cropCanvas.getContext('2d').drawImage(processedCanvas, zone.left, zone.top, zone.width, zone.height, 0, 0, zone.width, zone.height);
const { data } = await worker.recognize(cropCanvas, {tesseract: {psm: '7', tessedit_char_whitelist: OCR_WHITELIST}});
const ocrText = data.text.replace(/\n/g, ' ').trim().toUpperCase();
if (DISALLOWED_ROOM_NAMES.some(disallowed => ocrText.includes(disallowed))) continue;
const dimensionRegex = /(\d{1,2})\.?(\d{1,2})?\s*[mM]?\s*[xX×,*]\s*(\d{1,2})\.?(\d{1,2})?/;
const cleanedText = data.text.replace(/\s+/g, '');
const specialCaseMatch = cleanedText.match(/(\d\.\d)\.(\d{2})\.(\d)m?$/i);
const dimMatch = data.text.match(dimensionRegex);
const isSpecialRoom = SPECIAL_ROOM_KEYWORDS_REGEX.test(labelText);
let width = NaN, length = NaN;
if (dimMatch) {
width = parseDimensionValue(dimMatch[1], dimMatch[2]);
length = parseDimensionValue(dimMatch[3], dimMatch[4]);
if (specialCaseMatch) {
const correctedLength = parseDimensionValue(specialCaseMatch[2].slice(-1) + specialCaseMatch[3], null);
if (!isNaN(correctedLength) && correctedLength > 0.5) length = correctedLength;
}
} else if (specialCaseMatch) {
width = parseFloat(specialCaseMatch[1]);
length = parseDimensionValue(specialCaseMatch[2].slice(-1) + specialCaseMatch[3], null);
}
const minDim = isSpecialRoom ? 0.5 : 1.5;
const isValid = !isNaN(width) && !isNaN(length) && width > minDim && length > minDim && width < 10 && length < 10;
if (isValid) {
allFoundRooms.push({ label: labelText.replace(/\s+/g, ' ').trim().toUpperCase(), width, length, area: +(width * length).toFixed(2) });
allFoundZones.push(zone);
} else if (isSpecialRoom && !dimMatch && !specialCaseMatch) {
allFoundRooms.push({ label: labelText.replace(/\s+/g, ' ').trim().toUpperCase(), width: null, length: null, area: null });
allFoundZones.push(zone);
}
}
}
return { rooms: allFoundRooms, zones: allFoundZones };
}
function fetchImageAsBlob(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: url,
responseType: 'blob',
onload: function(response) {
if (response.status === 200) {
resolve(response.response);
} else {
reject(new Error(`Failed to fetch image. Status: ${response.status}`));
}
},
onerror: function(error) {
reject(new Error('Network error while fetching image for OCR.'));
}
});
});
}
function cropEqualHorizontalBorders(img, threshold = 30) {
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');
tempCanvas.width = img.width;
tempCanvas.height = img.height;
tempCtx.drawImage(img, 0, 0);
const imgData = tempCtx.getImageData(0, 0, img.width, img.height);
const { data, width, height } = imgData;
let borderWidth = 0;
for (let x = 0; x < Math.floor(width / 2); x++) {
let blackColumn = true;
for (let y = 0; y < height; y++) {
const i = (y * width + x) * 4;
const r = data[i], g = data[i+1], b = data[i+2];
if (r > threshold || g > threshold || b > threshold) {
blackColumn = false;
break;
}
}
if (!blackColumn) {
borderWidth = x;
break;
}
}
if (borderWidth === 0 || (width - 2 * borderWidth) <= 0) {
return tempCanvas;
}
const cropWidth = width - 2 * borderWidth;
const croppedCanvas = document.createElement('canvas');
const croppedCtx = croppedCanvas.getContext('2d');
croppedCanvas.width = cropWidth;
croppedCanvas.height = height;
croppedCtx.drawImage(img, borderWidth, 0, cropWidth, height, 0, 0, cropWidth, height);
return croppedCanvas;
}
function resizeCanvas(canvas, maxSize = MAX_OCR_SIZE) {
const width = canvas.width;
const height = canvas.height;
let newWidth = width;
let newHeight = height;
if (width > maxSize || height > maxSize) {
const ratio = width / height;
if (ratio > 1) {
newWidth = maxSize;
newHeight = Math.round(maxSize / ratio);
} else {
newHeight = maxSize;
newWidth = Math.round(maxSize * ratio);
}
}
if (newWidth !== width || newHeight !== height) {
const resizedCanvas = document.createElement('canvas');
const resizedCtx = resizedCanvas.getContext('2d');
resizedCanvas.width = newWidth;
resizedCanvas.height = newHeight;
resizedCtx.drawImage(canvas, 0, 0, newWidth, newHeight);
return resizedCanvas;
}
return canvas;
}
let cvReady = false;
function ensureCvIsReady() {
return new Promise(resolve => {
if (cvReady) return resolve();
const checkCv = setInterval(() => {
if (typeof cv !== 'undefined' && cv.Mat) {
clearInterval(checkCv);
cvReady = true;
console.log("[REA Enhancer] OpenCV.js is ready.");
resolve();
}
}, 100);
});
}
let ocrWorker = null;
let ocrWorkerPromise = null;
async function ensureOcrWorker(statusElement) {
if (ocrWorker) return ocrWorker;
if (ocrWorkerPromise) {
if(statusElement) statusElement.textContent = 'Waiting for OCR engine...';
return await ocrWorkerPromise;
}
ocrWorkerPromise = new Promise(async (resolve, reject) => {
try {
if(statusElement) statusElement.textContent = 'Initializing OCR engine...';
const worker = await Tesseract.createWorker('eng');
ocrWorker = worker;
resolve(worker);
} catch (error) {
console.error("[REA Enhancer] Failed to initialize Tesseract worker:", error);
ocrWorkerPromise = null;
reject(error);
}
});
return await ocrWorkerPromise;
}
async function processFloorplanOcr(containerElement, listingData, floorplanUrl) {
if (!containerElement) return;
let ocrContainer = containerElement.querySelector('.rea-ocr-container');
if (!ocrContainer) {
ocrContainer = document.createElement('div');
ocrContainer.className = 'rea-ocr-container';
containerElement.appendChild(ocrContainer);
}
const ocrStatusEl = document.createElement('div');
ocrStatusEl.textContent = 'Scanning floorplan...';
ocrStatusEl.className = 'ocr-status scanning';
ocrContainer.innerHTML = '';
ocrContainer.appendChild(ocrStatusEl);
const urlParts = floorplanUrl.split('/');
const uniqueImageId = urlParts[urlParts.length - 2];
const cacheKey = 'ocr_v5_' + uniqueImageId; // Cache version bump
const cached = await apiCache.get(cacheKey);
if (cached && cached.rooms) {
listingData.ocrRooms = cached.rooms;
displayOcrResults(ocrStatusEl, cached.rooms);
if (getCurrentlySelectedSortKey() === 'smallest-room-desc') {
handleSortUiChange();
}
return;
}
try {
await ensureCvIsReady();
const worker = await ensureOcrWorker(ocrStatusEl);
const imageBlob = await fetchImageAsBlob(floorplanUrl);
const img = new Image();
const objectUrl = URL.createObjectURL(imageBlob);
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = objectUrl;
});
let croppedCanvas = cropEqualHorizontalBorders(img);
let sourceCanvas = resizeCanvas(croppedCanvas, MAX_OCR_SIZE);
URL.revokeObjectURL(objectUrl);
let finalRooms = [];
let finalZones = [];
const passes = [
{ name: "Otsu Threshold", binarize: (c) => binarizeCanvasOtsu(c) },
{ name: "Adaptive (5, 24)", binarize: (c) => binarizeCanvasAdaptive(c, 5, 24) },
{ name: "Adaptive (7, 12)", binarize: (c) => binarizeCanvasAdaptive(c, 7, 12) }
];
for (const pass of passes) {
ocrStatusEl.textContent = `Scanning (Pass: ${pass.name})...`;
const processedCanvas = pass.binarize(sourceCanvas);
const { data: { words } } = await worker.recognize(processedCanvas, {tesseract: {psm: '11', tessedit_char_whitelist: OCR_WHITELIST}});
const results = await findAndProcessRooms(worker, processedCanvas, words);
for (let i = 0; i < results.rooms.length; i++) {
const newRoom = results.rooms[i];
const newZone = results.zones[i];
if (!isZoneDuplicate(newZone, finalZones)) {
finalRooms.push(newRoom);
finalZones.push(newZone);
}
}
}
listingData.ocrRooms = finalRooms;
displayOcrResults(ocrStatusEl, finalRooms);
await apiCache.set(cacheKey, { rooms: finalRooms });
if (getCurrentlySelectedSortKey() === 'smallest-room-desc') {
handleSortUiChange();
}
} catch (error) {
console.error(`[REA Enhancer] OCR Error for ${floorplanUrl}:`, error);
ocrStatusEl.textContent = 'Could not read floorplan dimensions.';
listingData.ocrRooms = [];
await apiCache.set(cacheKey, { rooms: [] });
if (getCurrentlySelectedSortKey() === 'smallest-room-desc') {
handleSortUiChange();
}
}
}
function waitForElement(selector, callback, timeout = 30000, interval = 100) {
let elapsedTime = 0;
const timer = setInterval(() => {
const element = document.querySelector(selector);
if (element) {
clearInterval(timer);
callback(element);
}
elapsedTime += interval;
if (elapsedTime >= timeout) {
clearInterval(timer);
console.warn(`[REA Enhancer] Element "${selector}" not found after ${timeout/1000}s.`);
}
}, interval);
}
function parsePriceFromDisplay(priceDisplay) {
if (!priceDisplay || typeof priceDisplay !== 'string') {
return { low: null, high: null, isNumeric: false };
}
if (!priceDisplay.includes('$')) {
return { low: null, high: null, isNumeric: false };
}
const priceRegex = /\$?([\d,]+\.?\d*)\s*([mk])?/gi;
const numbers = [];
let match;
while ((match = priceRegex.exec(priceDisplay)) !== null) {
const numPart = match[1].replace(/,/g, '');
if (isNaN(parseFloat(numPart))) {
continue;
}
let num = parseFloat(numPart);
const multiplier = match[2]?.toLowerCase();
if (multiplier === 'k') {
num *= 1000;
} else if (multiplier === 'm') {
num *= 1000000;
}
numbers.push(num);
}
if (numbers.length === 0) {
return { low: null, high: null, isNumeric: false };
}
if (numbers.length === 1) {
return { low: numbers[0], high: numbers[0], isNumeric: true };
} else {
numbers.sort((a, b) => a - b);
return { low: numbers[0], high: numbers[numbers.length - 1], isNumeric: true };
}
}
let SCRIPT_CURRENT_SORT_KEY = (() => {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get('activeSort') || 'price-asc';
})();
function getCurrentlySelectedSortKey() {
let selectedSort = 'price-asc';
const largeScreenSortInput = document.querySelector('input[name="filters-sort-types"]');
if (largeScreenSortInput && largeScreenSortInput.value) {
selectedSort = largeScreenSortInput.value;
} else {
const smallScreenSelect = document.getElementById('small-screen-sort-type-filter');
if (smallScreenSelect && smallScreenSelect.value) {
selectedSort = smallScreenSelect.value;
} else {
return SCRIPT_CURRENT_SORT_KEY;
}
}
SCRIPT_CURRENT_SORT_KEY = selectedSort;
return selectedSort;
}
async function sortAllListingsDataGlobally() {
if (!allListingsData || allListingsData.length === 0) {
return;
}
const sortKey = getCurrentlySelectedSortKey();
const transportSortEnabled = await GM_getValue(TRANSPORT_SORT_ENABLED_KEY, true);
const getTransportWeight = (time) => {
if (time === null || typeof time === 'undefined') return 5;
if (time === Infinity) return 4;
if (time <= 5) return 0;
if (time <= 10) return 1;
if (time <= 15) return 2;
return 3; // > 15 mins
};
allListingsData.sort((a, b) => {
if (!a || !b) return 0;
if (transportSortEnabled) {
const weightA = getTransportWeight(a.walkingTime);
const weightB = getTransportWeight(b.walkingTime);
if (weightA !== weightB) {
return weightA - weightB;
}
}
if (sortKey === 'smallest-room-desc') {
const roomsA = a.ocrRooms || [];
const roomsB = b.ocrRooms || [];
const hasOcrDataA = roomsA.length > 0;
const hasOcrDataB = roomsB.length > 0;
if (hasOcrDataB && !hasOcrDataA) return 1;
if (hasOcrDataA && !hasOcrDataB) return -1;
if (hasOcrDataA && hasOcrDataB) {
const bedroomsA = roomsA.filter(r => r.area && BEDROOM_KEYWORDS.some(k => r.label.toUpperCase().includes(k.toUpperCase())));
const bedroomsB = roomsB.filter(r => r.area && BEDROOM_KEYWORDS.some(k => r.label.toUpperCase().includes(k.toUpperCase())));
const studiesA = roomsA.filter(r => r.area && STUDY_KEYWORDS.some(k => r.label.toUpperCase().includes(k.toUpperCase())));
const studiesB = roomsB.filter(r => r.area && STUDY_KEYWORDS.some(k => r.label.toUpperCase().includes(k.toUpperCase())));
const storageA = roomsA.filter(r => STORAGE_KEYWORDS.some(k => r.label.toUpperCase().includes(k.toUpperCase())));
const storageB = roomsB.filter(r => STORAGE_KEYWORDS.some(k => r.label.toUpperCase().includes(k.toUpperCase())));
const primaryRoomCountA = bedroomsA.length + studiesA.length;
const primaryRoomCountB = bedroomsB.length + studiesB.length;
if (primaryRoomCountB !== primaryRoomCountA) {
return primaryRoomCountB - primaryRoomCountA;
}
const calculateScore = (bedrooms, studies, storage) => {
let score = 0;
const primaryRoomCount = bedrooms.length + studies.length;
const minBedroomArea = bedrooms.length > 0 ? Math.min(...bedrooms.map(r => r.area)) : 0;
const totalBedroomArea = bedrooms.length > 0 ? bedrooms.reduce((sum, r) => sum + r.area, 0) : 0;
score += minBedroomArea;
score += totalBedroomArea * 0.01; // Tie-breaker for larger total area
if (storage.length > 0) {
if (minBedroomArea >= 16.8 && primaryRoomCount >= 2) {
score += 1000;
}
const totalStorageArea = storage.reduce((sum, s) => sum + (s.area || 0), 0);
const dimensionlessStorageCount = storage.filter(s => !s.area).length;
const storageValue = 1.0 + (totalStorageArea * 0.2) + (dimensionlessStorageCount * 0.5);
score += storageValue;
}
return score;
};
const scoreA = calculateScore(bedroomsA, studiesA, storageA);
const scoreB = calculateScore(bedroomsB, studiesB, storageB);
if (scoreB !== scoreA) {
return scoreB - scoreA;
}
}
}
const priceA = parsePriceFromDisplay(a.price?.display);
const priceB = parsePriceFromDisplay(b.price?.display);
const direction = (sortKey === 'price-desc') ? -1 : 1;
const finalDirection = (sortKey === 'smallest-room-desc') ? 1 : direction;
if (priceA.isNumeric && !priceB.isNumeric) return -1 * finalDirection;
if (!priceA.isNumeric && priceB.isNumeric) return 1 * finalDirection;
if (!priceA.isNumeric && !priceB.isNumeric) {
const textA = a.price?.display || '';
const textB = b.price?.display || '';
return textA.localeCompare(textB) * finalDirection;
}
if (priceA.low !== priceB.low) {
return (priceA.low - priceB.low) * finalDirection;
}
if (priceA.high !== priceB.high) {
return (priceA.high - priceB.high) * finalDirection;
}
return 0;
});
}
function reorderListingElementsInDOM() {
if (!allListingsData || allListingsData.length === 0) {
return;
}
const containers = document.querySelectorAll('.tiered-results--exact, .tiered-results--surrounding, .results-card-list');
if (containers.length === 0) {
return;
}
containers.forEach(container => {
if (container.style.display !== 'flex') {
container.style.display = 'flex';
container.style.flexDirection = 'column';
}
});
const liElementsMap = new Map();
document.querySelectorAll('.tiered-results--exact > li, .tiered-results--surrounding > li, .results-card-wrapper > li, .results-card-list > li').forEach(li => {
const listingData = li._listingData || domListingsMap.get(li);
if (listingData && listingData.id) {
liElementsMap.set(listingData.id, li);
}
else {
li.classList.add('rea-ad-hidden');
}
});
allListingsData.forEach((listingData, index) => {
const liElement = liElementsMap.get(listingData.id);
if (liElement) {
liElement.style.order = index;
}
});
}
function extractRealEstateListings(htmlContent) {
try {
let scriptTextContent = null;
const scriptStartMarker = "window.ArgonautExchange=";
const scriptTagOpen = "<script>";
const scriptTagClose = "</script>";
let scriptStartIndex = htmlContent.indexOf(scriptStartMarker);
if (scriptStartIndex === -1) {
const scriptTagsRegex = /<script\b[^>]*>([\s\S]*?)<\/script>/gi;
let match;
while ((match = scriptTagsRegex.exec(htmlContent)) !== null) {
if (match[1] && match[1].includes(scriptStartMarker)) {
scriptTextContent = match[1];
break;
}
}
} else {
const actualScriptStart = htmlContent.lastIndexOf(scriptTagOpen, scriptStartIndex);
const scriptEnd = htmlContent.indexOf(scriptTagClose, scriptStartIndex);
if (actualScriptStart !== -1 && scriptEnd !== -1) {
scriptTextContent = htmlContent.substring(actualScriptStart + scriptTagOpen.length, scriptEnd);
}
}
if (!scriptTextContent) {
return [];
}
const jsonLikeString = scriptTextContent
.replace(scriptStartMarker, "")
.replace(/;\s*$/, "");
const argonautData = JSON.parse(jsonLikeString);
const urqlPath1 = argonautData?.["resi-property_listing-experience-web"]?.urqlClientCache;
const urqlPath2 = argonautData?.["resi-property_buy_listing-experience-web"]?.urqlClientCache;
const urqlClientCacheString = urqlPath1 || urqlPath2;
if (!urqlClientCacheString) {
return [];
}
const urqlClientCache = JSON.parse(urqlClientCacheString);
const cacheEntryKey = Object.keys(urqlClientCache)[0];
const cacheEntry = urqlClientCache[cacheEntryKey];
if (!cacheEntry || !cacheEntry.data) {
return [];
}
const dataString = cacheEntry.data;
const searchData = JSON.parse(dataString);
let rawItems = [];
if (searchData?.rentSearch?.results) {
const results = searchData.rentSearch.results;
if (Array.isArray(results.exact?.items)) rawItems = rawItems.concat(results.exact.items);
if (Array.isArray(results.surrounding?.items)) rawItems = rawItems.concat(results.surrounding.items);
}
else if (searchData?.buySearch?.results) {
const results = searchData.buySearch.results;
if (Array.isArray(results.exact?.items)) rawItems = rawItems.concat(results.exact.items);
if (Array.isArray(results.surrounding?.items)) rawItems = rawItems.concat(results.surrounding.items);
}
else if (searchData?.projectSearch?.results) {
const results = searchData.projectSearch.results;
if (Array.isArray(results.exact?.items)) rawItems = rawItems.concat(results.exact.items);
if (Array.isArray(results.surrounding?.items)) rawItems = rawItems.concat(results.surrounding.items);
}
if (rawItems.length === 0) {
return [];
}
const extractedListings = rawItems.map(item => item.listing).filter(Boolean);
return extractedListings;
} catch (error) {
console.error("[REA Enhancer] Error during extraction or parsing:", error);
return [];
}
}
async function fetchAndExtractListings(url) {
return new Promise(resolve => {
GM_xmlhttpRequest({
method: "GET",
url: url,
onload: function(response) {
if (response.status >= 200 && response.status < 300) {
const listings = extractRealEstateListings(response.responseText);
resolve(listings);
} else {
console.error(`[REA Enhancer] Failed to fetch new page. Status: ${response.status}`);
resolve([]);
}
},
onerror: function(error) {
console.error('[REA Enhancer] Error during GM_xmlhttpRequest.', error);
resolve([]);
}
});
});
}
function normalizeListingSize(liElement) {
const card = liElement.querySelector('.residential-card');
if (card) {
card.classList.remove('residential-card--compressed-view');
}
const carousel = liElement.querySelector('.property-card-hero');
if (carousel) {
carousel.classList.remove('property-card-hero--extra-small');
carousel.classList.add('property-card-hero--large');
}
}
function cleanupListings() {
const listingElements = document.querySelectorAll('.tiered-results--exact > li, .tiered-results--surrounding > li, .results-card-wrapper > li');
listingElements.forEach(liElement => {
liElement.querySelector('.rea-custom-description')?.remove();
liElement.querySelector('.rea-walking-time-container')?.remove();
liElement.querySelector('.rea-ocr-container')?.remove();
liElement.removeAttribute('data-floorplan-set');
});
}
function addDescriptionsAndLinkData() {
if (allListingsData.length === 0) {
return;
}
allListingsData.forEach((listingData) => {
const listingId = listingData.id;
const liElement = document.querySelector(`a.details-link[href*="-${listingId}"]`)?.closest('li');
if (liElement) {
liElement._listingData = listingData;
domListingsMap.set(liElement, listingData);
const addressEl = liElement.querySelector('.residential-card__address-heading a');
if (addressEl) {
addressEl.textContent = listingData.address?.display?.fullAddress || addressEl.textContent;
addressEl.href = listingData._links?.canonical?.href || addressEl.href;
}
const contentWrapper = liElement.querySelector('.residential-card__content');
if (contentWrapper && listingData.description) {
let descElement = contentWrapper.querySelector('.rea-custom-description');
if (!descElement) {
descElement = document.createElement('div');
descElement.className = 'rea-custom-description';
contentWrapper.appendChild(descElement);
}
descElement.innerHTML = listingData.description.replace(/\n/g, '<br>');
let ocrContainer = contentWrapper.querySelector('.rea-ocr-container');
if (!ocrContainer) {
ocrContainer = document.createElement('div');
ocrContainer.className = 'rea-ocr-container';
contentWrapper.appendChild(ocrContainer);
}
}
if (document.getElementById('reaWalkingTimeCheckbox')?.checked) {
const headingEl = liElement.querySelector('.residential-card__address-heading');
if (headingEl) {
updateWalkingTime(listingData, headingEl, 'train');
}
}
updateListingImage(liElement, listingData);
normalizeListingSize(liElement);
}
});
}
async function updateListingImage(liElement, listingData) {
if (liElement.dataset.floorplanSet === 'true' || !listingData) {
return;
}
const floorplan = listingData.media?.floorplans?.[0];
if (!floorplan?.templatedUrl) return;
let finalImageUrl = floorplan.templatedUrl.replace('{size}', '6096x3056-resize,r=00,g=00,b=00');
const totalImages = listingData.media?.images?.length || 0;
const prevButton = liElement.querySelector('.carousel__left');
if (totalImages > 1 && prevButton) {
liElement.dataset.floorplanSet = 'true';
const initialSrc = liElement.querySelector('.property-image__img')?.src;
const newImageSrc = await new Promise(resolve => {
const observer = new MutationObserver(() => {
const currentImg = liElement.querySelector('.property-image__img');
if (currentImg && currentImg.src !== initialSrc) {
observer.disconnect();
resolve(currentImg.src);
}
});
observer.observe(liElement, { subtree: true, attributes: true, attributeFilter: ['src'] });
prevButton.click();
});
if (newImageSrc && !newImageSrc.includes(floorplan.templatedUrl.split('/')[4])) {
finalImageUrl = newImageSrc.replace('800x600', '6096x3056-resize,r=00,g=00,b=00');
}
}
if (document.getElementById('reaOcrCheckbox')?.checked) {
const contentWrapper = liElement.querySelector('.residential-card__content');
if (contentWrapper) {
processFloorplanOcr(contentWrapper, listingData, finalImageUrl);
}
}
}
function applyFilters() {
const addressKeywordsInput = document.getElementById('reaAddressFilterInput');
const descriptionKeywordsInput = document.getElementById('reaDescriptionFilterInput');
const whitelistInput = document.getElementById('reaAddressWhitelistInput');
const hideNoPriceCheckbox = document.getElementById('reaHideNoPriceCheckbox');
if (!addressKeywordsInput || !descriptionKeywordsInput || !whitelistInput) {
return;
}
const addressKeywordsRaw = addressKeywordsInput.value.toLowerCase();
const descriptionKeywordsRaw = descriptionKeywordsInput.value.toLowerCase();
const whitelistRaw = whitelistInput.value.toLowerCase();
const hideNoPrice = hideNoPriceCheckbox && hideNoPriceCheckbox.checked;
const addressFilterTerms = addressKeywordsRaw ? addressKeywordsRaw.split(',').map(k => k.trim()).filter(k => k) : [];
const descriptionFilterTerms = descriptionKeywordsRaw ? descriptionKeywordsRaw.split(',').map(k => k.trim()).filter(k => k) : [];
const whitelistTerms = whitelistRaw ? whitelistRaw.split(',').map(k => k.trim()).filter(k => k) : [];
let visibleCount = 0;
const listingElements = document.querySelectorAll('.tiered-results--exact > li, .tiered-results--surrounding > li, .results-card-wrapper > li');
listingElements.forEach(liElement => {
const listingData = liElement._listingData || domListingsMap.get(liElement);
let hide = false;
if (listingData) {
if (hideNoPrice && !(listingData.price?.display || "").includes('$')) {
hide = true;
}
const fullAddress = (listingData.address?.display?.fullAddress || "").toLowerCase();
if (!hide && whitelistTerms.length > 0 && !whitelistTerms.some(term => fullAddress.includes(term))) {
hide = true;
}
if (!hide && addressFilterTerms.length > 0) {
if (addressFilterTerms.some(term => fullAddress.includes(term))) {
hide = true;
}
}
if (!hide && descriptionFilterTerms.length > 0) {
const description = (listingData.description || "").toLowerCase();
if (descriptionFilterTerms.some(term => description.includes(term))) {
hide = true;
}
}
}
if (hide) {
liElement.classList.add('listing-hidden-by-filter');
liElement.style.display = 'none';
} else {
liElement.classList.remove('listing-hidden-by-filter');
liElement.style.display = '';
visibleCount++;
}
});
const resultsCountElement = document.querySelector('.results-count__wrapper .Text__Typography-sc-1103tao-0');
if (resultsCountElement) {
if (addressFilterTerms.length > 0 || descriptionFilterTerms.length > 0 || whitelistTerms.length > 0 || hideNoPrice) {
if (!resultsCountElement.dataset.originalText) {
resultsCountElement.dataset.originalText = resultsCountElement.textContent;
}
resultsCountElement.textContent = `${visibleCount} listings (filtered)`;
} else if (resultsCountElement.dataset.originalText) {
resultsCountElement.textContent = resultsCountElement.dataset.originalText;
}
}
}
async function updateSortOptionsUI() {
const sortEnabled = await GM_getValue(ROOM_SIZE_SORT_ENABLED_KEY, true);
const optionValue = 'smallest-room-desc';
const optionText = 'Rooms (Largest First)';
const smallScreenSelect = document.getElementById('small-screen-sort-type-filter');
if (smallScreenSelect) {
let option = smallScreenSelect.querySelector(`option[value="${optionValue}"]`);
if (sortEnabled && !option) {
option = document.createElement('option');
option.value = optionValue;
option.textContent = optionText;
smallScreenSelect.appendChild(option);
} else if (!sortEnabled && option) {
if (smallScreenSelect.value === optionValue) {
smallScreenSelect.value = 'price-asc';
}
option.remove();
}
}
const largeScreenButton = document.querySelector('.LargeScreenFilter__LargeScreenWrapper-sc-1u61mdy-0 .styles__DisplayContainer-sc-l614ls-4');
if (largeScreenButton && !largeScreenButton.dataset.listenerAttached) {
largeScreenButton.dataset.listenerAttached = 'true';
largeScreenButton.addEventListener('click', () => {
setTimeout(async () => {
const sortEnabledOnClick = await GM_getValue(ROOM_SIZE_SORT_ENABLED_KEY, true);
const listbox = document.querySelector('ul[role="listbox"]');
if (!listbox) return;
let customItem = listbox.querySelector(`li[data-value="${optionValue}"]`);
if (sortEnabledOnClick && !customItem) {
const firstItem = listbox.querySelector('li');
if (firstItem) {
customItem = firstItem.cloneNode(true);
customItem.dataset.value = optionValue;
const textSpan = customItem.querySelector('span:not([class])');
if (textSpan) textSpan.textContent = optionText;
listbox.appendChild(customItem);
}
} else if (!sortEnabledOnClick && customItem) {
customItem.remove();
}
}, 100);
});
}
}
function getTotalPages() {
const pageLinks = document.querySelectorAll('.styles__PageNumberContainer-sc-1tm2eg4-1 a[aria-label^="Go to page "]');
if (pageLinks.length === 0) {
return 1;
}
const lastPageLink = pageLinks[pageLinks.length - 1];
const pageNumber = parseInt(lastPageLink.textContent, 10);
return isNaN(pageNumber) ? 1 : pageNumber;
}
function createListingElement(listingData) {
const li = document.createElement('li');
li._listingData = listingData;
domListingsMap.set(li, listingData);
const priceDisplay = listingData.price?.display ? `<span class="property-price ">${listingData.price.display}</span>` : '';
const initialImageUrl = (listingData.media?.floorplans?.[0]?.templatedUrl || listingData.media?.mainImage?.templatedUrl || '').replace('{size}', '800x600');
const fullAddress = listingData.address?.display?.fullAddress || 'Address unavailable';
const canonicalLink = listingData._links?.canonical?.href || '#';
const beds = listingData.generalFeatures?.bedrooms?.value || 0;
const baths = listingData.generalFeatures?.bathrooms?.value || 0;
const parking = listingData.generalFeatures?.parkingSpaces?.value || 0;
const propertyType = listingData.propertyType?.display || '';
let featuresHtml = `
<div class="Inline__InlineContainer-sc-1ppy24s-0 dRNKVz">
${beds ? `<li aria-label="${beds} bedrooms" class="styles__Li-sc-xhfhyt-0 iMbEAF"><svg class="CK__Icon--medium "><use href="#ck-sprite-consumerXpBedMd"></use></svg><p class="Text__Typography-sc-1103tao-0 ljPIrY">${beds}</p></li>` : ''}
${baths ? `<li aria-label="${baths} bathroom" class="styles__Li-sc-xhfhyt-0 iMbEAF"><svg class="CK__Icon--medium "><use href="#ck-sprite-consumerXpBathMd"></use></svg><p class="Text__Typography-sc-1103tao-0 ljPIrY">${baths}</p></li>` : ''}
${parking ? `<li aria-label="${parking} car space" class="styles__Li-sc-xhfhyt-0 iMbEAF"><svg class="CK__Icon--medium "><use href="#ck-sprite-consumerXpCarMd"></use></svg><p class="Text__Typography-sc-1103tao-0 ljPIrY">${parking}</p></li>` : ''}
</div>
${propertyType ? `<span aria-hidden="true">•</span><p class="Text__Typography-sc-1103tao-0 ljPIrY">${propertyType}</p>` : ''}
`;
li.innerHTML = `
<article class="Card__Box-sc-3mecgt-0 gMijQj PropertyCardLayout__StyledCard-sc-1qkhjdh-0 results-card residential-card">
<div class="residential-card__image-wrapper">
<div class="residential-card__image">
<div class="carousel residential-card__images property-card-hero property-card-hero--large">
<div class="property-image">
<img class="property-image__img" src="${initialImageUrl}" alt="${fullAddress}">
</div>
</div>
</div>
</div>
<div class="residential-card__content-wrapper" role="presentation">
<div class="residential-card__content" role="presentation">
<div>
<div class="residential-card__title">
<div class="residential-card__price" role="presentation">${priceDisplay}</div>
</div>
<div>
<h2 class="residential-card__address-heading">
<a href="${canonicalLink}" class="details-link residential-card__details-link">${fullAddress}</a>
</h2>
</div>
</div>
<ul class="styles__Wrapper-sc-xhfhyt-1 bGRFcz residential-card__primary" aria-label="Property features">
${featuresHtml}
</ul>
<div class="rea-custom-description">${(listingData.description || '').replace(/\n/g, '<br>')}</div>
<div class="rea-ocr-container"></div>
</div>
</div>
</article>
`;
return li;
}
async function bruteForceSort(listings) {
const transportSortEnabled = await GM_getValue(TRANSPORT_SORT_ENABLED_KEY, true);
const getTransportWeight = (time) => {
if (time === null || typeof time === 'undefined') return 5;
if (time === Infinity) return 4;
if (time <= 5) return 0;
if (time <= 10) return 1;
if (time <= 15) return 2;
return 3; // > 15 mins
};
listings.sort((a, b) => {
if (!a || !b) return 0;
if (transportSortEnabled) {
const weightA = getTransportWeight(a.walkingTime);
const weightB = getTransportWeight(b.walkingTime);
if (weightA !== weightB) {
return weightA - weightB;
}
}
const roomsA = a.ocrRooms || [];
const roomsB = b.ocrRooms || [];
const hasOcrDataA = roomsA.length > 0;
const hasOcrDataB = roomsB.length > 0;
if (hasOcrDataB && !hasOcrDataA) return 1;
if (hasOcrDataA && !hasOcrDataB) return -1;
if (hasOcrDataA && hasOcrDataB) {
const bedroomsA = roomsA.filter(r => r.area && BEDROOM_KEYWORDS.some(k => r.label.toUpperCase().includes(k.toUpperCase())));
const bedroomsB = roomsB.filter(r => r.area && BEDROOM_KEYWORDS.some(k => r.label.toUpperCase().includes(k.toUpperCase())));
const studiesA = roomsA.filter(r => r.area && STUDY_KEYWORDS.some(k => r.label.toUpperCase().includes(k.toUpperCase())));
const studiesB = roomsB.filter(r => r.area && STUDY_KEYWORDS.some(k => r.label.toUpperCase().includes(k.toUpperCase())));
const storageA = roomsA.filter(r => STORAGE_KEYWORDS.some(k => r.label.toUpperCase().includes(k.toUpperCase())));
const storageB = roomsB.filter(r => STORAGE_KEYWORDS.some(k => r.label.toUpperCase().includes(k.toUpperCase())));
const primaryRoomCountA = bedroomsA.length + studiesA.length;
const primaryRoomCountB = bedroomsB.length + studiesB.length;
if (primaryRoomCountB !== primaryRoomCountA) {
return primaryRoomCountB - primaryRoomCountA;
}
const calculateScore = (bedrooms, studies, storage) => {
let score = 0;
const primaryRoomCount = bedrooms.length + studies.length;
const minBedroomArea = bedrooms.length > 0 ? Math.min(...bedrooms.map(r => r.area)) : 0;
const totalBedroomArea = bedrooms.length > 0 ? bedrooms.reduce((sum, r) => sum + r.area, 0) : 0;
score += minBedroomArea;
score += totalBedroomArea * 0.01;
if (storage.length > 0) {
if (minBedroomArea >= 16.8 && primaryRoomCount >= 2) {
score += 1000;
}
const totalStorageArea = storage.reduce((sum, s) => sum + (s.area || 0), 0);
const dimensionlessStorageCount = storage.filter(s => !s.area).length;
const storageValue = 1.0 + (totalStorageArea * 0.2) + (dimensionlessStorageCount * 0.5);
score += storageValue;
}
return score;
};
const scoreA = calculateScore(bedroomsA, studiesA, storageA);
const scoreB = calculateScore(bedroomsB, studiesB, storageB);
if (scoreB !== scoreA) {
return scoreB - scoreA;
}
}
const priceA = parsePriceFromDisplay(a.price?.display);
const priceB = parsePriceFromDisplay(b.price?.display);
if (priceA.isNumeric && !priceB.isNumeric) return -1;
if (!priceA.isNumeric && priceB.isNumeric) return 1;
if (!priceA.isNumeric && !priceB.isNumeric) {
return (a.price?.display || '').localeCompare(b.price?.display || '');
}
if (priceA.low !== priceB.low) {
return priceA.low - priceB.low;
}
if (priceA.high !== priceB.high) {
return priceA.high - priceB.high;
}
return 0;
});
}
async function startBruteForceScrape() {
const button = document.getElementById('reaBruteForceButton');
if (!button) return;
button.disabled = true;
const originalText = button.textContent;
const totalPages = getTotalPages();
let allScrapedListings = [];
const listContainer = document.querySelector('.tiered-results--exact, .results-card-list');
const surroundingContainer = document.querySelector('.tiered-results--surrounding');
const paginationContainer = document.querySelector('.Pagination__StyledPaginationSummary-sc-1luljj5-0')?.parentElement;
if (listContainer) listContainer.innerHTML = ''; // Clear list immediately
if (surroundingContainer) surroundingContainer.innerHTML = '';
if (paginationContainer) paginationContainer.style.display = 'none';
const currentUrl = new URL(window.location.href);
const basePath = currentUrl.pathname.replace(/\/list-\d+/, '');
const searchParams = currentUrl.search;
const MIN_INTERVAL_MS = 1000;
for (let i = 1; i <= totalPages; i++) {
const loopStartTime = Date.now();
button.textContent = `Fetching & Processing Page ${i} of ${totalPages}...`;
const pageUrl = `${currentUrl.origin}${basePath}/list-${i}${searchParams}`;
const listingsFromPage = await fetchAndExtractListings(pageUrl);
if (listingsFromPage.length === 0) {
console.warn(`[REA Enhancer] No listings on page ${i}. Assuming end of results.`);
break;
}
allScrapedListings.push(...listingsFromPage);
const pageElements = listingsFromPage.map(listingData => {
const liElement = createListingElement(listingData);
listContainer.appendChild(liElement);
return liElement;
});
const processingPromises = pageElements.map(li => {
const listingData = li._listingData;
if (!listingData) return Promise.resolve();
const processSingleListing = async () => {
if (document.getElementById('reaWalkingTimeCheckbox')?.checked) {
const headingEl = li.querySelector('.residential-card__address-heading');
if (headingEl) {
await updateWalkingTime(listingData, headingEl, 'train');
}
}
const floorplan = listingData.media?.floorplans?.[0];
if (document.getElementById('reaOcrCheckbox')?.checked && floorplan?.templatedUrl) {
const floorplanUrl = floorplan.templatedUrl.replace('{size}', '6096x3056-resize,r=00,g=00,b=00');
const contentWrapper = li.querySelector('.residential-card__content');
if (contentWrapper) {
await processFloorplanOcr(contentWrapper, listingData, floorplanUrl);
}
}
};
return processSingleListing();
});
await Promise.all(processingPromises);
if (i < totalPages) {
const elapsedTime = Date.now() - loopStartTime;
const delayNeeded = MIN_INTERVAL_MS - elapsedTime;
if (delayNeeded > 0) {
await new Promise(resolve => setTimeout(resolve, delayNeeded));
}
}
}
button.textContent = 'Performing Final Sort...';
await new Promise(resolve => setTimeout(resolve, 50));
await bruteForceSort(allScrapedListings);
allListingsData = allScrapedListings;
reorderListingElementsInDOM();
applyFilters();
const resultsCountEl = document.querySelector('.results-count__wrapper .Text__Typography-sc-1103tao-0');
if (resultsCountEl) {
resultsCountEl.textContent = `${allScrapedListings.length} properties found`;
resultsCountEl.dataset.originalText = resultsCountEl.textContent;
}
button.textContent = 'All Pages Loaded!';
setTimeout(() => {
button.textContent = originalText;
button.disabled = false;
if (ocrWorker) {
ocrWorker.terminate();
ocrWorker = null;
ocrWorkerPromise = null;
}
}, 5000);
}
async function createFilterUI(targetElement) {
if (document.querySelector('.rea-custom-filter-container')) {
return;
}
const filterContainer = document.createElement('div');
filterContainer.className = 'rea-custom-filter-container';
const bruteForceButton = document.createElement('button');
bruteForceButton.id = 'reaBruteForceButton';
bruteForceButton.textContent = 'Load & Enhance All Pages';
bruteForceButton.onclick = startBruteForceScrape;
filterContainer.appendChild(bruteForceButton);
const whitelistGroup = document.createElement('div');
whitelistGroup.className = 'rea-custom-filter-group';
const whitelistLabel = document.createElement('label');
whitelistLabel.setAttribute('for', 'reaAddressWhitelistInput');
whitelistLabel.textContent = 'Only show if address contains (comma-separated):';
const whitelistInput = document.createElement('input');
whitelistInput.type = 'text';
whitelistInput.id = 'reaAddressWhitelistInput';
whitelistInput.placeholder = 'e.g., sydney, bondi, melbourne';
whitelistInput.addEventListener('input', applyFilters);
whitelistGroup.appendChild(whitelistLabel);
whitelistGroup.appendChild(whitelistInput);
filterContainer.appendChild(whitelistGroup);
const addressGroup = document.createElement('div');
addressGroup.className = 'rea-custom-filter-group';
const addressLabel = document.createElement('label');
addressLabel.setAttribute('for', 'reaAddressFilterInput');
addressLabel.textContent = 'Hide if address contains (comma-separated):';
const addressInput = document.createElement('input');
addressInput.type = 'text';
addressInput.id = 'reaAddressFilterInput';
addressInput.placeholder = 'e.g., room, unit 10a, level';
addressInput.value = DEFAULT_ADDRESS_KEYWORDS;
addressInput.addEventListener('input', applyFilters);
addressGroup.appendChild(addressLabel);
addressGroup.appendChild(addressInput);
const descriptionGroup = document.createElement('div');
descriptionGroup.className = 'rea-custom-filter-group';
const descriptionLabel = document.createElement('label');
descriptionLabel.setAttribute('for', 'reaDescriptionFilterInput');
descriptionLabel.textContent = 'Hide if description contains (comma-separated):';
const descriptionInput = document.createElement('input');
descriptionInput.type = 'text';
descriptionInput.id = 'reaDescriptionFilterInput';
descriptionInput.placeholder = 'e.g., shared, room for rent';
descriptionInput.value = DEFAULT_DESCRIPTION_KEYWORDS;
descriptionInput.addEventListener('input', applyFilters);
descriptionGroup.appendChild(descriptionLabel);
descriptionGroup.appendChild(descriptionInput);
filterContainer.appendChild(addressGroup);
filterContainer.appendChild(descriptionGroup);
const walkGroup = document.createElement('div');
walkGroup.className = 'rea-checkbox-group rea-custom-filter-group';
const walkLabel = document.createElement('label');
const walkCheck = document.createElement('input');
walkCheck.type = 'checkbox';
walkCheck.id = 'reaWalkingTimeCheckbox';
walkCheck.checked = await GM_getValue(WALKING_TIME_ENABLED_KEY, true);
walkLabel.append(walkCheck, 'Show walking time to nearest public transport');
walkCheck.addEventListener('change', async (event) => {
const isChecked = event.target.checked;
await GM_setValue(WALKING_TIME_ENABLED_KEY, isChecked);
if (isChecked && !ORS_API_KEY) {
promptForApiKey();
}
document.querySelectorAll('.tiered-results--exact > li, .tiered-results--surrounding > li, .results-card-wrapper > li').forEach((li) => {
const headingEl = li.querySelector('.residential-card__address-heading');
if (!headingEl) return;
const container = headingEl.querySelector('.rea-walking-time-container');
if (walkCheck.checked && !container) {
if (li._listingData) updateWalkingTime(li._listingData, headingEl, 'train');
} else if (!walkCheck.checked && container) {
container.remove();
}
});
});
walkGroup.appendChild(walkLabel);
const ocrGroup = document.createElement('div');
ocrGroup.className = 'rea-checkbox-group rea-custom-filter-group';
const ocrLabel = document.createElement('label');
const ocrCheck = document.createElement('input');
ocrCheck.type = 'checkbox';
ocrCheck.id = 'reaOcrCheckbox';
ocrCheck.checked = await GM_getValue(OCR_ENABLED_KEY, true);
ocrLabel.append(ocrCheck, 'Scan floorplans for room sizes (Beta)');
ocrCheck.addEventListener('change', async (event) => {
await GM_setValue(OCR_ENABLED_KEY, event.target.checked);
});
ocrGroup.appendChild(ocrLabel);
const sortGroup = document.createElement('div');
sortGroup.className = 'rea-checkbox-group rea-custom-filter-group';
const sortLabel = document.createElement('label');
const sortCheck = document.createElement('input');
sortCheck.type = 'checkbox';
sortCheck.id = 'reaRoomSortCheckbox';
sortCheck.checked = await GM_getValue(ROOM_SIZE_SORT_ENABLED_KEY, true);
sortLabel.append(sortCheck, 'Enable sorting by room size');
sortCheck.addEventListener('change', async (event) => {
await GM_setValue(ROOM_SIZE_SORT_ENABLED_KEY, event.target.checked);
await updateSortOptionsUI();
if (!event.target.checked && getCurrentlySelectedSortKey() === 'smallest-room-desc') {
handleSortUiChange();
}
});
sortGroup.appendChild(sortLabel);
const transportSortGroup = document.createElement('div');
transportSortGroup.className = 'rea-checkbox-group rea-custom-filter-group';
const transportSortLabel = document.createElement('label');
const transportSortCheck = document.createElement('input');
transportSortCheck.type = 'checkbox';
transportSortCheck.id = 'reaTransportSortCheckbox';
transportSortCheck.checked = await GM_getValue(TRANSPORT_SORT_ENABLED_KEY, true);
transportSortLabel.append(transportSortCheck, 'Prioritize sorting by walking distance');
transportSortCheck.addEventListener('change', async (event) => {
await GM_setValue(TRANSPORT_SORT_ENABLED_KEY, event.target.checked);
handleSortUiChange();
});
transportSortGroup.appendChild(transportSortLabel);
const hideNoPriceGroup = document.createElement('div');
hideNoPriceGroup.className = 'rea-checkbox-group rea-custom-filter-group';
const hideNoPriceLabel = document.createElement('label');
const hideNoPriceCheck = document.createElement('input');
hideNoPriceCheck.type = 'checkbox';
hideNoPriceCheck.id = 'reaHideNoPriceCheckbox';
hideNoPriceCheck.checked = await GM_getValue(HIDE_NO_PRICE_ENABLED_KEY, true);
hideNoPriceLabel.append(hideNoPriceCheck, 'Hide listings without a price');
hideNoPriceCheck.addEventListener('change', async (event) => {
await GM_setValue(HIDE_NO_PRICE_ENABLED_KEY, event.target.checked);
applyFilters();
});
hideNoPriceGroup.appendChild(hideNoPriceLabel);
filterContainer.appendChild(walkGroup);
filterContainer.appendChild(ocrGroup);
filterContainer.appendChild(sortGroup);
filterContainer.appendChild(transportSortGroup);
filterContainer.appendChild(hideNoPriceGroup);
if (targetElement.firstChild) {
targetElement.insertBefore(filterContainer, targetElement.firstChild);
} else {
targetElement.appendChild(filterContainer);
}
await updateSortOptionsUI();
}
async function initSearchPage(url = null) {
ORS_API_KEY = await GM_getValue('ORS_API_KEY', '');
const walkingTimeEnabled = await GM_getValue(WALKING_TIME_ENABLED_KEY, true);
if (walkingTimeEnabled && !ORS_API_KEY) {
promptForApiKey();
}
if (url) {
allListingsData = await fetchAndExtractListings(url);
} else {
allListingsData = extractRealEstateListings(document.documentElement.outerHTML);
}
if (allListingsData.length === 0) {
console.warn("[REA Enhancer] No listings data extracted. Aborting further processing for this page.");
return;
}
mainSearchLogic();
}
async function handleSortUiChange() {
setTimeout(async () => {
await sortAllListingsDataGlobally();
reorderListingElementsInDOM();
applyFilters();
}, 200);
}
function setupSortChangeListeners() {
const smallScreenSort = document.getElementById('small-screen-sort-type-filter');
if (smallScreenSort) {
smallScreenSort.addEventListener('change', handleSortUiChange);
}
const largeScreenSortWrapper = document.querySelector('.LargeScreenFilter__LargeScreenWrapper-sc-1u61mdy-0');
if (largeScreenSortWrapper) {
const sortDisplayElement = largeScreenSortWrapper.querySelector('.styles__DisplayContainer-sc-l614ls-4 span.sort-control');
if (sortDisplayElement) {
const sortObserver = new MutationObserver(() => {
handleSortUiChange();
});
sortObserver.observe(sortDisplayElement, { childList: true, characterData: true, subtree: true });
}
largeScreenSortWrapper.addEventListener('click', (event) => {
const customOption = event.target.closest('li[data-value="smallest-room-desc"]');
if (customOption) {
const hiddenInput = largeScreenSortWrapper.querySelector('input[name="filters-sort-types"]');
const displaySpan = largeScreenSortWrapper.querySelector('span.sort-control');
if(hiddenInput) hiddenInput.value = 'smallest-room-desc';
if(displaySpan) displaySpan.textContent = 'Rooms (Largest First)';
handleSortUiChange();
}
}, true);
}
}
async function applyInitialSortState() {
const sortEnabled = await GM_getValue(ROOM_SIZE_SORT_ENABLED_KEY, true);
if (!sortEnabled) return;
const optionValue = 'smallest-room-desc';
const optionText = 'Rooms (Largest First)';
const largeScreenInput = document.querySelector('input[name="filters-sort-types"]');
if (largeScreenInput) {
largeScreenInput.value = optionValue;
}
const largeScreenDisplay = document.querySelector('.LargeScreenFilter__LargeScreenWrapper-sc-1u61mdy-0 .styles__DisplayContainer-sc-l614ls-4 span.sort-control');
if (largeScreenDisplay) {
largeScreenDisplay.textContent = optionText;
}
const smallScreenSelect = document.getElementById('small-screen-sort-type-filter');
if (smallScreenSelect) {
if (!smallScreenSelect.querySelector(`option[value="${optionValue}"]`)) {
const option = document.createElement('option');
option.value = optionValue;
option.textContent = optionText;
smallScreenSelect.appendChild(option);
}
smallScreenSelect.value = optionValue;
}
}
async function mainSearchLogic() {
const filterInsertionPointSelector = '.View__StyledBottomWrapper-sc-5bovee-2';
const listContainerSelector = '.tiered-results--exact, .tiered-results--surrounding, .results-card-list';
const runEnhancements = async () => {
await applyInitialSortState();
cleanupListings();
addDescriptionsAndLinkData();
await sortAllListingsDataGlobally();
reorderListingElementsInDOM();
applyFilters();
setupSortChangeListeners();
};
const setupPersistentObserver = (listElement) => {
const persistentObserver = new MutationObserver((mutationsList, obs) => {
if (window.location.href !== obs.currentUrl) {
obs.disconnect();
domListingsMap.clear();
allListingsData = [];
setTimeout(() => initSearchPage(window.location.href), 100);
return;
}
for(const mutation of mutationsList) {
if (mutation.type === 'attributes' && (mutation.attributeName === 'src' || mutation.attributeName === 'srcset')) {
const img = mutation.target;
const liElement = img.closest('li');
if (liElement) {
const listingData = liElement._listingData || domListingsMap.get(liElement);
if (listingData) {
updateListingImage(liElement, listingData);
}
}
}
}
});
persistentObserver.observe(listElement, {
subtree: true,
attributes: true,
attributeFilter: ['src', 'srcset']
});
persistentObserver.currentUrl = window.location.href;
};
const waitForDomToSettleAndProcess = (listElement) => {
let debounceTimer;
const settlementObserver = new MutationObserver(() => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
settlementObserver.disconnect();
runEnhancements();
setupPersistentObserver(listElement);
}, 500);
});
settlementObserver.observe(listElement, { childList: true, subtree: true });
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
settlementObserver.disconnect();
runEnhancements();
setupPersistentObserver(listElement);
}, 500);
};
waitForElement(filterInsertionPointSelector, async (insertionElement) => {
if (!document.querySelector('.rea-custom-filter-container')) {
await createFilterUI(insertionElement.parentElement);
}
waitForElement(listContainerSelector, waitForDomToSettleAndProcess);
});
}
function extractSingleListingData(htmlContent) {
try {
const scriptStartMarker = "window.ArgonautExchange=";
const scriptTagOpen = "<script>";
const scriptTagClose = "</script>";
const scriptStartIndex = htmlContent.indexOf(scriptStartMarker);
if (scriptStartIndex === -1) return null;
const actualScriptStart = htmlContent.lastIndexOf(scriptTagOpen, scriptStartIndex);
const scriptEnd = htmlContent.indexOf(scriptTagClose, scriptStartIndex);
if (actualScriptStart === -1 || scriptEnd === -1) return null;
const scriptTextContent = htmlContent.substring(actualScriptStart + scriptTagOpen.length, scriptEnd);
const jsonLikeString = scriptTextContent
.replace(scriptStartMarker, "")
.replace(/;\s*$/, "");
const argonautData = JSON.parse(jsonLikeString);
const urqlClientCacheString = argonautData?.["resi-property_listing-experience-web"]?.urqlClientCache;
if (!urqlClientCacheString) return null;
const urqlClientCache = JSON.parse(urqlClientCacheString);
const cacheEntryKey = Object.keys(urqlClientCache)[0];
const cacheEntry = urqlClientCache[cacheEntryKey];
if (!cacheEntry || !cacheEntry.data) return null;
const dataString = cacheEntry.data;
const pageData = JSON.parse(dataString);
if (pageData?.details?.listing) {
return pageData.details.listing;
}
return null;
} catch (error) {
console.error("[REA Enhancer] Error extracting single listing data:", error);
return null;
}
}
async function initPdpPage() {
ORS_API_KEY = await GM_getValue('ORS_API_KEY', '');
const walkingTimeEnabled = await GM_getValue(WALKING_TIME_ENABLED_KEY, true);
const ocrEnabled = await GM_getValue(OCR_ENABLED_KEY, true);
if (walkingTimeEnabled && !ORS_API_KEY) {
promptForApiKey();
}
const listingData = extractSingleListingData(document.documentElement.outerHTML);
if (!listingData || !listingData.id) {
return;
}
waitForElement('.divided-content', (insertionPoint) => {
if (document.querySelector('.rea-pdp-custom-info')) return;
const customInfoContainer = document.createElement('div');
customInfoContainer.className = 'rea-pdp-custom-info';
customInfoContainer.style.backgroundColor = 'var(--ck-backgroundPrimary, #fff)';
customInfoContainer.style.padding = '1rem 1.5rem';
customInfoContainer.style.borderRadius = '0.75rem';
customInfoContainer.style.marginBottom = '1.5rem';
customInfoContainer.style.boxShadow = '0 0.0625rem 0.25rem 0 rgba(0, 0, 0, 0.16)';
customInfoContainer.style.border = '1px solid var(--ck-borderSecondary, #E5E3E8)';
insertionPoint.insertBefore(customInfoContainer, insertionPoint.firstChild);
if (walkingTimeEnabled) {
const walkingTimeParent = document.createElement('div');
walkingTimeParent.style.display = 'flex';
walkingTimeParent.style.alignItems = 'center';
walkingTimeParent.style.marginBottom = ocrEnabled && listingData.media?.floorplans?.[0] ? '8px' : '0';
customInfoContainer.appendChild(walkingTimeParent);
updateWalkingTime(listingData, walkingTimeParent, 'train');
}
if (ocrEnabled) {
const floorplan = listingData.media?.floorplans?.[0];
if (floorplan?.templatedUrl) {
const ocrParentContainer = document.createElement('div');
customInfoContainer.appendChild(ocrParentContainer);
const floorplanUrl = floorplan.templatedUrl.replace('{size}', '6096x3056-resize,r=00,g=00,b=00');
processFloorplanOcr(ocrParentContainer, listingData, floorplanUrl);
}
}
});
}
if (window.location.pathname.startsWith('/property-')) {
initPdpPage();
} else if (window.location.pathname.startsWith('/buy/')) {
initSearchPage();
}
})();