# Mudah.my Malaysia Listings, Prices & Seller Contacts (`farside/mudah-lead-radar`) Actor

Returns Mudah.my listings as structured records: title, price, location, postDate, description, imageUrls, sellerName, sellerPhone, category attributes. Covers cars, property, phones, jobs, home goods. Input: searchKeywords or categoryUrls. monitorMode emits price-change and new-listing events.

- **URL**: https://apify.com/farside/mudah-lead-radar.md
- **Developed by:** [Farside](https://apify.com/farside) (community)
- **Categories:** Lead generation, E-commerce, Automation
- **Stats:** 1 total users, 0 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$2.00 / 1,000 listing record captureds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Mudah.my Malaysia Listings, Prices & Seller Contacts

Track what is for sale on **Mudah.my**, Malaysia's largest classifieds marketplace — used car listings, property listings, phones, jobs and home goods — with prices, locations, photos, category attributes and the seller contact details Mudah publishes publicly.

### What you get

- **Every listing as a flat record** — `title`, `price`, `priceValue`, `currency`, `state`, `area`, `postDate`, `description`, `imageUrls`, `listingUrl`, plus `previousPrice` when the seller has cut the price.
- **Category-specific attributes, without a category-specific schema** — `make` / `model` / `mileage` / `transmission` / `engine_capacity` on used cars; `property_type` / `rooms` / `bathroom` / `size` / `facilities` on property listings; `phone_brand` / `phone_storage` / `condition` on phones; `salary` / `contract_type` / `company_name` on jobs.
- **Seller contact where Mudah shows it publicly** — `sellerPhone` and `sellerWhatsapp` in E.164 form, plus `sellerName`, `sellerType`, `sellerCompanyName` and `sellerVerified`. Every record carries `sellerContactProvenance`, so you always know whether a number was published or simply absent. It is never guessed, inferred, or unmasked.
- **Change monitoring** — set `monitorMode` and each run diffs against the last one, emitting events for price changes, new listings, delistings, relistings and seller edits. Push them to a `webhookUrl` or read them from the dataset. This is the part that turns a scrape into a feed.
- **Graceful degradation with per-field provenance** — if Mudah rate-limits a detail page or changes its page structure, the run does not die: it falls back through a documented tier ladder and each field records which tier produced it. You always know whether a null means "the seller did not publish this" or "this run could not see it".
- **A run report as the final record** — success rate, per-field completeness, which tier served which field, requests per listing, and category and contact-provenance breakdowns, so you can audit a run rather than trust it.

### Input

Give it keywords, or give it category page URLs. Everything else is optional.

```json
{
  "searchKeywords": ["myvi"],
  "category": "cars",
  "state": "selangor",
  "minPrice": 15000,
  "maxPrice": 40000,
  "maxListings": 100
}
```

Or watch a search for changes and get told what moved:

```json
{
  "categoryUrls": ["https://www.mudah.my/selangor/cars-for-sale"],
  "maxListings": 50,
  "monitorMode": true,
  "watchlistName": "selangor-cars",
  "webhookUrl": "https://example.com/hooks/mudah"
}
```

Or skip discovery entirely and scrape category pages directly:

```json
{
  "categoryUrls": [
    "https://www.mudah.my/selangor/cars-for-sale",
    "https://www.mudah.my/penang/properties-for-rent"
  ],
  "maxListings": 50
}
```

| Field | Type | Default | Notes |
|---|---|---|---|
| `searchKeywords` | array of strings | — | One search per keyword. Provide this **or** `categoryUrls`. |
| `categoryUrls` | array of strings | — | Mudah.my category or search URLs. Skips discovery; `category`, `state`, `minPrice`, `maxPrice` and `sortBy` are then ignored. |
| `category` | string | `all` | `cars`, `property-for-sale`, `property-for-rent`, `mobile-phones`, `jobs`, `home-appliances`, `motorcycles`, and more. |
| `state` | string | `malaysia` | Any of the 17 Malaysian states and federal territories, e.g. `selangor`, `penang`, `johor`, `kuala-lumpur`. |
| `maxListings` | integer | `100` | The main driver of run time and cost — one detail page fetch per listing. |
| `minPrice` / `maxPrice` | integer | — | In Malaysian ringgit (MYR). |
| `sortBy` | string | `newest` | `newest`, `price_asc`, `price_desc`. |
| `captureSellerContact` | boolean | `true` | Set `false` to get listings with no personal data at all. |
| `monitorMode` | boolean | `false` | Diff this run against the last one and emit change events. |
| `watchListingIds` | array of strings | — | Specific listing IDs or URLs to monitor. Turns on `monitorMode`. |
| `watchlistName` | string | derived | Names the baseline, so you can keep several independent watchlists. |
| `webhookUrl` | string | — | POST change events here as they are detected. |
| `proxyConfiguration` | object | none | Not needed — see [Limitations](#limitations). |

### Output

One record per listing. Abridged, and with the phone number redacted for this README:

```json
{
  "recordType": "listing",
  "listingId": "114838927",
  "listingUrl": "https://www.mudah.my/2015-perodua-myvi-1-3-ezi-original-paint-1-owner-114838927.htm",
  "title": "2015 Perodua MYVI 1.3 EZi Original Paint 1 Owner",
  "price": "RM 17,800",
  "priceValue": 17800,
  "currency": "MYR",
  "previousPrice": "RM 18,800",
  "categoryName": "Cars",
  "state": "Selangor",
  "area": "Petaling Jaya",
  "postDate": "Today 17:18",
  "description": "Perodua MYVI 1.3 PREMIUM X FACELIFT …",
  "imageUrls": ["https://cdn.rnudah.com/image_hd/plain/47ffb851…jpg", "… 11 more"],
  "imageCount": 12,
  "attributes": {
    "condition": "Used",
    "make": "Perodua",
    "model": "MyVi",
    "car_type": "Hatchback",
    "transmission": "Auto",
    "engine_capacity": "1298 cc",
    "mileage": "120 000 - 129 999",
    "manufactured_date": "2015"
  },
  "sellerName": "Extreme Auto Trader",
  "sellerType": "company",
  "sellerVerified": "verified",
  "sellerPhone": "+6012XXXXXXX",
  "sellerWhatsapp": "+6012XXXXXXX",
  "contactChannels": ["call", "wa"],
  "sellerContactProvenance": "publicly_shown",
  "capturedAt": "2026-09-01T17:50:57+0800"
}
```

Photos are stored as URLs. Image files are never downloaded into the dataset.

### Limitations

Read this part. It is the honest version.

#### Seller contact is public, but not universal

Mudah.my publishes seller phone numbers on listing pages to anonymous visitors — no login, no "show number" click. This actor reads only what is served that way. It does **not** unmask, guess, or reconstruct numbers, and it never touches a logged-in session.

But a large minority of sellers choose Mudah's in-app chat instead of publishing a number, and how many depends heavily on the category. Measured over 60 listings across six categories:

| Category | Contact publicly shown |
|---|---|
| Used cars | 100% |
| Property (apartment / condo / house) | 100% |
| Home appliances & kitchen | 100% |
| Jobs | 67% |
| **Mobile phones & gadgets** | **8%** |
| **Overall** | **78%** |

By seller type: company and dealer ads 95%, private ads 52%.

If your use case is dealer or agent lead generation in cars and property, expect near-complete contact coverage. **If it is consumer electronics, expect very little** — a verification run on 8 Penang iPhone listings returned 1 with a public number and 7 without. Listings with no published number are returned with `sellerContactProvenance: "not_available"` and a null `sellerPhone`, never a placeholder.

#### Completeness depends on what the listing shows

Fields the seller did not fill in do not exist to scrape. A car ad with no mileage stated yields no `mileage` attribute. Completeness is measured *relative* to what the live page actually displays, and the measurement uses an **independent source**: the page's `og:`/JSON-LD markup, rendered by a different code path than the payload the actor parses. On the acceptance run, 100% of the fields the markup path showed were captured. A record's absent field means the listing lacked it, not that the actor missed it.

#### Monitoring: the first run has nothing to compare

A watchlist's first run records a baseline and reports **no** changes — emitting
"new listing" for all 50 results of a baseline you never had would be noise, not
news. Changes are reported from the second run onward.

Delisting is the part that is easy to get wrong. Mudah's search results are
capped and reordered, so a listing missing from today's results has usually just
been pushed down by newer ads. Every baseline listing that today's search did
not return is therefore **re-fetched directly**, and only an HTTP 410 counts as
delisted — which also means a watchlist keeps tracking its members after they
drop out of the search window. A listing that neither resolves nor 410s is
reported as unverified rather than guessed at either way.

Webhook delivery is best-effort: a monitoring run will not fail because your
receiver is down, and every event is in the dataset regardless. Full behaviour:
[`docs/MONITORING.md`](docs/MONITORING.md).

#### Degraded runs return less, and say so

Every record carries `tier` and `fieldProvenance`. On a healthy run every field
reads `T3-detail-full`. When a detail page cannot be fetched or parsed, the
record is rebuilt from the search page's own payload and the page's SEO markup
instead, and the affected fields say which tier they came from. Verified by
deliberately breaking each source in turn: all four failure modes still complete
**8/8 listings**, at the tier the failure implies.

What you lose in a degraded run is specific rather than random: `description`
is unavailable from search results at all, and seller contact below the full
tier is available only for property listings. Contact provenance then reads
`not_observed_at_this_tier` rather than `not_available` — an absence we did not
verify is never reported as one we did. Full ladder: [`docs/TIERS.md`](docs/TIERS.md).

#### Anti-bot is an arms race

Mudah.my sits behind Cloudflare. Today the site serves ordinary HTTP requests without a challenge, and this actor stays deliberately polite — one request at a time and an adaptive delay that backs off when the site rate-limits and recovers when it stops. That keeps success rates high (100% on the runs behind this README) at the cost of speed: roughly **1.2 requests per listing** and a run rate that varies with how tolerant the site is that hour.

That balance is a snapshot, not a guarantee. Mudah can tighten its posture at any time, and if it does, throughput drops before correctness does. `proxyConfiguration` is available but off by default: Mudah currently serves Apify datacenter IPs exactly as it serves Malaysian residential ones — same listings, same prices, same public phone numbers — so a proxy buys nothing and residential proxies would add real cost.

#### Other things worth knowing

- **Pagination has a floor of diminishing returns.** A single search reaches roughly 22,000 listings before pages thin out. That is far beyond any realistic `maxListings`, but it is not infinite.
- **Long runs stop on a time budget, never on the platform's kill.** The platform ends a run at its timeout (1 hour by default). This actor watches that deadline and stops capturing about two and a half minutes earlier, so the run still **ends SUCCEEDED**: every captured listing is delivered and billed, the run report explains exactly where the run stopped (`timeBudgetReached: true`, with the count still pending), and a rerun with the same input continues from the checkpoint instead of starting over. For larger captures, raise the run timeout in the Console's run settings — or just run again.
- **`adType` reflects Mudah's own labelling**, which reports `"For sale"` even on job ads. The value is passed through faithfully rather than corrected.
- **Prices can be ranges.** Job salary bands populate `priceValue` (lower bound), `priceMax` and `priceIsRange`; most other categories leave `priceMax` null.
- **A resumed run continues; it does not restart.** `maxListings` is a target for the search as a whole, so rerunning an interrupted run finishes the job instead of capturing that many again. On the platform this means *resurrecting* the interrupted run — every new cloud run starts with a fresh checkpoint store, and rerunning the input there starts over.
- **Multiple searches split the quota evenly.** `searchKeywords: ["myvi", "honda city"]` with `maxListings: 10` returns five of each, not ten of the first. The same applies to multiple `categoryUrls`.

### Pricing

Pay-per-event. You are charged for results, not for time:

| Event | Charged when |
|---|---|
| `result-listing` | one per captured listing record |
| `monitor-change` | one per change event emitted in `monitorMode` |

The run report is not billed — it is run overhead, not a result. Nothing is
charged for a listing that failed to capture, and nothing is charged for a
listing that Mudah reports as gone (HTTP 410); a delisting in `monitorMode`
bills as the one `monitor-change` event that tells you about it.

One listing can emit more than one change event: an edited ad can produce
`price.changed`, `title.changed`, `description.changed`, `seller.changed`,
`location.changed`, `attributes.changed`, `images.changed` and
`listing.relisted` — up to **8 `monitor-change` charges for one ad in one
run**, plus one `result-listing` for the recapture. Size `maxTotalChargeUsd`
with that worst case in mind.

When the charging budget for a run is reached, the actor stops cleanly and says
so in the log rather than continuing to work uncharged, and the partial results
already pushed are yours.

### Handling seller contact data responsibly

Captured phone numbers are personal data of Malaysian individuals and businesses, and Malaysia's **Personal Data Protection Act 2010 (PDPA)** applies to how you process them.

Publicly visible is not the same as free to use for anything. In particular:

- Use the data for a purpose the seller would reasonably expect — enquiring about the listing they posted.
- The PDPA restricts unsolicited direct marketing, and Malaysia's Communications and Multimedia Act restricts unsolicited commercial messaging. Bulk cold-calling or mass WhatsApp blasts to scraped numbers is where users get into trouble.
- Keep the data no longer than you need it, secure it, and honour any opt-out you receive.
- Set `captureSellerContact: false` if your use case is market and price research. Prices, attributes and locations do not need anyone's phone number.

You are the data controller for anything you collect with this actor. This section is a pointer to the obligations, not legal advice.

### Documentation

| Document | What it covers |
|---|---|
| [`docs/CANARY.md`](docs/CANARY.md) | What Mudah.my actually allows: field matrix per category, URL templates, pagination, the seller-contact verdict, proxy comparison |
| [`docs/TIERS.md`](docs/TIERS.md) | The capability ladder and per-field provenance — what you still get when the site degrades |
| [`docs/MONITORING.md`](docs/MONITORING.md) | Baselines, the change events, how delisting is verified, webhooks |
| [`MAINTENANCE.md`](MAINTENANCE.md) | What breaks first when Mudah changes, how to detect it, expected upkeep |

### Data source and scope

Mudah.my only. No other marketplaces, no third-party enrichment, no email lookup, no data behind a login, and no contact-unmasking services. Everything in the output came from a page an anonymous visitor can load.

# Actor input Schema

## `searchKeywords` (type: `array`):

Keywords to search Mudah.my for, one search per keyword. Combine with 'category' and 'state' to narrow the results. Provide this OR 'categoryUrls'.

## `categoryUrls` (type: `array`):

Mudah.my category or search page URLs to scrape directly, e.g. https://www.mudah.my/selangor/cars-for-sale. Use this instead of 'searchKeywords' when you already know the exact listing pages you want; discovery is skipped and 'category', 'state', 'minPrice', 'maxPrice' and 'sortBy' are ignored.

## `category` (type: `string`):

Which Mudah.my category to search. 'all' searches every category. Only used with 'searchKeywords'.

## `state` (type: `string`):

Malaysian state or federal territory to limit the search to. 'malaysia' searches nationwide. Only used with 'searchKeywords'.

## `maxListings` (type: `integer`):

How many listing detail records to capture. Each listing costs one detail page fetch, so this is the main driver of run time and cost.

## `minPrice` (type: `integer`):

Only return listings priced at or above this amount, in Malaysian ringgit. Only used with 'searchKeywords'.

## `maxPrice` (type: `integer`):

Only return listings priced at or below this amount, in Malaysian ringgit. Only used with 'searchKeywords'.

## `sortBy` (type: `string`):

Order Mudah.my returns search results in. 'newest' lists the most recently posted ads first.

## `captureSellerContact` (type: `boolean`):

Include the seller's publicly shown phone and WhatsApp number. Mudah.my publishes these without a login on roughly 78% of listings; the rest offer in-app chat only and are reported as 'not\_available'. Turn this off to get listings without personal data.

## `monitorMode` (type: `boolean`):

Compare this run against the stored baseline for the same watchlist and emit a change event per difference: price changed, new listing, delisted, relisted, title/description/attributes edited. The first run records a baseline and reports no changes; every run after that reports what moved.

## `watchListingIds` (type: `array`):

Specific Mudah.my listings to monitor, as listing IDs (115674423) or listing URLs. Turns on monitorMode automatically. Use instead of, or alongside, searchKeywords/categoryUrls when you care about particular listings rather than a standing search.

## `watchlistName` (type: `string`):

Names the baseline this run compares against, so you can keep several independent watchlists and can change maxListings without losing your history. Defaults to a name derived from what you are watching.

## `webhookUrl` (type: `string`):

HTTP(S) endpoint to POST change events to as they are detected. Sends {source, eventCount, events\[]} in batches of 50. Delivery is best-effort: events always land in the dataset even if the webhook is unreachable.

## `proxyConfiguration` (type: `object`):

Optional. Mudah.my serves Apify datacenter IPs normally, so no proxy is needed by default. Enable one only if you see failures from your region.

## Actor input object example

```json
{
  "searchKeywords": [
    "myvi",
    "honda city"
  ],
  "categoryUrls": [
    "https://www.mudah.my/selangor/cars-for-sale"
  ],
  "category": "cars",
  "state": "selangor",
  "maxListings": 50,
  "minPrice": 20000,
  "maxPrice": 60000,
  "sortBy": "price_asc",
  "captureSellerContact": true,
  "monitorMode": true,
  "watchListingIds": [
    "115674423",
    "https://www.mudah.my/honda-city-115141457.htm"
  ],
  "watchlistName": "selangor-myvi-under-40k",
  "webhookUrl": "https://example.com/hooks/mudah",
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

## `dataset` (type: `string`):

One record per Mudah.my listing, plus a final run-report record.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "searchKeywords": [
        "myvi"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("farside/mudah-lead-radar").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "searchKeywords": ["myvi"] }

# Run the Actor and wait for it to finish
run = client.actor("farside/mudah-lead-radar").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "searchKeywords": [
    "myvi"
  ]
}' |
apify call farside/mudah-lead-radar --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,farside/mudah-lead-radar"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/j9MA8hVx99j9B57vq/builds/glNXlf0koCTqBTRq0/openapi.json
