edgeprop.my

find-agent-contact-details

Installation

Adds this website's skill for your agents

 

Summary

Extract a Malaysian real-estate agent's display name, full mobile phone (E.164), and email from any EdgeProp.my agent profile or listing page by parsing the inlined Next.js __NEXT_DATA__ payload — bypasses the UI's masked phone and email-form gateway.

FIG. 01
FIG. 02
FIG. 03
FIG. 04
FIG. 05
FIG. 06
SKILL.md
218 lines

EdgeProp.my Find Agent Contact Details

Purpose

Given an EdgeProp.my agent profile URL (/agent/{id}/{slug}) or any listing URL (/listing/sale/{id}/... or /listing/rent/{id}/...), return the listing agent's full display name, full unmasked mobile phone number (E.164 normalized for Malaysia), and email address. Read-only — never submits the Send Enquiry form, never books or contacts the agent.

When to Use

  • Looking up the contact details of a specific listed real-estate agent on EdgeProp Malaysia.
  • Building a contact card for any Malaysian property listing (sale or rental) — pulls the agent off the listing without the user needing to know the agent's own URL.
  • Bulk enrichment of agents discovered via /agents directory pages or via Browserbase Search results pointing at edgeprop.my/agent/....
  • Anywhere you'd otherwise scrape the rendered UI and chase the "Show Number" click-to-reveal — the JSON payload embedded in the page exposes the full phone and email upfront, so the click is wasted work.

Workflow

EdgeProp.my is a Next.js application sitting behind Cloudflare's managed-challenge bot protection. The page UI masks the agent's phone as 01X XXX XXXX and only offers an enquiry-form gateway for email — but the <script id="__NEXT_DATA__"> payload that Next.js inlines on every server-rendered page contains the agent's raw, unmasked phone and email in plaintext. Fetch the page once with a real Chromium that can pass the Cloudflare challenge, parse __NEXT_DATA__, done. No clicks, no enquiry form, no waiting on XHR reveals.

