Verify the current status of one or more PropertyGuru Malaysia rental listings and, when live, return displayed monthly rent, identifying fields, visible tenant-preference information, and the primary social-preview image when available.
Use Cases
Use when the caller provides one or more canonical PropertyGuru listing URLs. Listing URLs have the form https://www.propertyguru.com.my/property-listing/{slug}-{numeric-listing-id}; preserve supplied slugs and opaque IDs rather than guessing either value. If only a numeric {listing-id} and identifying text are supplied, resolve the canonical URL from PropertyGuru search results before verification.
Automation Flow
- If
{listing-url}is supplied, navigate directly to it withwaitUntil: "domcontentloaded". Do not visit the homepage or use the search UI. If an expected{listing-id}is also supplied, verify that the URL path ends in-{listing-id}before treating the page as the requested listing. - If only
{listing-id}plus a name, location, or agent query is supplied, first navigate directly tohttps://www.propertyguru.com.my/property-for-rent?freetext={url-encoded-query}(or/room-for-rent?freetext={url-encoded-query}for room-only searches). After rendering and solving any challenge, find an anchor matchinga[href*="/property-listing/"]whose canonical URL path ends in-{listing-id}. Use that returned canonical URL for the verification navigation; never invent the slug. - Wait for client rendering. If a Cloudflare challenge appears, solve it and wait until listing content replaces the challenge. Do not extract or classify the challenge page.
- On the rendered listing page, run this evaluator as-is:
(() => {
const clean = s => (s || '').replace(/\s+/g, ' ').trim();
const body = document.body?.innerText || '';
const title = clean(document.title);
const h1 = clean(document.querySelector('h1')?.textContent);
const lines = body.split(/\r?\n/).map(clean).filter(Boolean);
const monthlyPattern = /RM\s*[0-9][0-9,]*(?:\.[0-9]+)?\s*(?:\/\s*(?:month|mo|bulan)|per\s+month)/i;
const genericPattern = /RM\s*[0-9][0-9,]*(?:\.[0-9]+)?/i;
const monthlyRent = body.match(monthlyPattern)?.[0]?.trim() ||
document.querySelector('[class*="listing-price"], [class*="price"]')?.textContent?.trim() ||
body.match(genericPattern)?.[0]?.trim() || null;
const tenantLabel = /^(tenant\s*preferences?|preferred\s*tenants?|suitable\s*for|tenant type)\s*:?/i;
const sectionStop = /^(property details?|facilities|amenities|description|location|contact|agent|photos?|floor plans?|mortgage|nearby|similar properties|about this property)\b/i;
const labelIndex = lines.findIndex(line => tenantLabel.test(line));
let tenantPreferences = null;
if (labelIndex >= 0) {
const label = lines[labelIndex];
const inline = label.replace(tenantLabel, '').replace(/^\s*[::-]\s*/, '').trim();
const values = [];
if (inline) values.push(inline);
for (let i = labelIndex + 1; i < lines.length && values.length < 20; i++) {
if (sectionStop.test(lines[i]) && values.length) break;
if (tenantLabel.test(lines[i]) && i !== labelIndex) break;
values.push(lines[i]);
}
tenantPreferences = {
label,
values: values.filter((v, i, a) => a.indexOf(v) === i),
text: values.join(' | ') || null
};
}
const ogImage = document.querySelector('meta[property="og:image"]')?.content?.trim() || null;
const fallbackImage = [...document.querySelectorAll('img')]
.map(img => img.currentSrc || img.src || img.getAttribute('data-src') || null)
.find(Boolean) || null;
const lower = `${title} ${h1} ${body}`.toLowerCase();
const challenge = /checking your browser|verify you are human|just a moment|cloudflare|enable javascript and cookies/.test(lower);
const unavailable = /404|page not found|listing not found|no longer available|property is no longer|listing has been removed|does not exist/.test(lower);
const hasListingSignals = !!h1 && (!!monthlyRent || /for rent|rent|rental|bedroom|bathroom|sq\s*ft|sqft|property type/.test(lower));
const state = challenge ? 'blocked' : (unavailable || !hasListingSignals ? 'unavailable' : 'live');
const idMatch = location.pathname.match(/-(\d{6,})(?:\/)?$/);
return {
live: state === 'live',
state,
url: location.href,
listingId: idMatch ? idMatch[1] : null,
title: title.slice(0, 200) || null,
h1: h1 ? h1.slice(0, 200) : null,
monthlyRent: monthlyRent ? clean(monthlyRent).slice(0, 100) : null,
tenantPreferences,
image: ogImage || fallbackImage,
imageSource: ogImage ? 'og:image' : (fallbackImage ? 'img-fallback' : null),
signals: { challenge, unavailable, hasListingSignals },
bodyPreview: body.slice(0, 500)
};
})()- For multiple supplied URLs, verify each independently, or batch direct navigations and evaluations in one browser-agent call where supported. Return one result per listing. When a caller names an expected ID, compare it with the extractor's
listingIdand report any mismatch rather than silently substituting a different listing.
Possible Friction Points
- PropertyGuru may present a Cloudflare anti-bot challenge after navigation; solve it before extracting status, rent, tenant preferences, or images.
- A successful HTTP navigation alone does not prove that a listing is live. Require a meaningful heading plus rental/listing content, ideally including a price.
- Tenant-preference wording may appear under
Tenant Preferences,Preferred Tenants,Suitable For, orTenant Type; the extractor returns visible lines following the first matching label until a likely next section heading. - If no tenant-preference label is present,
tenantPreferencesisnull; do not infer preferences from the listing title, agent name, or room type. - Treat 404, not-found, removed, and no-longer-available text as unavailable even if residual page markup is present.
- Listing IDs are opaque. When only an ID is supplied, resolve it from a direct search result URL and select the anchor whose canonical path ends in
-{listing-id}; never fabricate a slug or ID. - When both an expected ID and a canonical URL are supplied, verify the numeric suffix exactly. Similar slugs can refer to different opaque listing IDs; do not report a neighboring URL as verification of the requested listing.
- Monthly rent is normally exposed as an
RMamount followed by/ month; the extractor prefers that form, then uses price selectors and a genericRMfallback. - The primary listing image is commonly exposed through
meta[property="og:image"]; opening or scrolling the gallery is unnecessary. - If
og:imageis absent, the first DOM image may be a logo, placeholder, or thumbnail. Label it withimageSource: "img-fallback"and do not assume it is the primary property photo. - Image URLs may be CDN URLs and can change independently of the listing URL; return the URL exactly as exposed by the page.
- Class names such as
[class*="listing-price"]and[class*="price"]are fallback selectors and may change; body-text patterns are the primary extraction method.