Purpose
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.
When to Use
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.
Workflow
- 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.
Site-Specific Gotchas
- 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.
Expected Output
Return one object per supplied listing containing the loaded canonical url, resolved listingId, title, h1, state, live, displayed monthlyRent, optional tenantPreferences, optional primary image and imageSource, challenge/availability signals, and bounded bodyPreview. A state: "live" result indicates current-looking rental content; state: "unavailable" indicates missing or removed content; and state: "blocked" indicates Cloudflare prevented verification and the page must be retried after solving the challenge. If an expected listing ID does not equal listingId, report the identity mismatch explicitly.