Cloudflare blocks plain curl / wget / non-residential HTTP with a 403 + JS challenge — confirmed during iteration. You must use Browserbase remote + --verified (residential captcha-solver flag) + --proxies (residential proxies) and let the page sit for ~10–15s after first navigation for the CF challenge to clear. After that the page is unchanged from a normal browser session.

  1. Start a stealth Browserbase session with both --verified and --proxies:

    sid=$(browse cloud sessions create --keep-alive --proxies --verified \
      | 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. Navigate to the target URL — either an agent profile (/agent/{id}/{slug}) or any listing (/listing/sale/..., /listing/rent/..., /rental/...). Wait ~12–15 seconds for Cloudflare's managed challenge to auto-clear. The title flips from "Just a moment..." to the real page title once cleared.

    browse open "https://www.edgeprop.my/agent/103203/eleen-ooi" --remote
    browse wait timeout 15000 --remote
    browse get title --remote   # confirm: not "Just a moment..."
    
  3. Extract the __NEXT_DATA__ JSON from the page — Next.js inlines it as a <script id="__NEXT_DATA__" type="application/json">…</script> element. Parse it as JSON.

    browse get html "//script[@id='__NEXT_DATA__']" --remote > nextdata.html
    # The .html field of the returned JSON envelope is the literal stringified JSON payload.
    
  4. Read the agent objectfield names differ between the two page types:

    Agent profile page (/agent/{id}/{slug}) — at props.pageProps.data.agent:

    JSON pathMeaning
    bizname_tDisplay name, e.g. "Eleen Ooi"
    contact_sRaw phone, no formatting, e.g. "0122829900"
    mail_sEmail, e.g. "eleenestates@outlook.com"
    org_name_sAgency slug, e.g. "cbdproperties" (lowercase, no spaces)
    agent_position_sPosition/title text, e.g. "Real Estate..."
    uid_iNumeric agent id matching the URL {id}

    Listing page (/listing/{sale|rent}/{id}/...) — at props.pageProps.JSONdata.result.agent:

    JSON pathMeaning
    agent_biznameDisplay name as shown on listing card, e.g. "DANIEL KHO" (often UPPERCASE on listings)
    agent_nameLegal name from BOVAEP registry, e.g. "KHO CHEE YONG" — frequently different from agent_bizname. Report agent_bizname as the public-facing answer unless caller specifically wants the legal name.
    agent_phRaw phone, e.g. "0162690803"
    emailEmail, e.g. "danielkho24@gmail.com"
    agency (a.k.a. agent_com)Full agency name, e.g. "IQI REALTY SDN. BHD."
    agent_idBOVAEP/PEA registration, e.g. "REN 16300" or "PEA 3651"
    uidNumeric agent id — use to construct the canonical /agent/{uid}/{slug} URL
  5. Normalize the phone to E.164. Malaysian mobile numbers start with 01 (e.g. 012…, 016…, 017…, 018…, 019…, 011…, 013…, 014…, 015…). Strip any non-digit characters, drop the leading 0, prepend +60. Examples: 0122829900+60122829900; 016-269 0803+60162690803. Keep the original raw string as phone_raw so the caller can verify.

  6. Output the JSON envelope from the Expected Output schema below. If the field genuinely isn't in __NEXT_DATA__ (rare — observed only when the agent record is suspended/archived), fall back to the Browser fallback below.

Browser fallback (rare — only when __NEXT_DATA__ is missing or stripped)

Older PRO-tier accounts or archived listings occasionally render with __NEXT_DATA__ absent or with the contact fields blanked. In that case revert to the on-page click-to-reveal flow — it's reliable, just slower:

  1. On the agent profile page, the phone is shown as a button labeled 01X XXX XXXX (last 4 digits masked) next to a green WHATSAPP button. Click the masked-phone button to reveal the full number:
    browse click "//button[.//span[contains(text(),'XXXX')]]" --remote
    browse wait timeout 2000 --remote
    browse get text "//*[contains(text(),'01') and not(contains(text(),'XXXX'))]" --remote
    
    The masked-phone button text updates in place from 012 282 XXXX012 282 9900. The WHATSAPP button next to it triggers a window.open(wa.me/<phone>) that gets popup-blocked in headless sessions — don't rely on it for extraction.
  2. Email cannot be revealed via UI — the SEND ENQUIRY button opens a contact form that asks for the visitor's email + message; the agent's email never appears in the rendered UI. If mail_s / email is absent from __NEXT_DATA__ and the click-reveal flow is your only path, set email: null and email_form_only: true.
  3. On listing pages, the on-card phone affordances (WHATSAPP, MOBILE CALL) under "OTHER WAYS TO ENQUIRE" do not flip the UI to show a number — they each fire a window.open that's popup-blocked. From a listing page, the reliable click-reveal path is to navigate to the agent's profile (the contact card has an /agent/{uid}/{slug} anchor on the agent's name) and reveal there. Many listing descriptions also contain the agent's phone written into the body text by the agent themselves — that's a noisy source, prefer __NEXT_DATA__.

Site-Specific Gotchas

  • Cloudflare managed challenge is mandatory. browse cloud fetch (no browser) and any plain HTTP client return a 403 with cf-mitigated: challenge and the JS-only "Just a moment..." interstitial. Only a real Chromium with --verified --proxies clears it. First navigation needs 10–15s of post-load wait — calling browse get title immediately will return "Just a moment...".
  • __NEXT_DATA__ is the goldmine — and the UI lies about what's hidden. The HTML rendered into the visible DOM masks phones as 01X XXX XXXX and offers no email at all. The exact same page's <script id="__NEXT_DATA__"> block contains both fields in plaintext (contact_s + mail_s on agent pages, agent_ph + email on listing pages). Do not waste turns clicking reveal buttons unless __NEXT_DATA__ is unexpectedly absent.
  • Field names differ between agent profile and listing pages. Agent profile uses Solr-style suffixed keys (contact_s, mail_s, bizname_t, org_name_s); listing pages use a agent sub-object with descriptive keys (agent_ph, email, agent_bizname, agency). Both are documented in step 4 above. Don't assume one schema — check the URL path first.
  • Two name fields on listings: agent_nameagent_bizname. agent_name is the legal name registered with BOVAEP (Malaysian Board of Valuers, Appraisers, Estate Agents and Property Managers), e.g. "KHO CHEE YONG". agent_bizname is the public-facing display name, e.g. "DANIEL KHO". The site UI exclusively shows agent_bizname — report that as name unless the caller specifically asks for the registered legal name.
  • Listing-page agent_bizname is often UPPERCASE ("DANIEL KHO") while agent-profile bizname_t is Title Case ("Daniel Kho"). Normalize to Title Case in the output if you care about presentation consistency.
  • License-number format is non-uniform. REN 16300 = Registered Estate Negotiator. PEA 3651 = Probationary Estate Agent. E (…) / VE (…) exist for fully-registered Estate Agents and Valuers. Surface the raw string; don't try to canonicalize.
  • Phone format quirks. contact_s / agent_ph are returned without dashes ("0162690803"). Some agent records have the phone also embedded in listing-description body text in stylized Unicode (e.g. 𝟎𝟏𝟔𝟐𝟔𝟗𝟎𝟖𝟎𝟑) — that's noisy, ignore it. Trust agent_ph / contact_s. Malaysian mobile prefixes observed: 010-019. To E.164, strip leading 0, prepend +60.
  • The "SEND ENQUIRY" button opens a contact form, not a mailto. The form fields are the visitor's full name, email and mobile — the agent's email is never exposed via this UI affordance. Do not submit this form during scraping (it would send a real lead to the agent).
  • WHATSAPP buttons rely on window.open(...) and get popup-blocked in headless Browserbase sessions. They never produce an inspectable wa.me/<phone> href in the DOM. Don't try to harvest the phone via WhatsApp click.
  • Don't trust data.org_name_s as the human-readable agency. It's a slug ("iqirealty", "cbdproperties"). The listing-page agent.agency / agent.agent_com field is the proper display name ("IQI REALTY SDN. BHD.", "CBD PROPERTIES SDN. BHD.").
  • Agent profile URLs are case-insensitive in the slug. /agent/101419/daniel-kho, /agent/101419/DANIEL%20KHO, and /agent/101419 all resolve to the same page. The numeric {uid} is the only stable identifier.
  • robots.txt is generous/agent/... and /listing/... are not in the Disallow list (only /admin/, /search/, /user/... are). Crawl-delay: 10 is advisory; keep request rate sane.
  • Cookies / sign-in are not required. All the data above is present in the unauthenticated page payload. Don't burn turns trying to log in.
  • Email is sometimes missing from __NEXT_DATA__ when the agent has set their profile to hide email (free-tier accounts more often than PRO-tier). When mail_s / agent.email is "" or absent, report email: null with email_form_only: true — don't fabricate one from the listing description.
  • Iteration cost note: per-iteration cost is dominated by the Browserbase session minutes (the --verified solver adds a few seconds to first-load). Budget ~15–30s per agent if reusing a session, or ~30–45s if creating a fresh session each time. Reuse sessions when bulk-extracting.

Expected Output

{
  "success": true,
  "name": "Eleen Ooi",
  "name_legal": null,
  "phone": "+60122829900",
  "phone_raw": "0122829900",
  "email": "eleenestates@outlook.com",
  "agency": "CBD PROPERTIES SDN. BHD.",
  "license": "PEA 3651",
  "agent_id": 103203,
  "agent_profile_url": "https://www.edgeprop.my/agent/103203/eleen-ooi",
  "source_url": "https://www.edgeprop.my/agent/103203/eleen-ooi",
  "extraction_method": "next_data",
  "email_form_only": false,
  "error_reasoning": null
}

Listing-page example (agent has a different legal vs. business name):

{
  "success": true,
  "name": "Daniel Kho",
  "name_legal": "KHO CHEE YONG",
  "phone": "+60162690803",
  "phone_raw": "0162690803",
  "email": "danielkho24@gmail.com",
  "agency": "IQI REALTY SDN. BHD.",
  "license": "REN 16300",
  "agent_id": 101419,
  "agent_profile_url": "https://www.edgeprop.my/agent/101419/daniel-kho",
  "source_url": "https://www.edgeprop.my/listing/sale/3726402/pandan-ria-apartment-for-sale-by-daniel-kho",
  "extraction_method": "next_data",
  "email_form_only": false,
  "error_reasoning": null
}

Email-hidden outcome (free-tier agent or hidden-email setting):

{
  "success": true,
  "name": "<agent display name>",
  "name_legal": null,
  "phone": "+60XXXXXXXXX",
  "phone_raw": "0XXXXXXXXX",
  "email": null,
  "agency": "<agency name>",
  "license": "<REN ####|PEA ####|E (#####)>",
  "agent_id": 999999,
  "agent_profile_url": "https://www.edgeprop.my/agent/999999/<slug>",
  "source_url": "<input url>",
  "extraction_method": "next_data",
  "email_form_only": true,
  "error_reasoning": "Email not present in __NEXT_DATA__ (mail_s empty). Agent's contact form is the only public email path."
}

Cloudflare-block / unreachable outcome:

{
  "success": false,
  "name": null,
  "name_legal": null,
  "phone": null,
  "phone_raw": null,
  "email": null,
  "agency": null,
  "license": null,
  "agent_id": null,
  "agent_profile_url": null,
  "source_url": "<input url>",
  "extraction_method": null,
  "email_form_only": false,
  "error_reasoning": "Cloudflare managed challenge did not clear within 30s. Retry with a fresh session using --verified --proxies, or wait and retry from a different residential IP."
}