# Indotrading.com B2B Supplier & Product Scraper (`fanndev/indotrading-scraper`) Actor

Scrape products, suppliers and contacts from Indotrading.com, Indonesia's largest B2B marketplace. Seven modes: category listings, product search, product detail, supplier profiles, supplier directories, company search and the full category tree. Optional supplier WhatsApp number. No login needed.

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

## Pricing

from $2.00 / 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

## Indotrading.com B2B Supplier & Product Scraper

Scrapes products, suppliers and supplier contact details from
[Indotrading.com](https://www.indotrading.com), Indonesia's largest B2B
marketplace directory. HTTP-only, no browser, no login, public data.

Seven modes, one actor. They share a TLS gate, a session and a product-card
parser, which is why they are not seven actors.

| Mode | Input | Emits |
| --- | --- | --- |
| `categoryProducts` | `categories` | Every product under a category, 40/page |
| `searchProducts` | `searchTerms` | Keyword product search |
| `productDetail` | `productUrls` | Full product record from schema.org JSON-LD |
| `supplierProfile` | `suppliers` | Company profile, optionally its whole catalogue |
| `supplierDirectory` | `categories` | Every supplier under a category |
| `searchSuppliers` | `searchTerms` | Keyword company search |
| `categoryTree` | — | All ~4,010 category slugs, in one request |

Optional per-run enrichment:

- **`includeContact`** — the supplier's WhatsApp number. One extra request per
  product. About two thirds of sellers have one on file.
- **`includeLastLogin`** — minutes since the supplier last signed in, the
  freshness signal the site renders as *"Online 2 Hari Lalu"*. Batched per
  page, so effectively free.
- **`includeSupplierProducts`** — in `supplierProfile`, also walk the
  supplier's own catalogue pages.

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

**Cloudflare, TLS fingerprint only.** There is no JS challenge, no clearance
cookie, no token to harvest and no rate limit worth the name.

| Client | Result |
| --- | --- |
| `requests` / `httpx` | **403** — Cloudflare "Attention Required" |
| `curl_cffi` chrome124, chrome131, safari17\_0, firefox133, edge101, chrome99\_android | **200**, all six |

All six profiles were tried on the same IP within the same minute as the 403.
So the profile ladder in `constants.TLS_PROFILES` is a *recovery* path, not a
search: a challenge body or an unexpected 403 rotates to the next profile and
rebuilds the session rather than failing the run.

**No IP gate.** 25 sequential requests in 9.2 s from one datacenter address all
returned full product payloads. Proxying is therefore **off by default**.
Residential stays available for an Indonesian exit address or high volume.

If Apify Proxy is requested but cannot be set up — which is what a **local**
run gets on a plan without the *Proxy external access* feature — the actor logs
a loud warning and continues direct rather than failing the run, because for
this target a proxy is a nicety rather than a requirement. On the Apify
platform the proxy path works normally.

### Surfaces and how they were found

`robots.txt` leaks `/api/` and `/AjaxMethod.asmx`. The `.asmx` endpoint is dead
— it falls through to the homepage. The live API host comes from
`window.storefrontApiHost` in the Astro bundle
`CategoryInteractions.astro_*.js`, which points at
`https://webapi.indotrading.com`. Three of its endpoints are open to anonymous
callers with no `Authorization`, no cookie and no origin check:

| Endpoint | Gives |
| --- | --- |
| `GET /api/Product/GetWhatsappLeadsData?secureProductId=` | Supplier WhatsApp number |
| `POST /api/Company/GetListCompanyLastLogin` | Minutes since last login, batched |
| `GET /api/Product/ListByCompany?companyId=` | A company's product ids |

The pages themselves are Astro SSR, so the product and supplier data is in the
HTML. Product detail pages carry full schema.org JSON-LD (`Product`, `Store`,
`BreadcrumbList`), which is what this actor reads there — it survives restyles
that break CSS selectors.

### Traps this actor handles

These are the things that will silently corrupt a naive scrape of this site.

**1. The page counter lies.** `/jual-pressure-washer` prints *"Menampilkan 640
Produk … (Halaman 1 dari 16)"* and runs dry at page 13, yielding **362** real
products. `/jual-mesin-las` advertises 79 pages and is empty well before 50.
The advertised numbers are recorded as `advertisedPages` /
`advertisedTotalProducts` and **never** used as a stop condition — paging stops
on the first page that renders zero cards.

**2. Pagination overlaps, intermittently.** Page 3 of `/jual-pressure-washer`
comes back as a repeat of page 2 (0 new items), while pages 5–11 are all fresh.
Records are deduplicated per target by `productId` / `companySlug`, but a page
of pure repeats does **not** stop the walk — stopping there would have cost 280
of the 362 products. The run log reports how many repeats were dropped.

**3. Unknown paths return HTTP 200 with the homepage.** `/rfqlist/pompa`,
`/sitemap.xml` and `/supplier` all answer 200 with the identical 451 KB
homepage instead of a 404. A page carrying the homepage `<title>` when
something else was expected is treated as `not_found`. Real 404s also exist
(a 24-byte body for a dead category, a full *"Produk Tidak Ditemukan"* page for
a dead product id) and map to the same diagnosis.

**4. Decoy pagination parameters.** `?page=N` works, and so does the bare `/N`
suffix. `hal`, `p`, `pg` and `PageNumber` are all accepted with HTTP 200 and
**silently ignored**, returning page 1 every time — success-shaped nonsense.

**5. Hidden membership badges in the page header.** The logged-out header
carries one hidden `.membership-supplier` badge per tier (free, platinum,
diamond) as templates for the member menu. A document-wide query returns tiers
belonging to nobody. Badge reads are scoped to `#comproHeader` or to the
individual card.

**6. The WhatsApp button is a UI gate, not a data gate.** Only 18 of 40 cards
render a `.showWa` button, but the contact endpoint returns a number for the
other 22 as well. The encrypted id is read from whichever button carries
`data-enc-pid` (the phone button always does), taking contact coverage from
45% to ~100% of *lookups*, and ~67% of products end up with an actual number.

**7. `priceRaw` and `priceText` disagree on purpose.** When a seller hides the
price the page prints *"Minta penawaran"* over a placeholder number such as
`123`. Both values are emitted; reading `priceRaw` alone will invent prices.

**8. Indonesian digit grouping.** `Menampilkan 4.004 Produk` is four thousand
and four, not 4.004.

**9. The search redirect discards the page parameter.** A keyword matching a
category name is 302'd from `/searchkeyword.aspx?keyword=mesin+las&page=2` to
`/jual-mesin-las` — **without** the query string. Re-requesting the search URL
for page 2, 3, 4 therefore lands on page 1 of the category every time, and the
scrape silently caps at 40 products while looking like it paged four times.
This actor follows the redirect once on page 1 and then pages the destination
path directly, which took `mesin las` from 40 records to 158.

### Closed surfaces

- **`/rfqlist/`** (buying requests / tenders) — reachable but always empty for
  anonymous callers: *"Maaf, kami tidak menemukan Permintaan …"* for every
  keyword and category tried. Login-gated.
- **`/AjaxMethod.asmx/*`** — advertised in `robots.txt`, falls through to the
  homepage.
- **No sitemaps.** `/sitemap.xml` and friends are homepage fallbacks. Use
  `categoryTree` instead: one request, ~4,010 slugs.

### Output

Every record carries the house envelope — `_input`, `_source`, `_scrapedAt`,
`recordType` — on top of the site's own fields. `recordType` is one of
`product`, `supplier`, `category` or `error`.

Failures never disappear. A dead slug, an empty result set or a failed fetch
each emit exactly one diagnostic row (`_error` = `not_found`, `no_results`,
`fetch_failed`, `unexpected_shape`, `run_failed`), so a downstream join always
sees a row for every input. Non-fatal degradation is marked with `_warning`,
which distinguishes *"this seller has no WhatsApp number on file"*
(`no_whatsapp_number_on_file`) from *"the lookup did not complete"*
(`contact_lookup_failed`).

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

### Local development

```bash
pip install -r requirements.txt

## Exercise all seven modes plus the not-found controls against the live site
python test_local.py
python test_local.py categoryProducts productDetail

## 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 seven modes return data; all three not-found controls return exactly one
  diagnostic row.
- `categoryProducts` on `/jual-pressure-washer` with `maxPages: 14` →
  **362 unique products from 79 suppliers, 79 repeats dropped, zero duplicates
  on disk**, clean stop at page 13.
- `categoryTree` → 4,010 categories in one request.
- `searchProducts` on the redirecting term `mesin las` with `maxPages: 4` →
  158 unique products across pages 1–4 (40 before the redirect fix).
- Contact enrichment: 100% of lookups answered, 67% carried a number.
- Last-login enrichment: 361/362.
- Field coverage 100% on `productId`, `productName`, `priceText`, `companyName`.

### Notes on use

Indotrading's `robots.txt` disallows a number of named crawlers and the `/api/`
prefix. This actor scrapes public, unauthenticated pages; check that your own
use fits your legal and contractual obligations before running it at volume,
and keep `maxPages` proportionate to what you actually need.

# Actor input Schema

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

Which surface to scrape. Each mode reads one input list: categoryProducts and supplierDirectory read 'categories', searchProducts and searchSuppliers read 'searchTerms', productDetail reads 'productUrls', supplierProfile reads 'suppliers', and categoryTree needs no input at all.

## `categories` (type: `array`):

Category slugs or URLs, used by categoryProducts and supplierDirectory. All four shapes work: 'jual-pressure-washer', '/jual-pressure-washer', 'pressure-washer', or the full https://www.indotrading.com/jual-pressure-washer URL. Run the categoryTree mode first to get the full list of valid slugs.

## `searchTerms` (type: `array`):

Keywords, used by searchProducts and searchSuppliers. A term that matches a category name is redirected to that whole category; a term that does not stays on the search results page with a narrower set.

## `productUrls` (type: `array`):

Full product page URLs for productDetail mode, e.g. https://www.indotrading.com/solusijaya10/high-pressure-cleaner-w250-18dpt-with-engine-yanmar-p1146289.aspx. The productUrl field of any listing record can be fed straight back in here.

## `suppliers` (type: `array`):

Supplier profile slugs or URLs for supplierProfile mode, e.g. 'lavorpro' or https://www.indotrading.com/lavorpro. The companyUrl field of any listing record works here.

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

How many listing pages to walk per category, search term or supplier. Product listings return 40 items per page, supplier directories about 5. Paging always stops early on the first empty page - indotrading's own 'Halaman 1 dari N' counter overstates how far a category can actually be paged, so it is recorded as metadata but never trusted as a stop condition.

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

Hard cap on records emitted per category or search term. Leave at 0 for no cap beyond maxPages.

## `includeContact` (type: `boolean`):

Look up each product's supplier WhatsApp number. This costs one extra request per product, roughly doubling the run, so it is off by default. About two thirds of sellers have a number on file; the rest come back with whatsappNumber null and \_warning 'no\_whatsapp\_number\_on\_file', which is a real absence rather than a failed lookup.

## `includeLastLogin` (type: `boolean`):

Add lastLoginMinutesAgo, how many minutes ago the supplier last signed in - the freshness signal indotrading renders as 'Online 2 Hari Lalu'. Batched into one request per page, so it is close to free.

## `includeSupplierProducts` (type: `boolean`):

In supplierProfile mode, also emit every product on the supplier's own pages (20 per page, up to maxPages). Off by default so a profile run stays one record per supplier.

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

Parallel requests for per-item fetches (product details and contact lookups). Listing pages are always walked in order because each page's referer is the previous page.

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

Attempts per request before giving up. Each retry backs off exponentially with jitter and rotates to the next TLS impersonation profile.

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

Per-request timeout in seconds. Category pages are large (500 KB and up), so do not set this too low.

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

Optional. Indotrading has no IP-based gate - 25 sequential requests from a single datacenter address all returned full payloads during recon, and the only Cloudflare rule in play keys on the TLS fingerprint, which this actor already handles. So proxying is off by default; turn on residential if you want an Indonesian exit address or are running at high volume.

## Actor input object example

```json
{
  "mode": "categoryProducts",
  "categories": [
    "jual-pressure-washer"
  ],
  "searchTerms": [
    "excavator komatsu pc200"
  ],
  "maxPages": 5,
  "maxItemsPerTarget": 0,
  "includeContact": false,
  "includeLastLogin": false,
  "includeSupplierProducts": false,
  "concurrency": 4,
  "maxRetries": 4,
  "requestTimeoutSecs": 45,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

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

Every product, supplier, category 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 = {
    "categories": [
        "jual-pressure-washer"
    ],
    "searchTerms": [
        "excavator komatsu pc200"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("fanndev/indotrading-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 = {
    "categories": ["jual-pressure-washer"],
    "searchTerms": ["excavator komatsu pc200"],
}

# Run the Actor and wait for it to finish
run = client.actor("fanndev/indotrading-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 '{
  "categories": [
    "jual-pressure-washer"
  ],
  "searchTerms": [
    "excavator komatsu pc200"
  ]
}' |
apify call fanndev/indotrading-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,fanndev/indotrading-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/3o4s5udPnU7kIFsvm/builds/cOzxKCtneivBwBSZT/openapi.json
