# JobStreet & JobsDB Jobs Scraper (Asia) (`hexagone-data/jobstreet-scraper`) Actor

Scrape JobStreet (Malaysia, Singapore, Philippines, Indonesia) and JobsDB (Hong Kong, Thailand) jobs: title, company, location, salary text, dates, optional full ad. Reads every list version it finds across visitors, not just one. Pay per job.

- **URL**: https://apify.com/hexagone-data/jobstreet-scraper.md
- **Developed by:** [Hexagone Data](https://apify.com/hexagone-data) (community)
- **Categories:** Jobs, Automation
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.05 / 1,000 jobs

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

## JobStreet & JobsDB Jobs Scraper (Asia)

Scrape job offers from **JobStreet** (Malaysia, Singapore, Philippines, Indonesia) and **JobsDB** (Hong Kong, Thailand), six
country sites in one Actor. Paste the search results URL your browser shows, filters included, or a job URL, and get one record
per job: title, advertiser, location, classification and sub-classification, work type, work arrangement, salary text as shown,
listing date, card tags and selling points. Turn on `includeDetails` to also get the full ad, its status and expiry, the apply
type, the employer questions, contacts found in the ad and the company profile, at no extra charge.

What makes it different:

- **Every version of the list it can find.** The site shows different lists to different visitors. For the same search, measured on
  2026-11-02: `midwife` in Malaysia gave 69 or 95 jobs depending on the visitor, 122 together; `pharmacist` in Malaysia 542 or 643,
  724 together; `pharmacist` in Hong Kong 129 or 216, 229 together. A scraper that reads one visitor gets one of these lists, by chance. This
  Actor checks 9 other visitors and reads every version it finds, each job delivered once. Some of the extra jobs are looser matches: the
  site widens its own results. The field `listVersion` tells you which version brought each job, and you can turn this off. Not every search has
  versions: none measured in Thailand (10 searches) nor on searches without a keyword.
- **Whole lists, no 550-result limit on these sites.** Measured: 16,249 distinct jobs of 16,249 for `engineer` in Malaysia.
  The run compares the distinct jobs of each version with the total the site announced. Any gap is reported with both numbers.
- **URLs and filters of all six sites**, in English or in the site's own language (`/th/`, `/id/`, `/zh/`).
- **No browser, no residential proxy**: the site's own search and job details APIs, through the Apify datacenter proxy.

### Try it with 20 jobs

1. Keep the prefilled start URL (`https://my.jobstreet.com/nurse-jobs/in-Kuala-Lumpur`) and **Maximum number of jobs = 20**.
2. Click **Start**. You pay for 20 jobs: $0.035 (details included, start fee included).
3. Open **Jobs overview** for the table, **Run summary** for what was collected and why anything is missing.

### What to paste

| You paste | Example | What you get |
|---|---|---|
| A keyword search | `https://my.jobstreet.com/nurse-jobs/in-Kuala-Lumpur` | every job of the search |
| A classification search | `https://id.jobstreet.com/jobs-in-sales/in-Jakarta` | filters kept exactly as in the URL |
| A search with filters | `https://sg.jobstreet.com/accountant-jobs/full-time?daterange=7&salaryrange=5000-&salarytype=monthly` | same |
| A remote / hybrid search | `https://sg.jobstreet.com/software-engineer-jobs/remote` | `workarrangement` filter |
| A page in the site's language | `https://th.jobsdb.com/th/sales-jobs`, `https://hk.jobsdb.com/zh/accountant-jobs` | translated labels, `url` in that language |
| A job | `https://ph.jobstreet.com/job/95631049` | that job with its details (`detailsSource: jobPage`) |

Supported URL parts: `/<keywords>-jobs`, `/jobs`, `/jobs-in-<classification>[/<sub-classification>]` (the 30 classifications),
`/<keywords>-jobs-in-<classification>`, `/in-<location>`, `/full-time`, `/part-time`, `/contract-temp`, `/casual-vacation`,
`/on-site`, `/remote`, `/hybrid`, and every query parameter of the site (`daterange`, `salaryrange`, `salarytype`, `sortmode`,
`distance`, `classification`, `subclassification`, `workarrangement`...). `page=`, and `jobId=` when you clicked a job on the page, are ignored: the
run reads the whole search. Old domains
(`www.jobstreet.com.my`, `www.jobstreet.com.sg`, `www.jobstreet.com.ph`, `www.jobstreet.co.id`) work. As on the website, an English
`/sales-jobs` means the Sales classification (the site redirects it to `/jobs-in-sales`), not the keyword; `/th/sales-jobs` is the keyword, as on the website. A URL the Actor does not
understand is **rejected with the reason**; it is never replaced by a broader search.

No URL? Fill **Keyword**, **Location**, **Country** (MY, SG, PH, ID, HK, TH), **Language** and the optional filters. URLs win over the
keyword fields.

**Where**: the location is sent as typed and the site decides what it means (`Johor Bahru` is searched as `Johor Bahru Johor`,
`Jakarta Raya` as `Jakarta`); the run summary shows it (`whereSearched`). A place the site does not know ends `no_results` with a
`LOCATION_UNKNOWN` warning: it is never replaced by another place.

### Output

One record per job and run (`jobId` is the key: a job found by two of your URLs or two list versions is delivered once). Unknown values are
`null`, never `""`, `0` or `false`. Real record, **abridged** (ad text and questions shortened; read on 2026-11-02). Every field is listed in the
dataset schema:

```json
{
  "jobId": "95053570",
  "url": "https://my.jobstreet.com/job/95053570",
  "title": "STAFF NURSE / NURSE AIDE",
  "advertiserName": "Little Precious Postnatal Care",
  "advertiserId": "60755985",
  "companyName": "Little Precious Postnatal Care",
  "companyId": "168549543467903",
  "location": "Kuala Lumpur",
  "countryCode": "MY",
  "classification": "Healthcare & Medical",
  "classificationId": "1211",
  "subClassification": "Nursing - Community, Maternal & Child Health",
  "subClassificationId": "6340",
  "workTypes": [
    "Full time"
  ],
  "workArrangements": [
    "On-site"
  ],
  "salaryLabel": "RM 2,500 – RM 3,500 per month",
  "teaser": "Assessment of nursing needs for mothers and babies. Advice on breastfeeding, managing new-born and other health promotion to mothers. Monitor...",
  "bulletPoints": [
    "Meal Allowances + Night Shift Allowances + Overtime",
    "Transportation nearby: LRT Abdullah Hukum (5mins walking distance)",
    "Friendly working environment, we work as team work together"
  ],
  "postedAt": "2026-10-05T03:55:40Z",
  "postedAtLabel": "28d ago",
  "tags": [
    "Expiring soon"
  ],
  "site": "MY",
  "language": "en",
  "sourceUrl": "https://my.jobstreet.com/nurse-jobs/in-Kuala-Lumpur",
  "position": 6,
  "listVersion": 1,
  "detailsSource": "jobPage",
  "descriptionText": "Assessment of nursing needs for mothers and babies.\nAdvice on breastfeeding, managing new-born and other healt...",
  "status": "Active",
  "isExpired": false,
  "expiresAt": "2026-11-04T12:59:59Z",
  "isExternalApply": false,
  "questions": [
    "What's your expected monthly basic salary?",
    "..."
  ],
  "jobPageClassifications": [
    "Nursing - Community, Maternal & Child Health (Healthcare & Medical)"
  ],
  "suburb": null,
  "state": "Kuala Lumpur",
  "country": "Malaysia",
  "area": null
}
```

Good to know:

- `advertiserName` is the name the card shows; `companyName` is the site's normalised company name, not shown on the card, null when the
  site has none.
- `salaryLabel` is the salary text exactly as shown (`RM 4,000 – RM 5,000 per month`). The site has no structured salary in its
  answers: no amount is invented.
- `classification` and `subClassification` are the first classification (the one the card shows); `classifications` lists all.
- `listVersion`: 1 = in the list of the run's first visitor; 2 or more = another visitor sees this job, the first one does not.
- `position` is the rank in the list of that version as the search API serves it. The order depends on the visitor.
- `language` follows the page you pasted. It changes translated labels (work type, location, classification, dates), never the jobs.
- With `includeDetails` off, every detail field is null (or `[]`) and `detailsSource` is `listing`.

### Run summary

Each run writes a readable **Run summary** (and the same as JSON in `OUTPUT`): per start URL, the status, jobs delivered vs jobs
announced by the site, the list versions read (`listVersions`), and the reason for anything missing.

| Status | Meaning |
|---|---|
| `done` | in every list version read: distinct jobs seen + jobs the site repeated inside one page reach the jobs announced, and no page failed, came back empty, only repeated earlier jobs or saw the total collapse. One exception, always shown with its numbers: the site served every announced row but its ranking moved while a long list was read, so some jobs were seen twice (at most 1 job or 0.5%, plus the jobs the site added to its total meanwhile; worst measured 38 of 16,318) |
| `partial_collection` | something is missing: both numbers and the cause; warning `PARTIAL_COLLECTION` |
| `stopped_at_limit` | your `maxItems` or maximum charge was reached |
| `no_results` | the site announced 0 jobs: the search is empty, the URL is wrong or the site is down (`NO_RESULTS` warning) |
| `no_new_jobs` | every job of this URL was already delivered by an earlier URL |
| `job_not_available` | a pasted job id does not exist (the site answers "no job" without error); no job charged |
| `blocked_by_site`, `rate_limited`, `network_error`, `http_error`, `not_found`, `site_maintenance` | the site refused or did not answer even after new IPs: never reported as an empty search |
| `unrecognized_page`, `structure_changed` | the answer is not a search result the Actor can read; never reported as an empty search |
| `not_processed`, `interrupted`, `internal_error` | the URL was not reached or finished (limit reached before it, run stopped, unexpected error) |

`jobsAnnouncedBySite` is the largest total of a version: with several versions, `jobsSeen` can be higher than it. A few jobs removed while the
list is read (measured: 3 of 17,815) are normal; a bigger drop is reported. Codes starting with `ANO_` (`ANO_ACCES`, `ANO_CHAMP_VIDE`,
`ANO_ERREUR_INTERNE`...) come from the shared run checks: access verdicts, a field suddenly empty, an internal error.

A run where no URL delivers anything **fails** with the exact cause. Warnings (`PARTIAL_COLLECTION`, `UNEXPECTED_DATA`, `JOBS_SKIPPED`,
`JOB_EXPIRED`, `DETAILS_NOT_READ`, `DETAILS_STOPPED`, `VISITORS_NOT_CHECKED`, `LOCATION_UNKNOWN`...) are listed under **Checks**; "Checks passed" is
shown only when there is none.

### Pricing

Pay per event: **$1.50 per 1,000 jobs** ($0.0015 per job delivered), **details included**: the same price with or without
`includeDetails`, and for a pasted job URL. Plus a **$0.005 start fee per run** (charged by Apify once per GB of memory; the
default is 1 GB).
Apify subscribers pay less automatically, on every event: 10% off on the Bronze plan, 20% on Silver, 30% on Gold and above.

| Jobs | Price (with or without details, start fee included) |
|---|---|
| 20 | $0.035 |
| 1,000 | $1.505 |

Only jobs written to your dataset are paid. Reading every list version brings more jobs for a search, so a search with no `maxItems`
costs more than one visitor's list: set `maxItems`, or turn `allVersions` off, if you want one list only. Your maximum charge per run is
respected: the run stops cleanly before the job that would exceed it, and no job details are read for a job that could not be paid.

### Known limits

- The list versions are found by asking 9 other visitors: a version seen by few visitors can be missed (measured: a version seen by 1 visitor in 10
  is missed about one run in three), and the run cannot prove there is no other. A visitor counts as another version when its total differs (by more
  than 2 jobs and 0.5%) or when its first 100 jobs hold one never seen; a version that differs only further down its list, with the same total, is not read.
  Jobs posted shortly before or while the list is being read do not count as a version: they are seen, counted (`jobsPostedDuringRun`) and not delivered; run again to get them.
  At most 6 versions are read per search (2 to 4 measured); more would be reported.
- `maxItems` (default 500) stops the run where it is: reached in the first list, no other visitor is checked, and the summary says so.
- Some jobs of a list are looser matches: the site widens keyword searches on its own, more for some visitors than for others.
- `position` differs from the rank on the website, and between visitors: the order is the site's ranking and depends on the visitor.
- `postedAtLabel` ("17h ago") is relative to the reading time. Featured ads, multiple locations and multiple classifications per job were never
  seen in 1,740 jobs read on 2026-11-02: delivered as the site sends them.
- Not provided: number of applicants (the site shows it to signed-in users only), estimated salary, salary amounts as numbers (the text is delivered as shown).
- Several work types or arrangements in the keyword fields are sent comma-separated, as the website's filter does.
- Vietnam is not covered: JobStreet no longer serves it (`www.jobstreet.vn` redirects elsewhere).
- Default run timeout: 4 hours (about 1 s per job with details on Apify, measured: 1,191 jobs in 20 minutes; some 14,000 jobs); raise it for larger searches with details.

### Support

Questions, a URL that does not work, a field you need: open an issue on the Actor's **Issues** tab with the run link.

This Actor is not affiliated with, endorsed or sponsored by SEEK Limited, JobStreet or Jobsdb. These names are trademarks of their owners, used here
only to describe the websites the data comes from. Only publicly visible job ads are collected; use the data in line with the site's terms
and the privacy laws that apply to you (contacts found in ads are personal data).

# Actor input Schema

## `startUrls` (type: `array`):

JobStreet or JobsDB search results URLs exactly as your browser shows them, filters included (e.g. https://my.jobstreet.com/nurse-jobs/in-Kuala-Lumpur, https://sg.jobstreet.com/accountant-jobs/full-time?daterange=7, https://th.jobsdb.com/th/sales-jobs, https://hk.jobsdb.com/zh/accountant-jobs), or job URLs (https://ph.jobstreet.com/job/<id>). When given, keyword and its filters are ignored.

## `keyword` (type: `string`):

Job search keywords, e.g. nurse, data analyst.

## `location` (type: `string`):

Where, as the site writes it: 'Kuala Lumpur', 'Cebu', 'Bangkok', 'Kowloon'. The site decides what the text means; the run summary tells which place was searched.

## `country` (type: `string`):

MY, SG, PH, ID = JobStreet; HK, TH = JobsDB.

## `language` (type: `string`):

en = English on every site; id only on Indonesia, th only on Thailand, zh only on Hong Kong. It changes translated labels (work type, location, classification), not the list of jobs.

## `workTypes` (type: `array`):

Any of: full-time, part-time, contract-temp, casual-vacation.

## `workArrangements` (type: `array`):

Any of: on-site, hybrid, remote.

## `classification` (type: `string`):

Classification id or slug as in the site URLs, e.g. 6281 or information-communication-technology.

## `dateRange` (type: `integer`):

1, 3, 7, 14 or 31 like the site; empty = any time.

## `salaryMin` (type: `integer`):

Only jobs paying at least this (site filter salaryrange=<min>-).

## `salaryType` (type: `string`):

Period of the minimum salary (monthly is the site's default in Asia).

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

relevance (site default) or date (newest first).

## `maxItems` (type: `integer`):

Stops after this many unique jobs across all start URLs. 0 = no limit. You pay per delivered job.

## `includeDetails` (type: `boolean`):

Off by default. When on, each delivered job's details are read once (the site's own job details API): full ad (HTML and text), status and expiry, apply type, employer questions, contacts found in the ad, video and branding, company profile (industry, size, website, rating, reviews, perks), suburb, state and postcode. Details are included in the price per job: no extra charge ($1.50 per 1,000 jobs with or without details).

## `allVersions` (type: `boolean`):

On by default. The site serves different lists to different visitors (measured: 69 or 95 jobs for the same search, 122 together). When on, 9 other visitors are checked and every version with another total or jobs never seen is read, so you get more jobs for the same search, each one once, and pay for each. Field listVersion tells which version brought a job (1 = the first visitor). Off: one visitor only.

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

Apify datacenter proxy by default: enough for the site's search and job details APIs on all 6 sites (measured 2026-11-02). A refused or timed-out request is retried with a new IP, up to 4 times.

## Actor input object example

```json
{
  "startUrls": [
    {
      "url": "https://my.jobstreet.com/nurse-jobs/in-Kuala-Lumpur"
    }
  ],
  "country": "MY",
  "language": "en",
  "salaryType": "monthly",
  "sortBy": "relevance",
  "maxItems": 20,
  "includeDetails": false,
  "allVersions": true,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `jobs` (type: `string`):

All job records, one per job offer, with every field.

## `jobsOverview` (type: `string`):

Title, advertiser, location, work type, salary, classification, date, link.

## `summary` (type: `string`):

Readable per-URL summary: status, jobs delivered vs announced, reason for anything missing.

## `summaryJson` (type: `string`):

The same summary as JSON (OUTPUT 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 = {
    "startUrls": [
        {
            "url": "https://my.jobstreet.com/nurse-jobs/in-Kuala-Lumpur"
        }
    ],
    "maxItems": 20,
    "includeDetails": false,
    "allVersions": true,
    "proxyConfiguration": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("hexagone-data/jobstreet-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 = {
    "startUrls": [{ "url": "https://my.jobstreet.com/nurse-jobs/in-Kuala-Lumpur" }],
    "maxItems": 20,
    "includeDetails": False,
    "allVersions": True,
    "proxyConfiguration": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("hexagone-data/jobstreet-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 '{
  "startUrls": [
    {
      "url": "https://my.jobstreet.com/nurse-jobs/in-Kuala-Lumpur"
    }
  ],
  "maxItems": 20,
  "includeDetails": false,
  "allVersions": true,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}' |
apify call hexagone-data/jobstreet-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,hexagone-data/jobstreet-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/oqxbbR5eAIX55YrHP/builds/aoSt6NkAttVu9aVDd/openapi.json
