Extract property details and sale history

Site realestate.com.auTask extract-property-details-and-sale-historyVersion v7Updated Sep 15, 2026Category real-estate

Extracts structured listing facts, sold details, agent information, and all available historical sale records from a realestate.com.au property page, optionally reached through an agency catalogue or property search result. This skill was captured from a live agent session on realestate.com.au and is published here as a reusable recipe for agents.

NoteSelectors and URL schemes drift as sites change. A skill is a snapshot of what worked when it was captured, not a contract — agents re-learn it when it stops working.

Use this skill for a realestate.com.au property URL, agency catalogue entry, or property-search result when the caller needs normalized listing facts, sold price and date, agent details, coordinates, and every available prior sale record.

Use Cases

  • Extract facts from a supplied property listing URL.
  • Reach a property through an agency catalogue and extract its details.
  • Resolve a property from a supplied search-results URL and classify zero, handful, or large results.
  • Return sold, current, asking, or price-range information.
  • Collect all visible prior sale dates and prices, including expanded history.

Automation Flow

  1. For an agency entry point, build https://www.realestate.com.au/find-agent/{suburb}-{state}-{postcode}?pid=rea:hp:search-box-search:find-agent, goto it, and resolve the agency and property links with:
(() => {
const norm = s => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase();
const agency = norm('{agency-name}');
const address = norm('{property-address}');
const links = [...document.querySelectorAll('a[href]')].map(a => ({text: a.innerText.trim(), href: a.href}));
const agencyLink = links.find(x => norm(x.text).includes(agency) || norm(x.href).includes(agency.replace(/[^a-z0-9]+/g, '-')));
const propertyLink = links.find(x => norm(x.text).includes(address) || norm(x.href).includes(address.replace(/[^a-z0-9]+/g, '-')));
return {agencyUrl: agencyLink?.href || null, propertyUrl: propertyLink?.href || null, links};
})()
  1. For a supplied search-results URL, goto it, wait for deferred results, classify the result count from visible result cards, and select a property link only when one is present. Do not treat an empty or blocked document as zero results.
  2. Goto the resolved propertyUrl, or use the supplied canonical URL; construct https://www.realestate.com.au/property-{type}-{state}-{suburb}-{listing-id} only when all URL components and the opaque listing ID are supplied.
  3. In the same BQL call, wait 4–8 seconds for deferred content, expand a visible sale-history control if present, wait for the expanded records, then run this evaluate() on the loaded page:
