propertyguru.com.sg logo
propertyguru.com.sg

search-rental-property

Installation

Adds this website's skill for your agents

 

Summary

Search PropertyGuru Singapore for rooms or whole units (condo/HDB/landed) for rent by location, budget, bedrooms, and room-vs-unit, returning each listing's price, type, address, nearest MRT, availability, agent, and URL plus the total match count. Read-only.

FIG. 01
FIG. 02
FIG. 03
FIG. 04
SKILL.md
199 lines

PropertyGuru Singapore Rental Search

Purpose

Search propertyguru.com.sg for rooms or whole units (apartments/condos, HDB flats, landed houses) available for rent in Singapore, filtered by location keyword, monthly budget, bedrooms, and unit-vs-room, and return a structured list of listings — title, monthly price, property type, address, nearest MRT, availability, listing agent, and canonical listing URL — plus the total match count. Read-only: never contacts an agent, enquires, or submits anything.

When to Use

  • "Find me a room/apartment to rent in {area} under S${budget}/month."
  • Budget-scoped rental hunting (room rental vs whole unit) in a specific Singapore town/district.
  • Monitoring new rental listings for a saved search (re-run and diff on id).
  • Comparing supply/price across towns (read result_count per query).

Workflow

The entire search is driven by a URL with query params — there is no need to interact with the search form or dropdowns. The results page is a Next.js app that ships the full, structured listing set inside a <script id="__NEXT_DATA__"> JSON blob, so extraction is a JSON parse, not DOM scraping. The site is behind Cloudflare, so a stealth (verified + residential-proxy) session is required.

  1. Create a stealth remote session (Cloudflare — a bare session is served the Just a moment... interstitial):

    sid=$(browse cloud sessions create --keep-alive --verified --proxies \
      | node -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>process.stdout.write(JSON.parse(s).id))")
    export BROWSE_SESSION="$sid"
    
  2. Pick the base path for the kind of rental:

    GoalPath
    All rentals/property-for-rent
    Apartments / condos (whole unit)/apartment-condo-for-rent
    HDB flats (whole unit)/hdb-for-rent
    Landed houses (whole unit)/landed-house-for-rent
    Condo room rental/apartment-condo-for-rent/room-rental
    HDB room rental/hdb-for-rent/room-rental
    Landed room rental/landed-house-for-rent/room-rental

    The /room-rental suffix sets entireUnitOrRoom=["room"]; the bare path returns whole units.

  3. Append filter params (camelCase; the site normalizes and echoes them back in searchParams):

    • freetext=<location> — town/MRT/project keyword, e.g. Tampines, Orchard
    • minPrice= / maxPrice= — monthly rent in SGD
    • bedrooms=<n> — repeat the param for multiple values (bedrooms=2&bedrooms=3)
    • sort=price&order=asc — sort by price ascending (order=desc for high-to-low)
    • page=<n> — 1-based; 20 listings per page

    Example:

    URL="https://www.propertyguru.com.sg/property-for-rent?freetext=Tampines&maxPrice=2500&sort=price&order=asc"
    browse open "$URL" --remote
    browse wait load --remote
    browse wait timeout 2500 --remote
    
  4. Extract from __NEXT_DATA__ — capture the whole page and parse the JSON out-of-band (see the first gotcha: do not use browse get text on the script node — it truncates):

    browse get html body --remote > body.json
    

    Then in Node: regex out <script id="__NEXT_DATA__">(…)</script>, JSON.parse, and read:

    • props.pageProps.pageData.resultCount → total matches
    • props.pageProps.pageData.searchParams → normalized filters (verify yours were applied)
    • props.pageProps.pageData.data.listingsData[] → 20 listings; each has a .listingData object with the fields mapped in Expected Output below.
  5. Paginate if needed: re-request with page=2,3,… up to ceil(resultCount / 20).

  6. Release the session:

    browse cloud sessions update "$sid" --status REQUEST_RELEASE
    

Browser fallback (if __NEXT_DATA__ is absent or extraction fails)

The rendered listing cards carry the same data as visible text. After browse snapshot, each card exposes: title/project, S$ <n> /mo, room-type chip (Master Room / Common Room / whole-unit type), a N pax chip for rooms, property type, built year, availability, nearest-MRT line, and Listed on <date>. The canonical listing URL is the card's anchor href of the form /listing/<slug>-<numericId>. This is slower and loses the numeric id/price_value precision you get from __NEXT_DATA__, so prefer the JSON path.

