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 (Modified)
// @namespace http://tampermonkey.net/
// @version 0.4
// @description Adds descriptions, walking time, and address/description keyword filters on realestate.com.au search results.
// @author Chris Malone
// @license MIT
// @match https://www.realestate.com.au/buy/*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @connect nominatim.openstreetmap.org
// @connect overpass-api.de
// @connect api.openrouteservice.org
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
let ORS_API_KEY = '';
let allListingsData = [];
let domListingsMap = new Map();
const ADDRESS_FILTER_KEY = 'rea_address_filter_keywords';
const DESCRIPTION_FILTER_KEY = 'rea_description_filter_keywords';
const WALKING_TIME_ENABLED_KEY = 'rea_walking_time_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;
/* max-height: 100px; */
/* overflow-y: auto; */
line-height: 1.4;
}
.rea-custom-description br {
margin-bottom: 0.5em;
display: block;
content: "";
}
.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;
}
.listing-hidden-by-filter {
/* display: none !important; */
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;
}
.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;
position: revert !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)); // Wait 5 seconds before retry
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. The walking time feature is disabled until a key is provided.");
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, addressEl, mode) {
const headingEl = addressEl.parentElement;
if (!headingEl) return;
headingEl.querySelector('.rea-walking-time-container')?.remove();
if (!addressEl.dataset.originalText) addressEl.dataset.originalText = addressEl.textContent;
addressEl.textContent = addressEl.dataset.originalText;
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);
headingEl.appendChild(container);
const fullAddress = listingData.address?.display?.fullAddress;
if (!fullAddress) { statusElement.textContent = 'Address unavailable'; statusElement.className = 'walking-time-status error'; return; }
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) { statusElement.textContent = 'Could not locate address'; statusElement.className = 'walking-time-status error'; return; }
const { result: stop, fromCache: sCache } = await findNearestStop(mode, coords.lat, coords.lon);
await throttleIfNeeded(sCache);
if (!stop) { statusElement.textContent = `No nearby ${mode} stop found`; statusElement.className = 'walking-time-status error'; return; }
const { result: time, fromCache: tCache } = await getWalkingTime(mode, coords, { lat: stop.lat, lon: stop.lon });
await throttleIfNeeded(tCache);
if (typeof time !== 'number') { statusElement.textContent = 'Walking time unavailable'; statusElement.className = 'walking-time-status error'; return; }
const stopName = stop.tags?.name || `${mode} stop`;
const icon = mode === 'train' ? '🚆' : '🚌';
statusElement.textContent = `🚶 ${time} min to ${icon} ${stopName}`;
statusElement.className = 'walking-time-status success';
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, addressEl, otherMode);
};
container.appendChild(toggleButton);
}
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 null;
}
const match = priceDisplay.match(/\$?(\d{1,3}(?:,\d{3})*|\d+)/);
if (match && match[1]) {
return parseInt(match[1].replace(/,/g, ''), 10);
}
return null;
}
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 {
console.warn("[REA Enhancer] Could not determine current sort key from UI. Using default/previous:", SCRIPT_CURRENT_SORT_KEY);
return SCRIPT_CURRENT_SORT_KEY;
}
}
SCRIPT_CURRENT_SORT_KEY = selectedSort;
return selectedSort;
}
function sortAllListingsDataGlobally() {
if (!allListingsData || allListingsData.length === 0) {
console.warn("[REA Enhancer] No allListingsData to sort.");
return;
}
const sortKey = getCurrentlySelectedSortKey();
console.log(`[REA Enhancer] Sorting allListingsData by: ${sortKey}`);
allListingsData.sort((a, b) => {
if (!a || !b) return 0;
switch (sortKey) {
case 'price-asc': {
const priceA = parsePriceFromDisplay(a.price?.display);
const priceB = parsePriceFromDisplay(b.price?.display);
if (priceA === null && priceB === null) return 0;
if (priceA === null) return 1;
if (priceB === null) return -1;
return priceA - priceB;
}
case 'price-desc': {
const priceA = parsePriceFromDisplay(a.price?.display);
const priceB = parsePriceFromDisplay(b.price?.display);
if (priceA === null && priceB === null) return 0;
if (priceA === null) return 1;
if (priceB === null) return -1;
return priceB - priceA;
}
default:
return 0;
}
});
}
function reorderListingElementsInDOM() {
if (!allListingsData || allListingsData.length === 0) {
console.warn("[REA Enhancer] No sorted data available to reorder DOM elements.");
return;
}
const listContainerParent = document.querySelector('.results-list');
const exactUl = document.querySelector('.tiered-results--exact');
const surroundingUl = document.querySelector('.tiered-results--surrounding');
const resultsCardListUl = document.querySelector('.results-card-list');
let primaryUlForReappending = exactUl || resultsCardListUl || surroundingUl;
if (!primaryUlForReappending && listContainerParent) {
console.warn("[REA Enhancer] Target ULs not found, creating a new one in .results-list");
primaryUlForReappending = document.createElement('ul');
primaryUlForReappending.className = 'tiered-results tiered-results--exact rea-custom-sorted-list'; // Give it a class
listContainerParent.appendChild(primaryUlForReappending);
} else if (!primaryUlForReappending) {
console.error("[REA Enhancer] No suitable UL container found to append sorted listings.");
return;
}
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);
li.remove();
} else {
// li.remove();
}
});
if (exactUl) exactUl.innerHTML = '';
if (surroundingUl) surroundingUl.innerHTML = '';
if (resultsCardListUl) resultsCardListUl.innerHTML = '';
if (primaryUlForReappending.className.includes('rea-custom-sorted-list')) {
primaryUlForReappending.innerHTML = '';
}
let appendedCount = 0;
allListingsData.forEach(listingData => {
const liElement = liElementsMap.get(listingData.id);
if (liElement) {
primaryUlForReappending.appendChild(liElement);
appendedCount++;
}
});
console.log(`[REA Enhancer] DOM reordered: ${appendedCount} listings appended to primary UL.`);
}
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) {
console.error("[REA Enhancer] Could not find the target script tag containing 'window.ArgonautExchange'.");
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) {
console.error("[REA Enhancer] Path to 'urqlClientCache' not found in ArgonautExchange data. Checked 'resi-property_listing-experience-web' and 'resi-property_buy_listing-experience-web'.");
return [];
}
const urqlClientCache = JSON.parse(urqlClientCacheString);
const cacheEntryKey = Object.keys(urqlClientCache)[0];
const cacheEntry = urqlClientCache[cacheEntryKey];
if (!cacheEntry || !cacheEntry.data) {
console.error("[REA Enhancer] Cache entry or its 'data' property not found in urqlClientCache.");
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);
console.log(`[REA Enhancer] Found ${results.exact.items.length} items in rentSearch.results.exact.items`);
}
if (Array.isArray(results.surrounding?.items)) {
rawItems = rawItems.concat(results.surrounding.items);
console.log(`[REA Enhancer] Found ${results.surrounding.items.length} items in rentSearch.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);
console.log(`[REA Enhancer] Found ${results.exact.items.length} items in buySearch.results.exact.items`);
}
if (Array.isArray(results.surrounding?.items)) {
rawItems = rawItems.concat(results.surrounding.items);
console.log(`[REA Enhancer] Found ${results.surrounding.items.length} items in buySearch.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);
console.log(`[REA Enhancer] Found ${results.exact.items.length} items in projectSearch.results.exact.items`);
}
if (Array.isArray(results.surrounding?.items)) {
rawItems = rawItems.concat(results.surrounding.items);
console.log(`[REA Enhancer] Found ${results.surrounding.items.length} items in projectSearch.results.surrounding.items`);
}
}
if (rawItems.length === 0) {
console.error("[REA Enhancer] No listing items found in 'exact' or 'surrounding' paths for rent, buy, or project search results.", searchData);
return [];
}
const extractedListings = rawItems.map(item => item.listing).filter(Boolean); // Filter out any undefined/null listings
if (extractedListings.length === 0 && rawItems.length > 0) {
console.warn("[REA Enhancer] Found raw items, but no valid 'listing' property in them.", rawItems);
}
console.log(`[REA Enhancer] Extracted ${extractedListings.length} listings from ${rawItems.length} raw items.`);
return extractedListings;
} catch (error) {
console.error("[REA Enhancer] Error during extraction or parsing:", error);
return [];
}
}
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 addDescriptionsAndLinkData() {
if (allListingsData.length === 0) {
console.warn("[REA Enhancer] No listings data to process for adding descriptions.");
return;
}
const listingElements = document.querySelectorAll('.tiered-results--exact > li, .tiered-results--surrounding > li, .results-card-wrapper > li');
console.log(`[REA Enhancer] Found ${listingElements.length} listing card elements in DOM.`);
listingElements.forEach(liElement => {
const linkElement = liElement.querySelector('a.details-link[href^="/property-"]');
if (!linkElement) return;
const href = linkElement.getAttribute('href');
const idMatch = href.match(/-(\d+)$/);
if (!idMatch) return;
const listingId = idMatch[1];
const listingData = allListingsData.find(l => l.id === listingId);
if (listingData) {
liElement._listingData = listingData;
domListingsMap.set(liElement, listingData);
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>');
}
if (document.getElementById('reaWalkingTimeCheckbox')?.checked) {
const addressEl = liElement.querySelector('.residential-card__address-heading a');
if (addressEl) {
// Avoid re-adding if it's already there from a previous run
if (!addressEl.parentElement.querySelector('.rea-walking-time-container')) {
updateWalkingTime(listingData, addressEl, 'train'); // Default to train
}
}
}
updateListingImage(liElement, listingData);
normalizeListingSize(liElement);
} else {
console.warn(`[REA Enhancer] No data found for listing ID ${listingId} from DOM element.`);
}
});
console.log(`[REA Enhancer] Linked ${domListingsMap.size} DOM elements to listing data.`);
}
function updateListingImage(liElement, listingData) {
const floorplan = listingData.media?.floorplans?.[0];
if (!floorplan?.templatedUrl) return;
const floorplanUrl = floorplan.templatedUrl.replace('{size}', '2000x1600-resized');
const thumbnailElement = liElement.querySelector('.residential-card__image img');
if (thumbnailElement && thumbnailElement.src !== floorplanUrl) {
thumbnailElement.removeAttribute('srcset');
thumbnailElement.src = floorplanUrl;
}
const sourceElement = liElement.querySelector('.residential-card__image picture source');
if (sourceElement && sourceElement.srcset !== floorplanUrl) {
sourceElement.srcset = floorplanUrl;
}
const propertyImageDiv = liElement.querySelector('.residential-card__image .property-image');
if (propertyImageDiv && propertyImageDiv.getAttribute('data-url') !== floorplanUrl) {
propertyImageDiv.setAttribute('data-url', floorplanUrl);
}
}
function applyFilters() {
const addressKeywordsInput = document.getElementById('reaAddressFilterInput');
const descriptionKeywordsInput = document.getElementById('reaDescriptionFilterInput');
const whitelistInput = document.getElementById('reaAddressWhitelistInput');
if (!addressKeywordsInput || !descriptionKeywordsInput || !whitelistInput) {
console.warn("[REA Enhancer] Filter input fields not found. Cannot apply filters.");
return;
}
const addressKeywordsRaw = addressKeywordsInput.value.toLowerCase();
const descriptionKeywordsRaw = descriptionKeywordsInput.value.toLowerCase();
const whitelistRaw = whitelistInput.value.toLowerCase();
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) {
const fullAddress = (listingData.address?.display?.fullAddress || "").toLowerCase();
if (whitelistTerms.length > 0 && !whitelistTerms.some(term => fullAddress.includes(term))) {
hide = true;
}
if (addressFilterTerms.length > 0) {
const fullAddress = (listingData.address?.display?.fullAddress || "").toLowerCase();
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;
}
}
} else {
// If no data linked, can't filter, so keep visible or decide a default
// console.warn("[REA Enhancer] No data found for a listing element during filtering:", liElement);
}
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++;
}
});
console.log(`[REA Enhancer] Filtering complete. ${visibleCount} listings visible.`);
const resultsCountElement = document.querySelector('.results-count__wrapper .Text__Typography-sc-1103tao-0'); // Example, might need adjustment
if (resultsCountElement) {
if (addressFilterTerms.length > 0 || descriptionFilterTerms.length > 0) {
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 createFilterUI(targetElement) {
const filterContainer = document.createElement('div');
filterContainer.className = 'rea-custom-filter-container';
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 addressEl = li.querySelector('.residential-card__address-heading a');
if (!addressEl) return;
const container = addressEl.parentElement.querySelector('.rea-walking-time-container');
if (walkCheck.checked && !container) {
if (li._listingData) updateWalkingTime(li._listingData, addressEl, 'train');
} else if (!walkCheck.checked && container) {
container.remove();
addressEl.textContent = addressEl.dataset.originalText || '';
}
});
});
walkGroup.appendChild(walkLabel);
filterContainer.appendChild(walkGroup);
// --- End walking time checkbox ---
if (targetElement.firstChild) {
targetElement.insertBefore(filterContainer, targetElement.firstChild);
} else {
targetElement.appendChild(filterContainer);
}
console.log("[REA Enhancer] Filter UI created.");
}
async function init() {
console.log("[REA Enhancer] Initializing script...");
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();
return;
}
allListingsData = extractRealEstateListings(document.documentElement.outerHTML);
if (allListingsData.length === 0) {
console.warn("[REA Enhancer] No listings data extracted. Aborting further processing.");
const observer = new MutationObserver((mutationsList, obs) => {
for(const mutation of mutationsList) {
if (mutation.type === 'childList') {
for (const node of mutation.addedNodes) {
if (node.tagName === 'SCRIPT' && node.textContent.includes("window.ArgonautExchange=")) {
console.log("[REA Enhancer] ArgonautExchange script detected after initial load. Re-attempting extraction.");
obs.disconnect();
allListingsData = extractRealEstateListings(document.documentElement.outerHTML);
if (allListingsData.length > 0) {
mainLogic();
}
return;
}
}
}
}
});
observer.observe(document.head, { childList: true, subtree: true });
setTimeout(async () => {
if (allListingsData.length === 0) {
console.log("[REA Enhancer] Retrying extraction after a delay.");
allListingsData = extractRealEstateListings(document.documentElement.outerHTML);
if (allListingsData.length > 0) mainLogic();
else console.warn("[REA Enhancer] Still no data after delay.");
}
}, 3000);
return;
}
mainLogic();
}
function handleSortUiChange() {
console.log("[REA Enhancer] Sort UI change detected.");
setTimeout(() => {
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((mutationsList) => {
for (let mutation of mutationsList) {
if (mutation.type === 'childList' || mutation.type === 'characterData') {
handleSortUiChange();
return;
}
}
});
sortObserver.observe(sortDisplayElement, { childList: true, characterData: true, subtree: true });
console.log("[REA Enhancer] Observer attached to large screen sort display.");
} else {
largeScreenSortWrapper.addEventListener('click', (event) => {
const target = event.target;
if (target.closest('ul[role="listbox"] li') || target.closest('.styles__InputBorder-sc-w8zjqe-1')) {
handleSortUiChange();
}
}, true);
console.log("[REA Enhancer] Click listener attached to large screen sort wrapper (fallback).");
}
}
console.log("[REA Enhancer] Sort change listeners setup.");
}
function mainLogic() {
const filterInsertionPointSelector = '.View__StyledBottomWrapper-sc-5bovee-2';
const listContainerSelector = '.tiered-results--exact, .tiered-results--surrounding, .results-card-list';
waitForElement(filterInsertionPointSelector, async (insertionElement) => {
await createFilterUI(insertionElement.parentElement);
waitForElement(listContainerSelector, (listElement) => {
addDescriptionsAndLinkData();
sortAllListingsDataGlobally();
reorderListingElementsInDOM();
applyFilters();
setupSortChangeListeners();
const observer = new MutationObserver((mutationsList) => {
let needsReProcess = false;
for (const mutation of mutationsList) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
const addedListItems = Array.from(mutation.addedNodes).some(node => node.tagName === 'LI');
if (addedListItems) {
needsReProcess = true;
break;
}
}
if (mutation.type === 'attributes' && (mutation.attributeName === 'src' || mutation.attributeName === 'srcset')) {
const img = mutation.target;
const liElement = img.closest('li');
if (liElement && (liElement._listingData || domListingsMap.get(liElement))) {
updateListingImage(liElement, liElement._listingData || domListingsMap.get(liElement));
}
}
}
if (needsReProcess) {
console.log("[REA Enhancer] List items changed, re-processing descriptions and filters.");
if (window.location.href !== observer.currentUrl) {
console.log("[REA Enhancer] URL changed, attempting to re-extract all data.");
allListingsData = extractRealEstateListings(document.documentElement.outerHTML);
observer.currentUrl = window.location.href;
}
addDescriptionsAndLinkData();
sortAllListingsDataGlobally();
reorderListingElementsInDOM();
applyFilters();
}
});
observer.observe(listElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['src', 'srcset'] });
observer.currentUrl = window.location.href;
});
});
}
// Start the process
init();
})();