(() => {
const clean = v => String(v ?? '').replace(/\s+/g, ' ').trim() || null;
const text = el => clean(el?.textContent);
const first = sels => { for (const s of sels) { const el = document.querySelector(s); if (el) return text(el); } return null; };
const body = clean(document.body.innerText) || '';
const title = clean(document.title) || '';
if (/\b429\b|too many requests|access denied|captcha|blocked/i.test(title + ' ' + body) || body.length < 80) return {blocked: true, reason: 'Listing content was not rendered; do not interpret fields as absent.', title, body: body.slice(0, 500)};
const jsonLd = [...document.querySelectorAll('script[type="application/ld+json"]')].flatMap(s => { try { const x = JSON.parse(s.textContent); return Array.isArray(x) ? x : [x]; } catch { return []; } });
const graph = jsonLd.flatMap(x => Array.isArray(x?.['@graph']) ? x['@graph'] : [x]);
const entity = graph.find(x => x && (x['@type'] === 'Residence' || x['@type'] === 'SingleFamilyResidence' || x['@type'] === 'House' || x.address || x.geo)) || {};
const address = entity.address || {};
const geo = entity.geo || {};
const labelled = label => { const re = new RegExp('(?:^|\\n)\\s*' + label + '\\s*[:\\n]\\s*([^\\n]+)', 'i'); return body.match(re)?.[1]?.trim() || null; };
const stat = (labels, selectors) => first(selectors) || labels.map(labelled).find(Boolean) || null;
const price = first(['[data-testid*="price"]', '[class*="price"]', '[data-testid*="Price"]']) || body.match(/(?:AU\$|\$)\s?[\d,.]+(?:\s*[-–]\s*(?:AU\$|\$)?\s?[\d,.]+)?/i)?.[0] || null;
const sales = [...document.querySelectorAll('li, tr, article, [data-testid*="sale"], [class*="sale"], [class*="history"]')].map(el => {
const value = text(el); if (!value || !/(sold|sale|\$\s?[\d,.]+)/i.test(value)) return null;
const date = value.match(/\b(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\s+\d{4}\b|\b\d{1,2}[/-]\d{1,2}[/-]\d{2,4}\b/i)?.[0] || null;
const salePrice = value.match(/(?:AU\$|\$)\s?[\d,.]+/i)?.[0] || null;
return date || salePrice ? {date, price: salePrice, text: value} : null;
}).filter(Boolean).filter((x, i, a) => a.findIndex(y => y.text === x.text) === i);
return {
blocked: false,
address: clean(typeof address === 'string' ? address : [address.streetAddress, address.addressLocality, address.addressRegion, address.postalCode].filter(Boolean).join(', ')) || first(['[data-testid*="address"]', '[class*="address"]']),
coordinates: {latitude: geo.latitude ?? null, longitude: geo.longitude ?? null},
listingStatus: entity.availability || first(['[data-testid*="status"]', '[class*="status"]']) || labelled('Status'),
soldPrice: body.match(/(?:sold|sale price)[^\n]*(?:AU\$|\$)\s?[\d,.]+/i)?.[0] || price,
soldDate: body.match(/(?:sold|sale date)[^\n]*\b(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\s+\d{4}\b/i)?.[0] || null,
currentPrice: price,
beds: stat(['Bedrooms', 'Beds'], ['[data-testid*="bed"]', '[aria-label*="bed" i]']),
baths: stat(['Bathrooms', 'Baths'], ['[data-testid*="bath"]', '[aria-label*="bath" i]']),
parking: stat(['Parking', 'Car spaces', 'Car space'], ['[data-testid*="parking"]', '[aria-label*="parking" i]']),
landSize: stat(['Land size', 'Land area'], ['[data-testid*="land"]', '[class*="land"]']),
floorArea: stat(['Floor size', 'Building size', 'Floor area'], ['[data-testid*="floor"]', '[class*="floor"]']),
propertyType: entity['@type'] || first(['[data-testid*="property-type"]', '[class*="property-type"]']) || labelled('Property type'),
agent: first(['[data-testid*="agent"]', '[class*="agent"]']) || labelled('Agent'),
agency: first(['[data-testid*="agency"]', '[class*="agency"]']) || labelled('Agency'),
historicalSales: sales
};
})()

Params

ParamWhat it doesExample value
listing-urlCanonical property page to openhttps://www.realestate.com.au/property/10-wattle-st-bendigo-vic-3550/
search-urlSearch-results page to classify and resolvehttps://www.realestate.com.au/
agency-nameAgency text used to resolve the catalogue linkBarry Plant Bundoora
property-addressAddress text used to resolve the property link14 Greenwood Drive, Bundoora VIC 3083
suburbFind-agent URL suburb segmentbundoora
stateFind-agent URL state segmentvic
postcodeFind-agent URL postcode segment3083
property-typeURL segment when constructing a listing URLhouse
listing-idOpaque numeric listing ID already supplied by the caller444842740

Possible Friction Points

TriggerAction
HTTP 429, empty snapshot, or bot/interstitial pageRetry the BQL session with an Australian residential proxy; if it persists, return blocked: true and do not report null fields as listing facts.
Homepage or search page returns HTTP 429 even through an Australian residential proxyStop repeated navigation attempts and return blocked: true with the interstitial or spelling-correction outcome marked as prevented; do not classify the search as zero results.
Australian residential proxy still returns HTTP 429 after an additional waitStop repeated navigation attempts and return blocked: true; do not classify the search as zero results.
Agency catalogue has no accessible linksRetry the BQL session with an Australian residential proxy; do not fabricate an agency or property URL.
Consent or interstitial screenComplete the visible consent/interstitial in the same BQL session, then wait and rerun the extractor.
Sale-history records are collapsed or deferredExpand the visible history control, wait for records to render, then rerun the extractor; an empty initial array is not proof of no history.
JSON-LD, agency-link, search-result, or history selectors are unverified on the rendered pageTreat extracted values as unconfirmed until a non-blocked page validates those selectors.

Call it

GET https://production-sfo.browserless.io/skills?token=TOKEN-HERE&domain=realestate.com.au&task=extract-property-details-and-sale-history