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
/agentsdirectory pages or via Browserbase Search results pointing atedgeprop.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.
-
Start a stealth Browserbase session with both
--verifiedand--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" -
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..." -
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. -
Read the agent object — field names differ between the two page types:
Agent profile page (
/agent/{id}/{slug}) — atprops.pageProps.data.agent:JSON path Meaning 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}/...) — atprops.pageProps.JSONdata.result.agent:JSON path Meaning 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 fromagent_bizname. Reportagent_biznameas 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 -
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 leading0, prepend+60. Examples:0122829900→+60122829900;016-269 0803→+60162690803. Keep the original raw string asphone_rawso the caller can verify. -
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:
- On the agent profile page, the phone is shown as a button labeled
01X XXX XXXX(last 4 digits masked) next to a greenWHATSAPPbutton. Click the masked-phone button to reveal the full number:
The masked-phone button text updates in place frombrowse click "//button[.//span[contains(text(),'XXXX')]]" --remote browse wait timeout 2000 --remote browse get text "//*[contains(text(),'01') and not(contains(text(),'XXXX'))]" --remote012 282 XXXX→012 282 9900. TheWHATSAPPbutton next to it triggers awindow.open(wa.me/<phone>)that gets popup-blocked in headless sessions — don't rely on it for extraction. - Email cannot be revealed via UI — the
SEND ENQUIRYbutton opens a contact form that asks for the visitor's email + message; the agent's email never appears in the rendered UI. Ifmail_s/emailis absent from__NEXT_DATA__and the click-reveal flow is your only path, setemail: nullandemail_form_only: true. - 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 awindow.openthat'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 withcf-mitigated: challengeand the JS-only"Just a moment..."interstitial. Only a real Chromium with--verified --proxiesclears it. First navigation needs 10–15s of post-load wait — callingbrowse get titleimmediately 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 as01X XXX XXXXand offers no email at all. The exact same page's<script id="__NEXT_DATA__">block contains both fields in plaintext (contact_s+mail_son agent pages,agent_ph+emailon 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 aagentsub-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_name≠agent_bizname.agent_nameis the legal name registered with BOVAEP (Malaysian Board of Valuers, Appraisers, Estate Agents and Property Managers), e.g."KHO CHEE YONG".agent_biznameis the public-facing display name, e.g."DANIEL KHO". The site UI exclusively showsagent_bizname— report that asnameunless the caller specifically asks for the registered legal name. - Listing-page
agent_biznameis often UPPERCASE ("DANIEL KHO") while agent-profilebizname_tis 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_phare 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. Trustagent_ph/contact_s. Malaysian mobile prefixes observed:010-019. To E.164, strip leading0, 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).
WHATSAPPbuttons rely onwindow.open(...)and get popup-blocked in headless Browserbase sessions. They never produce an inspectablewa.me/<phone>href in the DOM. Don't try to harvest the phone via WhatsApp click.- Don't trust
data.org_name_sas the human-readable agency. It's a slug ("iqirealty","cbdproperties"). The listing-pageagent.agency/agent.agent_comfield 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/101419all resolve to the same page. The numeric{uid}is the only stable identifier. robots.txtis generous —/agent/...and/listing/...are not in the Disallow list (only/admin/,/search/,/user/...are).Crawl-delay: 10is 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). Whenmail_s/agent.emailis""or absent, reportemail: nullwithemail_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
--verifiedsolver 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."
}