Site-Specific Gotchas

  • browse get text "script#__NEXT_DATA__" truncates the JSON. The blob is ~1 MB; get text cuts it off mid-string and the parse fails, which pushes agents into scraping the accessibility tree — where listings come back with id: null and listing URLs missing their numeric suffix. Always use browse get html body (or a targeted browse eval returning only the fields you need) and parse the full __NEXT_DATA__ block.
  • Cloudflare WAF. --verified --proxies is required. A bare session lands on the Just a moment... challenge page (title) and __NEXT_DATA__ will reflect the challenge, not listings. Confirmed by the pre-run probe (301 → cloudflare, cloudflare-waf).
  • Prioritized / sponsored listings jump the queue. Even with sort=price&order=asc, the first few listingsData entries can be out of price order (observed: S$2,950 appearing before S$4,700 and S$3,200). If you need strict ordering, re-sort client-side by listingData.price.value. Check listingData.isPrioritized to flag promoted entries.
  • Params are camelCase and get rewritten. Input maxprice/beds[] is accepted but the canonical URL uses maxPrice, bedrooms. Trust pageData.searchParams in the response to confirm what actually applied, not the URL you sent.
  • Room rentals have bedrooms: 0. A room listing is identified by entireUnitOrRoom=["room"] (from the /room-rental path) plus a listingFeatures chip of Master Room / Common Room and a N pax occupancy chip — not by the bedrooms field, which is 0 for rooms.
  • property.typeGroup encodes the family: H = HDB, N = non-landed (condo/apartment/walk-up/cluster/exec-condo), L = landed. The bare /apartment-condo-for-rent path pins propertyTypeCode=[CONDO,APT,WALK,CLUS,EXCON] and propertyTypeGroup=N.
  • shortAddress / area are often empty strings for HDB listings; fullAddress (e.g. Blk 612B Tampines North Drive 1) is the reliable address field. pricePerArea.localeStringValue (S$ 6.00 psf) is present even when area is blank.
  • No JSON-LD on search pages (0 application/ld+json blocks). __NEXT_DATA__ is the only embedded structured source. Individual /listing/... detail pages may carry richer schema, but that's a different skill.
  • No public unauthenticated JSON search API was found reachable from the search page — the Next.js SSR payload is the data source. Don't burn time hunting a REST endpoint; parse __NEXT_DATA__.
  • Dates are relative to crawl time (Listed on Oct 01, 2026 (3d ago)); recency.text carries both absolute and relative forms.

Expected Output

{
  "success": true,
  "query": {
    "path": "/property-for-rent",
    "location": "Tampines",
    "min_price": null,
    "max_price": 2500,
    "bedrooms": null,
    "kind": "any",
    "sort": "price asc"
  },
  "result_count": 837,
  "page": 1,
  "listings": [
    {
      "id": 22513270,
      "title": "Tampines",
      "price": "S$ 900 /mo",
      "price_value": 900,
      "currency": "SGD",
      "bedrooms": 0,
      "bathrooms": 1,
      "property_type": "HDB Flat",
      "property_type_group": "H",
      "room_type": "Room",
      "address": "Blk 612B Tampines North Drive 1",
      "price_per_area": "S$ 6.00 psf",
      "nearest_mrt": "690 m (8 min) from CR6 Tampines North MRT (U/C)",
      "availability": "Ready to Move",
      "agent": "Goh Betty",
      "agent_license": "R044749A",
      "listed": "Listed on Oct 01, 2026 (3d ago)",
      "is_verified": true,
      "is_prioritized": false,
      "url": "https://www.propertyguru.com.sg/listing/hdb-for-rent-tampines-22513270"
    }
  ],
  "error_reasoning": null
}

Room-rental outcome shape (note bedrooms: 0, a room_type chip, and a pax occupancy value):

{
  "success": true,
  "query": { "path": "/apartment-condo-for-rent/room-rental", "location": "Orchard", "max_price": 1800, "kind": "room" },
  "result_count": 264,
  "listings": [
    {
      "title": "Orchard Towers",
      "price": "S$ 1,400 /mo",
      "price_value": 1400,
      "bedrooms": 0,
      "room_type": "Common Room",
      "pax": 6,
      "property_type": "Apartment",
      "availability": "Available from 15 Oct",
      "url": "https://www.propertyguru.com.sg/listing/for-rent-orchard-towers-..."
    }
  ],
  "error_reasoning": null
}

Failure shapes:

// Cloudflare block (bare / non-stealth session)
{ "success": false, "result_count": 0, "listings": [], "error_reasoning": "cloudflare_challenge: page title 'Just a moment...', __NEXT_DATA__ not populated with listings" }

// Valid search, zero matches
{ "success": true, "result_count": 0, "listings": [], "error_reasoning": null }