# JobStreet Scraper — MY, SG, ID, PH (+ JobsDB TH & HK) (`corvuslab/jobstreet-scraper`) Actor

Scrape JobStreet jobs across Malaysia, Singapore, Indonesia and the Philippines by keyword, location, category, work type, date or salary — with parsed salaries, full descriptions, company profiles, incremental change tracking and notifications.

- **URL**: https://apify.com/corvuslab/jobstreet-scraper.md
- **Developed by:** [Corvuslab](https://apify.com/corvuslab) (community)
- **Categories:** Jobs, Lead generation, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: 5.00 out of 5 stars

## Pricing

from $1.70 / 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.

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#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

## JobStreet & JobsDB Scraper — 6 Markets, Parsed Salaries, Change Tracking

Extract structured job data from **JobStreet** (Malaysia, Singapore, Indonesia, the Philippines) and **JobsDB** (Thailand, Hong Kong) — with parsed salaries, full descriptions, company profiles, recruiter contacts and incremental change tracking for recurring monitoring.

Purpose-built for throughput: a run moves **~28× less data** than a conventional JobStreet scraper and finishes in seconds instead of minutes. **2,000 job listings in 16 seconds.**

Every input field is optional — run it with no input at all and you'll get the newest Malaysian listings.

***

### ✨ Key features

- 🌏 **Six markets, one actor** — every JobStreet and JobsDB market in Southeast and East Asia, switched with a dropdown. All six are verified working end to end.
- ⚡ **Fast by design** — 2,000 listings in 16 seconds, or under 5 minutes with full descriptions and company profiles for every one.
- 💰 **Parsed salaries, not just labels** — every salary is broken into `salaryMin`, `salaryMax`, `salaryCurrency` and `salaryType`, correctly handling Indonesian `Rp 4.000.000` dot-grouping, Hong Kong `$30k p.w.` shorthand, and hourly/weekly/daily/annual periods.
- 🔀 **Drop-in replacement** — accepts the input shapes other JobStreet actors use and can emit their field names too, so you can usually switch without touching your pipeline. [See below](#-switching-from-another-jobstreet-scraper).
- 📁 **Readable category dropdowns** — pick "Information & Communication Technology → Engineering - Software" from a list of 30 categories and 394 sub-categories. No hunting for numeric codes.
- 🏢 **Company intelligence** — industry, size, website, specialities, perks, registration number, verification status and advertiser age.
- 📧 **Recruiter contacts** — emails, phone numbers, social profiles and contact names parsed out of every description, plus a `requireContact` filter for building outreach lists.
- ⭐ **Company reviews** — employee rating and review count for employers that have them.
- 🌱 **Demand signals that are actually real** — the sites' own badges (`Be an early applicant`, `Urgently hiring`, `Expiring soon`), filterable via `requireTags`, so you can target low-competition roles. There is **no numeric applicant count** in these markets — see the [FAQ](#does-this-report-how-many-people-have-applied) before believing a scraper that offers you one.
- 🔄 **Incremental monitoring** — recurring runs emit only **new**, **updated** and **reappeared** listings. Measured on a real search: a second run returned **0 results instead of 359** and finished in **1.9 seconds instead of 75**.
- 🎯 **Post-filters that cost nothing** — keyword, date, salary, recency and custom field rules are applied before results are pushed, so you are never billed for jobs you filtered out.
- 🔔 **Job alerts with no code** — push matches to Telegram, Slack, Discord, WhatsApp or any webhook (n8n, Make, Zapier) when a run finishes.
- 🔗 **Paste-mode URLs** — copy any search or job URL straight from your browser; the market is detected automatically.
- 🤖 **Built for AI agents** — a compact core-field payload and an MCP endpoint for tool-calling workflows.
- 🔐 **Credentials stay secret** — every token and webhook URL is an encrypted secret input, and the run log is scrubbed so nothing leaks into output you can read.

***

### 🌏 Supported markets

| Market | Site | Currency | Verified |
| --- | --- | --- | --- |
| 🇲🇾 Malaysia | `my.jobstreet.com` | MYR | ✅ |
| 🇸🇬 Singapore | `sg.jobstreet.com` | SGD | ✅ |
| 🇮🇩 Indonesia | `id.jobstreet.com` | IDR | ✅ |
| 🇵🇭 Philippines | `ph.jobstreet.com` | PHP | ✅ |
| 🇹🇭 Thailand | `th.jobsdb.com` | THB | ✅ |
| 🇭🇰 Hong Kong | `hk.jobsdb.com` | HKD | ✅ |

JobStreet and JobsDB are both SEEK brands running on the same platform, so all six behave identically — same input shape, same output fields, same speed.

#### Scrape JobStreet Malaysia

The default market. Salaries are `RM 3,700 – RM 5,500 per month`; `locationRegion` carries the state.

```json
{ "country": "MY", "query": "data analyst", "location": ["Kuala Lumpur"], "maxResults": 100 }
```

#### Scrape JobStreet Singapore

Salaries appear as a bare `$3,200 – $4,300 per month` and are correctly tagged `SGD`, plus hourly rates for contract work.

```json
{ "country": "SG", "category": ["Banking & Financial Services"], "requireSalary": true, "maxResults": 100 }
```

#### Scrape JobStreet Indonesia

The only market with localised classification labels. `category` stays canonical English (`Marketing & Communications`) while `categoryLabel` preserves the Indonesian (`Pemasaran & Komunikasi`). Salaries use dot-thousands — `Rp 4.000.000` parses to `4000000`, not `4`.

```json
{ "country": "ID", "query": "data scientist", "location": ["Jakarta"], "maxResults": 100 }
```

#### Scrape JobStreet Philippines

Peso salaries (`₱22,000 – ₱26,000 per month`) and a high rate of employers publishing contact details, which makes PH the strongest market for lead generation.

```json
{ "country": "PH", "query": "accountant", "requireContact": "either", "maxResults": 100 }
```

#### Scrape JobsDB Thailand

Thai-language descriptions and screening questions come through intact in all three formats. Classifications are returned in English.

```json
{ "country": "TH", "query": "engineer", "location": ["Bangkok"], "maxResults": 100 }
```

#### Scrape JobsDB Hong Kong

Traditional Chinese and English listings both parse cleanly. The bare `$` is resolved to **HKD** from the market rather than misread as USD, and weekly/hourly shorthand (`$30k p.w.`, `$80 – $100 per hour`) is expanded correctly.

```json
{ "country": "HK", "query": "accountant", "maxResults": 100 }
```

***

### ⚡ Why this scraper is fast

Most JobStreet scrapers pull down the full search and job pages and pick the data out of the markup. This one retrieves only the fields it actually emits. Measured on live traffic:

| Per job | HTML page scraping | This actor | Saving |
| --- | --- | --- | --- |
| Search results | ~31 KB | ~1.6 KB | **19× less** |
| Job detail | ~181 KB | ~6 KB | **30× less** |
| **Total** | **~212 KB** | **~7.6 KB** | **~28× less** |

Fewer bytes means fewer requests — 100 jobs per search request instead of 30 — less to parse, and far less waiting.

**Measured throughput** on the Apify platform, with zero failed requests:

| Mode | 2,000 jobs | Requests |
| --- | --- | --- |
| Listings only | **16 seconds** | 21 |
| Full descriptions + company profiles | under 5 minutes | ~2,020 |

Listings-only is about **8× faster**, because a full-detail run needs one request per job where a listings-only run needs one per hundred. Use it when you only need listing-level fields and turn `includeDetails` back on when you want the enrichment.

**Proxy handling is automatic and included** — nothing to configure, no proxy account of your own, no blocked-request babysitting. Leave `proxyType` on `auto`.

***

### 🛡️ Reliability

Every number below was measured on the Apify platform, not locally.

| | Result |
| --- | --- |
| Failed requests, 2,000-job run | **0** — `retry_histogram [2020]`, no retries needed |
| Blocked requests | none — no escalation to residential proxies was needed |
| Memory | avg 95 MB, peak 120 MB — no growth over the run |
| Markets verified end to end | **6 of 6** |

**When the sites change, this gets fixed.** Job boards change their APIs, and a scraper that isn't maintained quietly rots. If a field stops populating or a market breaks, open an issue on the Actor's **Issues** tab and it will be looked at. Bug reports with a run ID are the fastest route to a fix.

**Spend is bounded by design.** `maxResults` caps the whole run, post-filters run before anything is pushed, and the actor stops cleanly when it reaches your spend cap rather than overrunning it.

***

### 🔀 Switching from another JobStreet scraper

Already running one and don't want to rewrite your pipeline? You mostly don't have to.

**Inputs are accepted in the shapes other actors use.** `query` and `location` work as a plain string, a real array, *or* a JSON-encoded list inside a string (`'["data analyst", "business analyst"]'`). `startUrls` accepts bare URL strings as well as `{"url": …}` objects. `customFilters` accepts `op` as well as `operator`. `category` and `subCategory` accept raw numeric SEEK codes alongside the readable names. In most cases you can paste your existing input straight in.

**Output field names.** Almost every field already matches by name. Four don't, and setting `compatFieldNames: true` emits the other names as extra copies, so both your old and new field references keep working:

| Our field | Also emitted as | Holds |
| --- | --- | --- |
| `url` | `canonicalUrl` | The listing URL |
| `workType` | `employmentType` | `Full time`, `Part time`, … |
| `expiresAt` | `validThrough` | ISO expiry timestamp |
| `locationRegion` | `locationState` | State / province |

Aliases are added, never substituted — nothing that reads our names breaks. They cost a few bytes per record, so leave the option off once you've migrated.

**One value-level difference to check:** our `workArrangement` is `On-site` / `Hybrid` / `Remote` (capitalised), where some actors emit `on-site` / `hybrid` / `remote`. If you filter on that string downstream, lower-case it.

**Fields we deliberately don't emit:** `applicantCount`, `applicantVolumeLabel` and `coverLetterPercentage`. These don't exist in the public data for these markets — we probed the API exhaustively for them. Use `isEarlyApplicant` and `requireTags` for the low-competition targeting they're meant to serve; see the [FAQ](#does-this-report-how-many-people-have-applied).

***

### 📊 What data can you extract?

**Core listing**
`jobId` · `seekJobId` · `title` · `company` · `advertiserId` · `url` · `applyUrl` · `applyEmail` · `phoneNumber` · `roleId` · `normalisedRoleTitle`

**Location (structured)**
`location` · `locationCountry` · `locationSuburb` · `locationArea` · `locationRegion` · `locationPostcode`

**Salary (parsed)**
`salaryText` · `salaryMin` · `salaryMax` · `salaryCurrency` · `salaryType`

**Classification**
`workType` · `workArrangement` · `workArrangementLabel` · `category` · `subCategory` · `categoryLabel` · `subCategoryLabel` · `categoryId` · `subCategoryId`

> `category`, `subCategory` and `workArrangement` are always the canonical English value, so
> results from different markets aggregate cleanly. Where a market publishes a localised name —
> Indonesian listings say *Pemasaran & Komunikasi* and *Kantor* — the original is kept alongside
> in `categoryLabel`, `subCategoryLabel` and `workArrangementLabel`. Those three appear **only
> when they differ** from the canonical value, so they are absent in the English-language markets.

**Description** *(with `includeDetails`)*
`teaser` · `abstract` · `description` · `descriptionHtml` · `descriptionMarkdown` · `descriptionLength` · `bulletPoints` · `screeningQuestions`

**Company intelligence** *(with `includeDetails`)*
`companyLogo` · `companyCoverImageUrl` · `companyDescription` · `companyIndustry` · `companySize` · `companyWebsite` · `companySpecialities` · `companyPrimaryLocation` · `companyPerks` · `companyUrl` · `companySearchUrl` · `companyRegistrationNo` · `companyReviewRating` · `companyReviewCount` · `advertiserRegisteredAt`

**Contacts** *(with `extractContacts`)*
`contactName` · `extractedEmails` · `extractedPhones` · `extractedUrls` · `socialProfiles`

**Demand signals**
`jobTags` · `jobTagLabels` · `isEarlyApplicant` · `isUrgent` · `isExpiringSoon`

**Status & dates**
`postedDate` · `postedDateDisplay` · `expiresAt` · `jobStatus` · `isSponsored` · `isFeatured` · `isVerifiedAdvertiser` · `isPrivateAdvertiser` · `isExternalApply` · `hasScreeningQuestions`

**Change tracking** *(with `incrementalMode`)*
`changeType` · `firstSeenAt` · `lastSeenAt` · `isRepost` · `repostOfId` · `repostDetectedAt` · `trackedHash` · `stateKey`

**Run metadata**
`searchQuery` · `searchUrl` · `sourceUrl` · `sourceCountry` · `sourceSite` · `sourceDomain` · `contentHash` · `contentQuality` · `detailFetched` · `fetchedAt` · `scrapedAt`

***

### ⚙️ Input

Everything is optional.

#### 🔍 Search

| Field | Type | Description |
| --- | --- | --- |
| `country` | select | Market to search: `MY`, `SG`, `ID`, `PH`, `TH`, `HK`. Default `MY`. |
| `query` | string | Search keywords, e.g. `software engineer`. Leave empty to browse everything. Also accepts a JSON list — `["data analyst", "business analyst"]` — to run several searches at once. |
| `queries` | array | Extra keyword searches. Each is a **separate** search; results are merged and de-duplicated across all queries. |
| `location` | array | City, state or region, e.g. `Kuala Lumpur`, `Metro Manila`. Add several to search each. Accepts a plain string or a JSON list too. |
| `startUrls` | array | Paste JobStreet / JobsDB search or `/job/<id>` URLs straight from your browser. |

#### 🎛️ Filters *(applied by the site — filtered-out jobs are never fetched or billed)*

| Field | Type | Description |
| --- | --- | --- |
| `category` | select | One of 30 job categories, by name. |
| `subCategory` | select | One of 394 sub-categories. Its parent is applied automatically. |
| `workType` | select | `fullTime`, `partTime`, `contract`, `casual`. |
| `workArrangement` | select | `onsite`, `hybrid`, `remote`. |
| `dateRange` | select | Posted within 1, 3, 7, 14 or 31 days. |
| `salaryMin` / `salaryMax` | integer | The site's salary bracket, in local currency. |
| `salaryPeriod` | select | `monthly` (default in SEA), `annual` or `hourly`. |
| `sortMode` | select | `relevance` or `date`. |

#### 📦 Results & output

| Field | Type | Description |
| --- | --- | --- |
| `maxResults` | integer | Cap for the **whole run** — this is what bounds your cost. `0` = unlimited (hard cap 20,000). Default `100`. |
| `maxPages` | integer | Optional cap on search pages per search. Each page returns up to 100 jobs. |
| `includeDetails` | boolean | Fetch full descriptions, company profiles and screening questions. Default `true`. |
| `descriptionFormat` | select | `all`, `text`, `markdown` or `html`. |
| `descriptionMaxLength` | integer | Truncate descriptions to N characters. `0` = full. |
| `extractContacts` | boolean | Parse emails, phones and links from descriptions. Default `true`. |
| `compact` | boolean | Core fields only — for AI agents and MCP. |
| `excludeEmptyFields` | boolean | Drop `null`, empty-string and empty-array fields. |
| `compatFieldNames` | boolean | Also emit `canonicalUrl`, `employmentType`, `validThrough` and `locationState` for pipelines built on another JobStreet scraper. |

#### 🎯 Advanced post-filters *(you are never billed for filtered-out jobs)*

| Field | Type | Description |
| --- | --- | --- |
| `includeKeywords` | array | Keep only jobs matching at least one term. |
| `excludeKeywords` | array | Drop jobs matching any term. |
| `keywordMatch*` | boolean | Limit keyword matching to Title / Company / Description / Category / BulletPoints. With none ticked, *include* terms match broadly (location, work type and salary text included) while *exclude* terms deliberately ignore those three — so excluding `intern` can never drop a whole city or pay band. |
| `minSalary` / `maxSalary` | integer | Strict cut-off on the **parsed** salary — unlike the site's bracket, never lets undisclosed-salary jobs through. |
| `requireSalary` | boolean | Only jobs that publish a salary. |
| `requireTags` | select | Only jobs carrying the site's own badges: `EARLY_APPLICANT` (few applications so far), `URGENT`, `EXPIRES_SOON`. |
| `requireContact` | select | `off`, `email`, `phone`, `either`, `both`. Enables `includeDetails` automatically. See the FAQ — this one can return fewer than `maxResults`. |
| `fromDate` / `toDate` | string | ISO posting-date window. |
| `maxAgeMinutes` | integer | Only jobs posted within the last N minutes. |
| `customFilters` | array | Rules on any output field: `[{"field":"companySize","operator":"includes","value":"1000"}]`. Operators: `includes`, `notIncludes`, `equals`, `notEquals`, `gt`, `gte`, `lt`, `lte`, `exists`, `notExists`. |

#### 🔄 Incremental monitoring

| Field | Type | Description |
| --- | --- | --- |
| `incrementalMode` | boolean | Emit only new, updated and reappeared listings. |
| `stateKey` | string | Optional name for the tracked search. Derived from your search if empty. |
| `emitUnchanged` | boolean | Also emit `UNCHANGED` records. |
| `emitExpired` | boolean | Emit `EXPIRED` when a tracked job disappears. |
| `skipReposts` | boolean | Drop re-advertised duplicates of jobs already seen. |

#### 🔔 Notifications *(all credentials are encrypted secret inputs)*

| Field | Type | Description |
| --- | --- | --- |
| `telegramToken` / `telegramChatId` | secret | Telegram bot credentials. **Both** are required. |
| `slackWebhookUrl` | secret | Slack incoming webhook. |
| `discordWebhookUrl` | secret | Discord channel webhook. |
| `whatsappPhoneNumberId` / `whatsappAccessToken` / `whatsappTo` | secret | WhatsApp via Meta Cloud API. All three required. |
| `webhookUrl` / `webhookHeaders` | secret | Generic JSON webhook for n8n / Make / Zapier. |
| `notificationLimit` | integer | Jobs listed per message (default 10). |
| `notifyOnlyChanges` | boolean | In incremental mode, notify only on new/updated jobs. |
| `includeRunSummary` | boolean | Prefix the message with a summary line. |

#### ⚙️ Advanced

| Field | Type | Description |
| --- | --- | --- |
| `proxyType` | select | `auto` (default) handles everything, escalating to residential only for requests that get blocked. Also `datacenter`, `residential`, `none`. |
| `maxConcurrency` | integer | Parallel requests. Default `8`, which is already at the site's throughput ceiling — raising it makes runs slower, not faster. |

***

### 💡 Input examples

**Remote tech jobs in Malaysia, with salary data**

```json
{
  "country": "MY",
  "category": ["Information & Communication Technology"],
  "workArrangement": ["remote"],
  "requireSalary": true,
  "minSalary": 8000,
  "maxResults": 200
}
```

**Daily monitor for new Singapore finance roles → Slack**

```json
{
  "country": "SG",
  "category": ["Banking & Financial Services"],
  "dateRange": "1",
  "sortMode": "date",
  "incrementalMode": true,
  "slackWebhookUrl": "https://hooks.slack.com/services/...",
  "notifyOnlyChanges": true,
  "maxResults": 0
}
```

**Low-competition roles: be an early applicant**

```json
{
  "country": "MY",
  "category": ["Information & Communication Technology"],
  "requireTags": ["EARLY_APPLICANT"],
  "sortMode": "date",
  "maxResults": 100
}
```

**Lead list: Philippine employers with recruiter contact details**

```json
{
  "country": "PH",
  "query": "accountant",
  "requireContact": "either",
  "includeDetails": true,
  "maxResults": 300
}
```

**Paste URLs straight from your browser**

```json
{
  "startUrls": [
    { "url": "https://my.jobstreet.com/software-engineer-jobs/in-Kuala-Lumpur" },
    { "url": "https://hk.jobsdb.com/job/93712392" }
  ],
  "maxResults": 100
}
```

**Compact output for an AI agent**

```json
{
  "country": "ID",
  "query": "data scientist",
  "compact": true,
  "includeDetails": false,
  "excludeEmptyFields": true,
  "maxResults": 50
}
```

***

### 📤 Output

One flat record per job. Example (trimmed):

```json
{
  "jobId": "93680982",
  "title": "Software Engineer Intern",
  "company": "Boostorder Sdn Bhd",
  "advertiserId": "60252927",
  "url": "https://my.jobstreet.com/job/93680982",
  "location": "Kuala Lumpur",
  "locationCountry": "MY",
  "locationSuburb": "Kuala Lumpur",
  "locationRegion": "Kuala Lumpur",
  "salaryText": "RM 1,200 – RM 1,800 per month",
  "salaryMin": 1200,
  "salaryMax": 1800,
  "salaryCurrency": "MYR",
  "salaryType": "monthly",
  "workType": "Full time",
  "workArrangement": "On-site",
  "category": "Information & Communication Technology",
  "subCategory": "Engineering - Software",
  "categoryId": "6281",
  "subCategoryId": "6290",
  "normalisedRoleTitle": "Software Engineering Intern",
  "postedDate": "2026-07-31T05:32:59.439Z",
  "postedDateDisplay": "1d ago",
  "expiresAt": "2026-08-30T13:59:59.999Z",
  "jobStatus": "Active",
  "isVerifiedAdvertiser": true,
  "hasScreeningQuestions": true,
  "description": "Fueled by a passion for digital transformation…",
  "descriptionMarkdown": "Fueled by a passion for **digital transformation**…",
  "descriptionLength": 2841,
  "screeningQuestions": [
    "Which of the following statements best describes your right to work in Malaysia?"
  ],
  "companyIndustry": "Computer Software & Networking",
  "companySize": "11-50 employees",
  "companyWebsite": "https://success.boostorder.com/en-gb/home",
  "companySpecialities": "Software, Mobile Application, Web Application",
  "companyPerks": ["Career Progression", "Medical Coverage", "Transport Allowance"],
  "companyRegistrationNo": "1210588-P",
  "extractedEmails": [],
  "extractedPhones": [],
  "contentQuality": "full",
  "sourceCountry": "MY",
  "sourceSite": "JobStreet",
  "scrapedAt": "2026-08-01T09:04:10.797Z"
}
```

The **Output** tab gives you four ready-made views: **Overview**, **Salary analysis**, **Leads & contacts** and **Changes**. Export any of them to CSV, Excel, JSON or XML.

#### Knowing what you got: `contentQuality`

Not every employer publishes the same amount. Rather than leave you to work that out, every record is graded:

| Grade | Meaning |
| --- | --- |
| `full` | Description **and** company profile (industry, size or website) |
| `partial` | Description, but this employer publishes no company profile |
| `basic` | Search-listing fields only — `includeDetails` was off |

Company-profile coverage is **per advertiser, not per market**: it depends on whether that employer filled in a branded profile, and it is not predicted by company size. Use `contentQuality` to triage records without re-checking every field, or filter on it with `customFilters`.

***

### 🔄 Incremental monitoring

Turn a repeated search into a change feed. With `incrementalMode` on, the actor remembers every job it has seen and tags each record:

| `changeType` | Meaning |
| --- | --- |
| `NEW` | Seen for the first time |
| `UPDATED` | Title, salary, location or description changed |
| `REAPPEARED` | Previously expired, now live again |
| `UNCHANGED` | Identical to last run — suppressed unless `emitUnchanged` |
| `EXPIRED` | Tracked before, now gone — emitted only with `emitExpired` |

**Measured on the Apify platform** — every ICT job in Kuala Lumpur posted in the last 7 days, two runs minutes apart:

| Run | Billed results | Requests | Runtime |
| --- | --- | --- | --- |
| First (cold) | 359 | 363 | 1m 15s |
| Second (warm) | **0** | **4** | **1.9s** |

The requests drop as steeply as the results because an unchanged job is recognised from the search listing alone — its detail page is never fetched. The second run read four search pages and stopped.

On a daily schedule with ~5% genuine churn, that is **~880 billed results a month instead of ~10,770** — a **92% reduction**, and the run finishes in seconds rather than minutes. That saving is what makes recurring monitoring cheap here, whatever the per-result price of any individual actor.

#### 🎯 Getting a clean change feed

Incremental mode compares each run against the previous one, so it works best when both runs
look at the **same, well-defined set of jobs**. Narrow the *search*, not the *result count*:

```json
{
  "country": "MY",
  "category": ["Information & Communication Technology"],
  "location": ["Kuala Lumpur"],
  "dateRange": "7",
  "sortMode": "date",
  "incrementalMode": true,
  "maxResults": 0
}
```

That tracks "every ICT job in KL posted in the last 7 days" — a definite set, so the only
things reported as `NEW` are genuinely new postings.

> ⚠️ **Don't bound a monitor with `maxResults` or `maxPages`.** Capping a 700-result search at
> 100 means you are tracking "the top 100 by relevance", and the cut-off falls in the middle of
> the ranking — jobs either side of it drift across it between runs and get reported as `NEW`
> and `EXPIRED` even though nothing was posted or removed. The actor warns you when this is
> happening: *"Incremental mode is tracking 100 of 704 matching jobs."*
>
> Note also that in incremental mode `maxResults` counts **changed** jobs, so a small cap makes
> the run page deeper to fill it.

***

### 🔔 Job alerts with no code

Point the actor at a channel and it messages you when a run finishes — no glue script, no server.

```json
{
  "country": "MY",
  "category": ["Information & Communication Technology"],
  "workArrangement": ["remote"],
  "dateRange": "1",
  "incrementalMode": true,
  "notifyOnlyChanges": true,
  "telegramToken": "<from @BotFather>",
  "telegramChatId": "<your chat id>",
  "maxResults": 0
}
```

Put that on a daily schedule and you have a personal job-alert bot that only pings you about listings you haven't already seen.

**Channels:** Telegram · Slack · Discord · WhatsApp (Meta Cloud API) · any JSON webhook, which is how you reach n8n, Make and Zapier.

**Credentials are handled properly.** Every token and webhook URL is an encrypted secret input, they never appear in the run log, and any error message that would normally quote a credential-bearing URL is scrubbed before it is written anywhere you can read. Partly-configured channels warn you rather than failing silently.

***

### 🤖 For AI agents and MCP

Available as an **MCP tool** at `https://mcp.apify.com`, so an agent can call it directly.

```json
{
  "country": "SG",
  "query": "machine learning engineer",
  "compact": true,
  "excludeEmptyFields": true,
  "descriptionMaxLength": 1200,
  "maxResults": 30
}
```

`compact` trims each record to core fields, `excludeEmptyFields` drops nulls and empty arrays, and `descriptionMaxLength` caps description size — together they cut token cost substantially versus a full record, which matters when results go straight into a context window.

***

### 🚀 How to scrape JobStreet

1. Click **Try for free** — no setup, no credit card needed to start.
2. Choose your **market** (Malaysia, Singapore, Indonesia, the Philippines, Thailand or Hong Kong).
3. Type your **keywords** and optionally a **location**, or paste a URL into **Start URLs**.
4. Narrow with **category**, **work type**, **arrangement**, **salary** or **posted-within** filters.
5. Set **Maximum results** to bound your cost.
6. Hit **Start** and watch results stream into the **Output** tab.
7. Export to CSV/Excel/JSON, or grab them from the API.

To run it on a schedule, open **Schedules** in the Apify Console, point it at this actor, and turn on `incrementalMode` so each run returns only what's new.

***

### 🎯 Use cases

- 📈 **Salary benchmarking** — parsed min/max/currency across six markets and 424 categories.
- 🧭 **Labour-market research** — hiring volume by category, region, work arrangement and company size.
- 🎯 **Recruitment lead generation** — find employers hiring now, with industry, size and contact details.
- 🏆 **Competitor hiring intelligence** — track what specific companies are advertising, and when they stop.
- 💼 **Job-board aggregation** — feed a niche board or newsletter with fresh, structured listings.
- 🔔 **Personal job alerts** — a daily Telegram or Slack digest of roles matching your exact criteria.
- 🤖 **AI & RAG pipelines** — compact, clean records ready for embedding or agent tool-calls.

***

### 💰 Keeping your bill down

Pricing is **pay per result** — a small run fee plus each job actually pushed to the dataset. Filtered-out jobs cost you nothing. Three levers control what you spend:

- **`maxResults`** caps the whole run. Nothing can overrun it.
- **Post-filters** run *before* results are pushed, so a strict filter means fewer billed records rather than wasted spend.
- **`incrementalMode`** cuts recurring monitoring by 80–95%.

Note that `includeDetails` affects speed, not price — a listings-only run is about 8× faster but returns the same number of records.

***

### ❓ FAQ

**Is scraping JobStreet legal?**
This actor collects only publicly available job listings — the same data any visitor sees without logging in. It does not access private, gated or personal account data. You are responsible for how you use the results, particularly any personal data such as recruiter names, emails or phone numbers.

**Do I need a proxy?**
No. Proxy handling is automatic and included. Leave `proxyType` on `auto` unless you see persistent blocking, in which case switch to `residential`.

<a id="does-this-report-how-many-people-have-applied"></a>
**Does this report how many people have applied?**
No, and neither does anything else — JobStreet and JobsDB don't publish an applicant count for these markets. There is no such field anywhere in their public data, and we probed for it exhaustively. What they *do* publish is a **"Be an early applicant"** badge, shown while a listing still has few applications. You get that as `isEarlyApplicant`, and `requireTags` filters on it, which serves the same purpose: finding roles you won't be competing for. If a scraper offers you a numeric applicant count for these markets, ask where it comes from.

**Why do some jobs have no salary?**
Because the employer didn't publish one — roughly 20–70% of listings state a salary depending on the market. Use `requireSalary` to keep only those that do.

**Why do some records have no company profile?**
Because that employer never filled one in. It varies by advertiser rather than by market, and isn't predicted by company size. The `contentQuality` field tells you which records have one, so you can filter rather than guess.

**Why is `workArrangement` sometimes different from `workArrangementLabel`?**
The sites localise their labels — an Indonesian on-site job reads "Kantor", and its category reads "Pemasaran & Komunikasi" rather than "Marketing & Communications". `workArrangement`, `category` and `subCategory` are always the canonical English value, so you can aggregate across markets without one category splitting into several strings. `workArrangementLabel`, `categoryLabel` and `subCategoryLabel` preserve the original wording and appear only when it differs — in practice, only on Indonesian listings.

**Why did `requireContact` return fewer jobs than I asked for?**
Because only a minority of employers publish an email or phone number in the listing — often 10–30% depending on market and role. Whether a job qualifies can only be known after its description is fetched, so the actor searches up to 10× deeper than your `maxResults` trying to fill the quota, then stops. Raise `maxResults`, broaden the search, or use `either` rather than `both`.

**Can I scrape a single job page?**
Yes — put any `/job/<id>` URL in `startUrls`. The market is detected from the URL.

**How many jobs can one run collect?**
Up to 20,000. For larger harvests, split by category, location or market and run in parallel.

**Are my Telegram/Slack tokens safe?**
Yes. They're encrypted secret inputs, they never appear in the run log, and any error message that would normally quote a credential-bearing URL is scrubbed before it's written anywhere you can read.

**Does this work for SEEK Australia or New Zealand?**
Not here — those are separate markets on `seek.com.au` / `seek.co.nz`. This actor covers SEEK's Asian brands.

**Something broke. What do I do?**
Open the Actor's **Issues** tab with your run ID. Job boards change their APIs from time to time and fixes are shipped when they do.

***

### 🔌 Use it from code

**Python**

```python
from apify_client import ApifyClient

client = ApifyClient("<YOUR_API_TOKEN>")
run = client.actor("<username>/jobstreet-scraper").call(run_input={
    "country": "MY",
    "query": "software engineer",
    "maxResults": 100,
})
for job in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(job["title"], job["company"], job["salaryText"])
```

**JavaScript**

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

const client = new ApifyClient({ token: '<YOUR_API_TOKEN>' });
const run = await client.actor('<username>/jobstreet-scraper').call({
    country: 'SG',
    query: 'data analyst',
    maxResults: 100,
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
```

Also available over the **REST API**, via the **Apify CLI**, and as an **MCP tool** for AI agents.

***

### ⚖️ Disclaimer

This actor accesses only publicly available data on jobstreet.com and jobsdb.com. You are responsible for how you use the extracted data — in particular any personal information such as names, phone numbers or email addresses — and for complying with the sites' terms of use, applicable data-protection law (including the GDPR and the PDPA where they apply), and the anti-spam rules of your jurisdiction.

Not affiliated with, endorsed by, or sponsored by JobStreet, JobsDB or SEEK.

***

**Keywords:** jobstreet scraper · jobstreet api · jobstreet malaysia jobs · jobstreet singapore · jobstreet indonesia · jobstreet philippines · jobsdb scraper · jobsdb thailand · jobsdb hong kong · seek asia scraper · southeast asia job data · job listings api · salary data malaysia · recruitment lead generation · job market research · hiring intelligence

# Actor input Schema

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

Which JobStreet / JobsDB market to search. Ignored for jobs reached through **Start URLs**, where the market is detected from the URL.

## `query` (type: `string`):

Keywords to search for, e.g. `software engineer`. Leave empty to return every job in the market (optionally narrowed by the filters below). Also accepts a JSON list — `["data analyst", "business analyst"]` — to run several searches at once.

## `queries` (type: `array`):

Run several searches in one go. Each entry is a **separate** search and results are merged and de-duplicated — they are OR-ed, not AND-ed.

## `location` (type: `array`):

City, state or region to search in, e.g. `Kuala Lumpur`, `Selangor`, `Metro Manila`. Add several to search each one. Leave empty for the whole country. Also accepts a plain string or a JSON list.

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

Paste JobStreet URLs directly — either a search page (`/software-engineer-jobs/in-Kuala-Lumpur`) or a single job (`/job/12345678`). The market is detected from the URL, and search URLs are paginated automatically.

## `category` (type: `array`):

Filter by job category. Several categories broaden the search (OR).

## `subCategory` (type: `array`):

Narrow to specific sub-categories. Its parent category is applied automatically, so you don't need to set both.

## `workType` (type: `array`):

Filter by employment type.

## `workArrangement` (type: `array`):

Filter by on-site / hybrid / remote.

## `dateRange` (type: `string`):

Only jobs listed within this window.

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

Lower bound of JobStreet's own salary bracket, in the market's local currency (MYR, SGD, IDR, PHP, THB, HKD). Note this bracket also matches jobs that publish no salary — use **Minimum parsed salary** below for a strict cut-off.

## `salaryMax` (type: `integer`):

Upper bound of JobStreet's salary bracket, in local currency.

## `salaryPeriod` (type: `string`):

How to interpret the salary bracket. Most Southeast Asian listings quote **monthly** pay.

## `sortMode` (type: `string`):

Result ordering. Use **Date** for monitoring so the newest listings arrive first.

## `maxResults` (type: `integer`):

Stop after this many jobs **for the whole run** — this is what bounds your cost. Set `0` for no limit (hard-capped at 20,000).

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

Optional safety cap on how many search pages to walk per search. Each page returns up to 100 jobs. Leave empty to let **Maximum results** decide.

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

Fetch each job's full description, company profile, screening questions and structured location. Turn **off** for a listings-only scrape, which is about 8x faster — it returns the same jobs, just without the enrichment.

## `descriptionFormat` (type: `string`):

Which representations of the job description to include.

## `descriptionMaxLength` (type: `integer`):

Cut descriptions to this many characters. `0` keeps them in full.

## `extractContacts` (type: `boolean`):

Parse recruiter emails, phone numbers and links out of the job description into `extractedEmails`, `extractedPhones` and `extractedUrls`.

## `compact` (type: `boolean`):

Emit only the core fields (id, title, company, location, salary, work type, date, URL) — small payloads for LLM agents and MCP workflows.

## `excludeEmptyFields` (type: `boolean`):

Remove `null`, empty-string and empty-array fields from each record.

## `compatFieldNames` (type: `boolean`):

Also emit `canonicalUrl`, `employmentType`, `validThrough` and `locationState` as copies of `url`, `workType`, `expiresAt` and `locationRegion` — the names other JobStreet scrapers use. Lets you switch without touching your existing pipeline. Every other field already matches by name.

## `includeKeywords` (type: `array`):

Keep only jobs matching at least one of these terms (case-insensitive).

## `excludeKeywords` (type: `array`):

Drop jobs matching any of these terms (case-insensitive). Useful for stripping agency spam or unwanted seniority levels.

## `keywordMatchTitle` (type: `boolean`):

Restrict the two keyword filters above to the job title. If no scope is ticked, all fields are searched.

## `keywordMatchCompany` (type: `boolean`):

Include the company name in keyword matching.

## `keywordMatchDescription` (type: `boolean`):

Include the description (or teaser when details are off) in keyword matching.

## `keywordMatchCategory` (type: `boolean`):

Include the category and sub-category in keyword matching.

## `keywordMatchBulletPoints` (type: `boolean`):

Include the listing's bullet points in keyword matching.

## `minSalary` (type: `integer`):

Strict cut-off on the **parsed** salary (local currency, as listed). Unlike JobStreet's own bracket this never lets undisclosed-salary jobs through.

## `maxSalary` (type: `integer`):

Strict upper cut-off on the parsed salary.

## `requireSalary` (type: `boolean`):

Drop listings that don't state a salary.

## `requireTags` (type: `array`):

Keep only listings carrying JobStreet's own demand badges. **Be an early applicant** marks listings that have had few applications so far — the platform's public signal for low-competition roles.

## `requireContact` (type: `string`):

Lead-generation filter: keep only jobs exposing a recruiter email and/or phone number. Automatically enables **Fetch full job details**. Only a minority of listings publish contact details, so a run may return fewer than **Maximum results** — it searches up to 10× deeper to try to fill the quota.

## `fromDate` (type: `string`):

ISO date or datetime, e.g. `2026-01-15`.

## `toDate` (type: `string`):

ISO date or datetime. A bare date includes the whole day.

## `maxAgeMinutes` (type: `integer`):

Precise recency window for near-real-time monitoring, e.g. `60`.

## `customFilters` (type: `array`):

Advanced rules evaluated against any output field, AND-ed together. Example: `[{"field":"companySize","operator":"includes","value":"1000"}]`. Operators: `includes`, `notIncludes`, `equals`, `notEquals`, `gt`, `gte`, `lt`, `lte`, `exists`, `notExists`.

## `incrementalMode` (type: `boolean`):

Remember jobs between runs and emit only **new**, **updated** and **reappeared** listings. On a daily schedule this typically cuts billed results by 80–95%. Each record gains a `changeType`.

## `stateKey` (type: `string`):

Optional name for this tracked search. Leave empty to derive one from the search parameters — set it only if you want two runs to share state deliberately.

## `emitUnchanged` (type: `boolean`):

Include `UNCHANGED` listings. Off by default — leaving it off is where the saving comes from.

## `emitExpired` (type: `boolean`):

Emit an `EXPIRED` record when a previously seen job disappears from results.

## `skipReposts` (type: `boolean`):

Drop listings that re-advertise a job already seen under a different id (same company, title and locality).

## `telegramToken` (type: `string`):

Bot token from @BotFather. Stored encrypted and never written to the run log.

## `telegramChatId` (type: `string`):

Chat, channel or group ID to post into.

## `slackWebhookUrl` (type: `string`):

Slack incoming-webhook URL. Stored encrypted and never logged.

## `discordWebhookUrl` (type: `string`):

Discord channel webhook URL. Stored encrypted and never logged.

## `whatsappPhoneNumberId` (type: `string`):

Meta Cloud API phone number ID. Stored encrypted and never logged.

## `whatsappAccessToken` (type: `string`):

Meta Cloud API access token. Stored encrypted and never logged.

## `whatsappTo` (type: `string`):

Recipient in international format, e.g. `60123456789`. Meta only permits free-form messages within 24 hours of the recipient's last message to you.

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

Receives a JSON POST with the matching jobs and run metadata. Stored encrypted and never logged.

## `webhookHeaders` (type: `object`):

Extra headers for the custom webhook, e.g. `{"Authorization": "Bearer …"}`. Values are redacted from logs.

## `notificationLimit` (type: `integer`):

How many jobs to list in the message body. The total count is always reported.

## `notifyOnlyChanges` (type: `boolean`):

In incremental mode, notify only for `NEW` / `UPDATED` / `REAPPEARED` listings.

## `includeRunSummary` (type: `boolean`):

Prefix the message with the market, search terms and match count.

## `proxyType` (type: `string`):

**Automatic** handles this for you — it escalates to residential IPs only for requests that actually get blocked, then drops back down. Proxies are included; you don't need an account of your own. Pick residential only if you see persistent blocking.

## `maxConcurrency` (type: `integer`):

Parallel requests. **Raising this does not make runs faster** — JobStreet caps throughput at roughly 400 requests per minute, so extra parallelism just makes each request wait longer (measured: doubling this to 16 left throughput unchanged and the run slightly slower). Lower it only if you see repeated retries.

## Actor input object example

```json
{
  "country": "MY",
  "query": "software engineer",
  "dateRange": "",
  "salaryPeriod": "monthly",
  "sortMode": "relevance",
  "maxResults": 100,
  "includeDetails": true,
  "descriptionFormat": "all",
  "descriptionMaxLength": 0,
  "extractContacts": true,
  "compact": false,
  "excludeEmptyFields": false,
  "compatFieldNames": false,
  "keywordMatchTitle": false,
  "keywordMatchCompany": false,
  "keywordMatchDescription": false,
  "keywordMatchCategory": false,
  "keywordMatchBulletPoints": false,
  "requireSalary": false,
  "requireContact": "off",
  "incrementalMode": false,
  "emitUnchanged": false,
  "emitExpired": false,
  "skipReposts": false,
  "notificationLimit": 10,
  "notifyOnlyChanges": false,
  "includeRunSummary": true,
  "proxyType": "auto",
  "maxConcurrency": 8
}
```

# Actor output Schema

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

Every scraped job listing with all fields, as JSON.

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

Key job fields only — title, company, location, salary, posted date and URL.

## `salaryAnalysis` (type: `string`):

Parsed salary ranges for benchmarking, ready for a spreadsheet.

## `contacts` (type: `string`):

Company and recruiter contact details parsed from the listings.

## `changes` (type: `string`):

New, updated, reappeared and expired listings from the latest incremental 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 = {
    "query": "software engineer"
};

// Run the Actor and wait for it to finish
const run = await client.actor("corvuslab/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 = { "query": "software engineer" }

# Run the Actor and wait for it to finish
run = client.actor("corvuslab/jobstreet-scraper").call(run_input=run_input)

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

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

```

## CLI example

```bash
echo '{
  "query": "software engineer"
}' |
apify call corvuslab/jobstreet-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "command": "npx",
            "args": [
                "mcp-remote",
                "https://mcp.apify.com/?tools=corvuslab/jobstreet-scraper",
                "--header",
                "Authorization: Bearer <YOUR_API_TOKEN>"
            ]
        }
    }
}

```

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/C3aSH4gkLElhrunQV/builds/2GVoY1fEmPI86fi2E/openapi.json
