# 99.co Property Scraper (Indonesia & Singapore) (`fanndev/99co-scraper`) Actor

Scrape property listings, prices and agents from 99.co across Indonesia and Singapore. Six modes: Indonesian search with price history and coordinates, full listing detail with POI and Q\&A, location facets, Singapore search and detail, and median HDB resale prices. No login needed.

- **URL**: https://apify.com/fanndev/99co-scraper.md
- **Developed by:** [Faisal Ahdan naufal](https://apify.com/fanndev) (community)
- **Categories:** Real estate, E-commerce
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.40 / 1,000 results

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## 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.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## 99.co Property Scraper (Indonesia & Singapore)

Scrapes property listings, prices, agents and market data from
[99.co](https://www.99.co). HTTP-only, no browser, no login, public data.

99.co runs **two entirely different applications on one domain**, which is the
single most important thing to know before scraping it:

| | Indonesia (`/id`) | Singapore (`/singapore`) |
| --- | --- | --- |
| Framework | Next.js Pages Router | Next.js App Router |
| Data layer | `__NEXT_DATA__` | schema.org JSON-LD + RSC flight payload |
| Page parameter | **`hlmn`** | **`page_num`** |
| Anonymous depth | **9 pages, hard** | no limit found |
| Per page | ~31 entries | ~36 entries |
| Corpus | 769,000+ listings | ~21,000 per query |

They share a Cloudflare edge and nothing else, which is why the modes are split
by market and the parsers do not overlap.

### Modes

| Mode | Input | Emits |
| --- | --- | --- |
| `idSearch` | `idSearchTargets` | Indonesian listings — the richest surface on the site |
| `idListingDetail` | `idListingSlugs` | Full listing plus POI, public Q\&A and area price trend |
| `idLocations` | `idSearchTargets` | Location facets — the seeds for beating the page ceiling |
| `sgSearch` | `sgSearchTargets` | Singapore listings |
| `sgListingDetail` | `sgListingUrls` | Full Singapore listing from schema.org |
| `sgHdbResalePrices` | — | Median HDB resale price per town, from the open JSON API |

The Indonesian listing object is unusually good: alongside the asking price it
carries `last_price` and `last_price_change_at` (the previous asking price and
when it moved), `delta_price_percentage`, and `market_price` — 99.co's own
valuation to compare against. Plus an indicative mortgage table, coordinates,
the full administrative hierarchy, agent, and media counts.

### Anti-bot: what actually gates this site

**Cloudflare.** Plain `requests` is answered **403 on every path**. Under
`curl_cffi` impersonation, `chrome124`, `chrome131`, `firefox133` and
`safari17_0` are served normally.

`edge101` and `chrome99_android` are **deliberately excluded** from the ladder:
both drew "Just a moment" (HTTP 429, `cf-mitigated: challenge`) on the bare
homepage during recon. Rotating onto a profile that is itself challenge-prone
turns one bad request into two.

Blocks are detected on the **`cf-mitigated: challenge` response header**, not on
the status code — the same challenge arrives as 403 on deep Indonesian pages and
as 429 on the homepage, so status alone is ambiguous.

#### The 9-page ceiling (the real constraint)

Indonesian search is **challenge-gated from page 10**. `?hlmn=10` and beyond
return `cf-mitigated: challenge`, and **no TLS profile gets past it** — verified
across `/id/jual/rumah` and `/id/sewa/rumah` at depths 10, 11, 12, 15, 20, 30,
50, 100, 200, 300, 400, 450 and 499, with a fresh session per profile.

This is policy, not a rate limit. A brand-new session is challenged on its very
first request to `hlmn=10`, while `hlmn=9` succeeds immediately afterwards.

So one Indonesian query reaches about **279 listings** (9 × 31), out of a corpus
of 769,000. The actor clamps `maxPages` to 9, logs the clamp, and after each
target logs how many of the matching listings it actually reached.

**The way through is to narrow the query** so each slice fits inside 9 pages.
Three dimensions are verified to reach the backend filter and move
`pagination.count`:

- **Location** — run `idLocations` and feed the `urlSlug` values back in.
  `jual/rumah` (769,269 matches) becomes `jual/rumah/jakarta-selatan` (87,872)
  becomes `jual/rumah/bandung/antapani`.
- **Price band** — `minPrice` / `maxPrice`. Jakarta Selatan houses narrowed to
  IDR 1–3 bn: 11,673 matches.
- **Bedrooms** — `minBedrooms`.

Singapore has no equivalent ceiling; `page_num=50` is served normally.

### Traps this actor handles

**1. Decoy pagination parameters, on both sites.** Indonesia takes `hlmn` and
Singapore takes `page_num`. `page`, `p`, `offset` and `start` are all accepted
with HTTP 200 and **silently ignored** — the response is page 1 again, with
`pagination.page` still reading 1. A scraper using `?page=` looks like it is
paging and returns the same 31 rows forever.

**2. The Indonesian listings array is not a list of listings.** It is a list of
*groups*, each tagged `primary` (paid developer and project placements) or
`secondary` (ordinary listings), interleaved. Both are emitted with `groupType`
attached; set `includeProjectPlacements: false` to drop placements, which you
want before counting a market, since placements are paid inventory rather than
organic supply.

**3. Singapore needs two extraction layers merged.** The schema.org `ItemList`
carries only URL, address and property type — but also the honest result total.
The RSC flight payload carries price, psf, beds, area, tenure and coordinates —
but no stable anchor to the canonical URL. They are joined on the listing id
(the last dash-separated token of the URL). Recon match rate: **36/36**.

**4. RSC encodes shared values as `$N` pointers.** A listing arrives as
`{"price": "$41"}` with row `41` holding `{"value":600,"unit":"S$"}`. Reading the
flight stream without resolving those gives you the string `"$41"` as a price.
The parser resolves them before emitting.

**5. Singapore *detail* pages have a different shape from search pages.** They
contain **no** `listing_id`-bearing RSC object at all — the data is in a
schema.org node typed `["Product", "Room"]`. Because `@type` is a *list* there,
any lookup keyed on the string form silently drops the richest node on the page.
(It did exactly that here until it was caught by a coverage check.)

**6. The Indonesian 404 is a 200-shaped page.** A dead slug renders normally
with `__NEXT_DATA__` present and `page: "/404"`. Detail pages do return a real
HTTP 404, so both are handled.

**7. The HDB price table splits meaning from values.** The API returns a
`headers` array and separate row arrays. Rows are zipped back onto their header
titles, and the raw `columns`/`values` pair is kept too, so a new flat-type
column widens the record rather than silently shifting every figure one cell
to the left.

### Output

Every record carries the house envelope — `_input`, `_source`, `_scrapedAt`,
`recordType` — on top of 99.co's own field shape. Indonesian records keep the
site's snake\_case names (`full_address`, `location_pin`, `price.price_tag`);
Singapore records use the schema.org names.

Failures never disappear. A dead slug, an empty result set, a bad target or a
challenge wall each emit exactly one diagnostic row (`_error` = `not_found`,
`no_results`, `unparsable_input`, `challenge_wall`, `fetch_failed`,
`unexpected_shape`, `run_failed`), so a downstream join always sees a row for
every input. `challenge_wall` specifically means Cloudflare served a managed
challenge to *every* TLS profile — on Indonesian search that is the page-10
ceiling and is expected, not a bug.

See `.actor/output_schema.json` for every field.

### Local development

```bash
pip install -r requirements.txt

## All six modes plus the not-found / bad-target / ceiling controls
python test_local.py
python test_local.py idSearch sgSearch

## Run through the Apify SDK against local storage
apify run
```

`test_local.py` grades field coverage per record type, so a parser that quietly
stops matching shows up as a column of zeros rather than as a crash.

### Verified

Against the live site on 2026-09-21:

- All six modes return data; all three controls return exactly one diagnostic row.
- `idSearch` with `maxPages: 50` → clamped to 9, **240 unique listings, zero
  errors**, no challenge wall reached.
- `idSearch` on two targets with placements excluded → 120 records, 120 unique
  ids, **100% coverage** on `id`, `title`, `url`, `price`, `location_pin`,
  `agent`, `attributes`.
- Fan-out: `jual/rumah` 769,269 → `jual/rumah/jakarta-selatan` 87,872 →
  with an IDR 1–3 bn band, 11,673.
- `sgSearch` → 71 listings over 2 pages, 100% coverage, RSC join 36/36.
- `sgHdbResalePrices` → 27 towns.
- `idLocations` → 14 facets, all with a usable `urlSlug`.
- `apify validate-schema` clean on all three schemas.

### Notes on use

99.co's `robots.txt` disallows `/api` and a number of named crawlers. This actor
reads public, unauthenticated pages, and the one API endpoint it calls
(`hdb-resale-price`) sits under that prefix — check that your own use fits your
legal and contractual obligations before running it at volume, and keep
`maxPages` and `requestDelaySecs` proportionate to what you actually need.

# Actor input Schema

## `mode` (type: `string`):

Which surface to scrape. 99.co runs two different applications on one domain, so modes are split by market: the id-prefixed ones cover Indonesia and the sg-prefixed ones cover Singapore. Each mode reads one input list.

## `idSearchTargets` (type: `array`):

Indonesian search paths, used by idSearch and idLocations. Must start with 'jual' (for sale) or 'sewa' (for rent), optionally followed by a property type and a location, e.g. 'jual/rumah', 'sewa/apartemen', 'jual/rumah/jakarta-selatan'. A full https://www.99.co/id/... URL also works. The urlSlug field from idLocations can be pasted straight in here.

## `idListingSlugs` (type: `array`):

Indonesian listing slugs for idListingDetail, e.g. 'rumah-dijual-24miliar-cilandak-cp-54195423533', or the full https://www.99.co/id/properti/... URL. The url field of any idSearch record can be fed straight back in here.

## `sgSearchTargets` (type: `array`):

Singapore search paths for sgSearch, relative to /singapore, e.g. 'rent', 'sale', 'rent/areas/bedok', 'rent/2-bedrooms'. A full https://www.99.co/singapore/... URL also works.

## `sgListingUrls` (type: `array`):

Full Singapore listing URLs for sgListingDetail, e.g. https://www.99.co/singapore/rooms/property/335a-yishun-street-31-hdb-PBi5f6nP6qK47XRZKadBXm. The listingUrl field of any sgSearch record works here.

## `maxPages` (type: `integer`):

How many result pages to walk per search target. Indonesian search is HARD CAPPED at 9: from page 10 onward 99.co answers with a Cloudflare managed challenge that no TLS fingerprint gets past, so a higher value is clamped and a warning is logged. Singapore has no such cap. Indonesian pages carry about 31 entries, Singapore about 36.

## `maxItemsPerTarget` (type: `integer`):

Hard cap on records emitted per search target. Leave at 0 for no cap beyond maxPages.

## `includeProjectPlacements` (type: `boolean`):

Indonesian result pages interleave ordinary listings ('secondary') with paid developer and project placements ('primary'). Both are emitted by default, tagged with groupType. Turn this off to keep only ordinary listings - worth doing when counting a market, since placements are paid inventory rather than organic supply.

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

Lower price bound for Indonesian search, in rupiah. Sent as harga\_min, which is verified to reach the backend filter. Price bands are the main way to split a query that would otherwise exceed the 9-page ceiling.

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

Upper price bound for Indonesian search, in rupiah. Sent as harga\_maks.

## `minBedrooms` (type: `integer`):

Minimum bedroom count for Indonesian search. Sent as kamar\_tidur\_min.

## `propertyType` (type: `string`):

Property type filter for Indonesian search, sent as tipe\_properti, e.g. 'rumah', 'apartemen', 'tanah', 'ruko'. Usually simpler to put the type in the search path instead ('jual/apartemen').

## `sort` (type: `string`):

Sort order for Indonesian search, sent as urut, e.g. 'termurah' (cheapest first) or 'terbaru' (newest first). Changing the sort is another way to reach different inventory within the 9-page ceiling.

## `concurrency` (type: `integer`):

Parallel requests for the detail modes. Search modes always walk pages in order because each page's referer is the previous page. Kept low by default: 99.co sits behind Cloudflare and pages are over 1 MB.

## `requestDelaySecs` (type: `integer`):

Minimum seconds between requests. Cloudflare scores this origin more aggressively than a plain server-rendered site, and the pages are large, so a small floor costs little. Set to 0 to disable pacing.

## `maxRetries` (type: `integer`):

Attempts per request before giving up. Each retry backs off exponentially with jitter and rotates to the next TLS impersonation profile. The client always gives the ladder at least one full lap, since a challenge is fingerprint-shaped.

## `requestTimeoutSecs` (type: `integer`):

Per-request timeout in seconds. 99.co pages are 1-1.6 MB, so do not set this too low.

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

Optional but recommended at volume. 99.co is behind Cloudflare and escalates to a managed challenge on some paths, so residential exit addresses hold up better than datacenter ones over a long run. If proxy setup fails the run continues direct and says so rather than failing.

## Actor input object example

```json
{
  "mode": "idSearch",
  "idSearchTargets": [
    "jual/rumah"
  ],
  "sgSearchTargets": [
    "rent"
  ],
  "maxPages": 5,
  "maxItemsPerTarget": 0,
  "includeProjectPlacements": true,
  "concurrency": 3,
  "requestDelaySecs": 1,
  "maxRetries": 4,
  "requestTimeoutSecs": 45,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}
```

# Actor output Schema

## `results` (type: `string`):

Every listing, location, HDB price and diagnostic error record from this run.

# 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 = {
    "idSearchTargets": [
        "jual/rumah"
    ],
    "sgSearchTargets": [
        "rent"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("fanndev/99co-scraper").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 = {
    "idSearchTargets": ["jual/rumah"],
    "sgSearchTargets": ["rent"],
}

# Run the Actor and wait for it to finish
run = client.actor("fanndev/99co-scraper").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 '{
  "idSearchTargets": [
    "jual/rumah"
  ],
  "sgSearchTargets": [
    "rent"
  ]
}' |
apify call fanndev/99co-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,fanndev/99co-scraper"
        }
    }
}
```

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/oWhGrf381fRGvQ4j7/builds/33icUh2HaU4X4lNDf/openapi.json
