# JobStreet $1💰 Keyword & URL Job Scraper (`abotapi/jobstreet-scraper`) Actor

From $1/1K. Scrape JobStreet listings across Malaysia, Singapore, Indonesia, and the Philippines. Extract titles, companies, salaries, locations, descriptions, company info, and apply details. Search with filters or paste JobStreet URLs directly. Fast, browser-free, and cost-efficient.

- **URL**: https://apify.com/abotapi/jobstreet-scraper.md
- **Developed by:** [Abot API](https://apify.com/abotapi) (community)
- **Categories:** Jobs, Automation, Developer tools
- **Stats:** 15 total users, 5 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: 5.00 out of 5 stars

## Pricing

from $1.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/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 Jobs Scraper (Malaysia, Singapore, Philippines, Indonesia)

Scrape job listings from JobStreet across all four Southeast Asian markets: Malaysia (my.jobstreet.com), Singapore (sg.jobstreet.com), the Philippines (ph.jobstreet.com), and Indonesia (id.jobstreet.com). The actor reads JobStreet's public JSON search and returns one clean, flat record per job, with an optional full description, company overview, salary, and contact details pulled from the public job-detail endpoint. No browser, fast, and inexpensive.

Search two ways: fill in the filter fields (country, keywords, location, category, work type, salary, date range), or paste one or more JobStreet search URLs and let the actor read the filters straight from each link.

### Why this scraper

- Covers all four JobStreet country sites from one actor, with the country auto-detected from each pasted URL.
- 60+ fields per job, including the full HTML description, plain-text body, bullet points, salary, company overview, and any phone numbers or emails found in the ad body.
- Detail enrichment is batched: one extra request fetches the full description for a whole page of up to 22 jobs, so deep runs stay fast.
- Forward pagination across as many pages as you allow, with automatic de-duplication by job ID.
- Filter by category and subcategory, work type, salary range (monthly or annual), and listing recency.
- Global caps on item count and run time, so a run never overshoots your budget. Partial results are always flushed.

### Data you get

> Sample shape, values are illustrative placeholders, not from a live listing.

| Field | Example |
| --- | --- |
| `id` | `"00000001"` |
| `url` | `"https://my.jobstreet.com/job/00000001"` |
| `title` | `"Software Engineer"` |
| `teaser` | `"Join a growing team building cloud services."` |
| `companyName` | `"Sample Company Sdn Bhd"` |
| `companyId` | `"00000000"` |
| `companyOverview` | `"Sample Company is a leading provider of ..."` |
| `advertiserName` | `"Sample Company Sdn Bhd"` |
| `locationLabel` | `"Kuala Lumpur"` |
| `locationSeoHierarchy` | `["Kuala Lumpur", "Wilayah Persekutuan"]` |
| `countryCode` | `"MY"` |
| `classificationInfo` | `{ "classification": "Information & Communication Technology", "subClassification": "Developers/Programmers" }` |
| `workTypes` | `["Full time"]` |
| `salaryLabel` | `"RM 5,000 to RM 8,000 per month"` |
| `content` | `"<p>Full job description HTML appears here when includeFullDescription=true.</p>"` |
| `contentText` | `"Full job description plain text appears here."` |
| `bulletPoints` | `["Competitive salary", "Hybrid work", "Career growth"]` |
| `phoneNumbers` | `["+60100000000"]` |
| `emails` | `["careers@example.com"]` |
| `listingDate` | `"2026-01-01T00:00:00Z"` |
| `listingDateDisplay` | `"2d ago"` |
| `expiresAt` | `"2026-02-01T00:00:00Z"` |
| `isVerified` | `true` |
| `applyLink` | `"https://my.jobstreet.com/job/00000001/apply"` |
| `scrapedAt` | `"2026-01-01T00:00:00.000Z"` |

The full record carries more: `classifications[]` with IDs, `workArrangementLabels`, `salaryCurrency`, `shareLink`, `companyOpenJobs`, `branding`, `advertiser` details, and raw passthrough blocks (`advertiserRaw`, `employerRaw`, `companyProfileRaw`) so no upstream field is ever dropped. `detailFetched` tells you, per record, whether the full-description graphql fetch actually succeeded for that job (only meaningful when `includeFullDescription` is on). `changeType`, `changedFields`, `firstSeenAt`, and `lastSeenAt` are added only when `incrementalMode` is on — see "Resume and recurring updates" below.

### How to use

#### 1. Basic search (Malaysia)

```json
{
  "country": "MY",
  "keywords": "software engineer",
  "location": "Kuala Lumpur",
  "maxItems": 50
}
```

#### 2. Search with filters (Singapore, ICT, full time, salary range)

```json
{
  "country": "SG",
  "keywords": "data engineer",
  "classification": ["6281"],
  "workType": ["242"],
  "salaryMin": 5000,
  "salaryMax": 12000,
  "salaryType": "monthly",
  "daterange": 14,
  "sortmode": "ListedDate",
  "maxItems": 100,
  "includeFullDescription": true
}
```

#### 3. Multiple countries via URL mode

```json
{
  "urls": [
    "https://my.jobstreet.com/it-jobs/in-Penang",
    "https://sg.jobstreet.com/jobs-in-information-communication-technology",
    "https://ph.jobstreet.com/accountant-jobs",
    "https://id.jobstreet.com/marketing-jobs/in-Jakarta-Raya"
  ],
  "maxItems": 200,
  "includeFullDescription": false
}
```

In URL mode, the filter fields in Option 1 are ignored. Each URL's host sets the country, and its path and query string set the filters. You can mix all four country sites in one run.

#### 4. Recent jobs only, sorted by date

```json
{
  "country": "ID",
  "keywords": "marketing",
  "daterange": 7,
  "sortmode": "ListedDate",
  "maxItems": 30
}
```

### Input parameters

| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `country` | string | `"MY"` | Market to search: `MY`, `SG`, `PH`, or `ID`. Applies in filter mode only. |
| `keywords` | string | (empty) | Job title or keywords. Empty matches all jobs in the location. |
| `location` | string | (empty) | Location within the country, for example `Kuala Lumpur` or `Jakarta Raya`. Empty searches the whole country. |
| `classification` | array | `[]` | One or more category IDs. Empty means all categories. |
| `workType` | array | `[]` | Work types: `242` Full time, `243` Part time, `244` Contract/Temp, `245` Casual/Vacation. |
| `salaryMin` | integer | (none) | Minimum salary in local currency. |
| `salaryMax` | integer | (none) | Maximum salary in local currency. |
| `salaryType` | string | `"monthly"` | Whether salary values are `monthly` or `annual`. |
| `daterange` | integer | (none) | Listed within N days. Accepted: 1, 3, 7, 14, 31. |
| `sortmode` | string | `"ListedDate"` | `ListedDate` newest first, or `KeywordRelevance` best match. |
| `urls` | array | `[]` | One or more JobStreet search URLs. Overrides Option 1. Country auto-detected per URL. |
| `includeFullDescription` | boolean | `true` | Fetch the full HTML description, company overview, expiry, and phone via the job-detail endpoint. Batched per page. |
| `maxItems` | integer | `20` | Global cap across all URLs and pages. `0` means unlimited. |
| `maxTimeSec` | integer | `0` | Global wall-clock cap in seconds. `0` means no limit. Partial results are flushed when reached. |
| `resumeFromRunId` | string | *(empty)* | Continue one specific interrupted run. See "Resume and recurring updates" below. |
| `incrementalMode` | boolean | `false` | Track a scheduled search across runs and classify changes. See below. |
| `stateKey` | string | *(empty)* | Optional name for the Incremental mode baseline. Auto-derived when empty. |
| `emitUnchanged` | boolean | `false` | Also push rows unchanged since the last Incremental mode run. Bills extra rows. |
| `emitExpired` | boolean | `false` | Push a synthetic row for jobs that dropped out of a fully-scanned search. Bills extra rows. |
| `proxyConfiguration` | object | Apify datacenter | Proxy settings. See below. |

### Resume and recurring updates

**Resume (`resumeFromRunId`)** continues one specific interrupted run: paste the run ID or dataset ID from a previous run and this one skips every job already collected there, picking up only what's still missing.

**Incremental mode (`incrementalMode`)** is for a search you run on a *schedule* (daily/weekly) against the same keywords/location/filters. Turn it on and the actor remembers what the search returned last time and classifies every job as:

- `NEW` — not seen before
- `UPDATED` — content changed since last time (see `changedFields`)
- `UNCHANGED` — identical to last time
- `REAPPEARED` — was gone, is back
- `EXPIRED` — tracked before, no longer found (only after a run that scanned every page of every search with nothing capping, time-limiting, or interrupting it — a partial scan can't tell "gone" from "not reached yet")

Off by default, so every existing scheduled run is unaffected. `stateKey` names the saved baseline; leave it empty and the actor derives one automatically from `country`/`keywords`/`location`/`classification`/`workType`/`salaryType`/`salaryMin`/`salaryMax`/`daterange`/`sortmode`/`urls`/`includeFullDescription`, so two differently-configured searches never share a baseline by accident.

By default, only `NEW`/`UPDATED`/`REAPPEARED`/`EXPIRED` rows are pushed. **Turning on `emitUnchanged` or `emitExpired` bills extra rows** (the per-result event, plus the detail-enrichment surcharge if `includeFullDescription` is on) for the additional rows those toggles push — leave them off if you only want to pay for what actually changed.

**A note on `maxItems` with Incremental mode on:** the cap counts rows actually *pushed*, not scanned. If most of a search is `UNCHANGED` and suppressed, the actor keeps paginating to backfill your `maxItems` budget with fresh listings — a capped incremental run can still return `maxItems` rows even when almost everything on the tracked pages didn't change. `EXPIRED` detection only ever fires on an uncapped, un-timed-out, non-resumed run that reached the natural end of every search.

### Send results into your apps (MCP connectors)

Optionally pipe the scraped results into the apps you already use, via Model Context Protocol (MCP) connectors. This is an extra delivery step **after** the scrape — the Apify dataset is never changed.

**What gets written to the connector:** a condensed, human-readable **summary** of each record — not the full JSON. Each item becomes one entry with a **title** and its key fields flattened to plain text. The **complete record always stays in the Apify dataset**.

1. Authorize a connector once under **Apify → Settings → Integrations** (Notion, Linear, Airtable, or Apify).
2. Select it in the **"Pipe results into your apps"** input field. (If the picker is empty, you haven't authorized a connector yet.)
3. For **Notion**, also set `notionParentPageUrl` to the page where items should be created.

The connection is mediated by Apify's MCP proxy, so this actor never sees your third-party credentials. Leave the field empty to skip.

### Output example

> Sample shape, values are illustrative placeholders, not from a live listing.

```json
{
  "id": "00000001",
  "url": "https://my.jobstreet.com/job/00000001",
  "applyLink": "https://my.jobstreet.com/job/00000001/apply",
  "title": "Software Engineer",
  "teaser": "Join a growing team building cloud services.",
  "companyName": "Sample Company Sdn Bhd",
  "companyId": "00000000",
  "companyOverview": "Sample Company is a leading provider of cloud services in the region.",
  "advertiserName": "Sample Company Sdn Bhd",
  "locationLabel": "Kuala Lumpur",
  "locationSeoHierarchy": ["Kuala Lumpur", "Wilayah Persekutuan"],
  "countryCode": "MY",
  "classificationInfo": {
    "classification": "Information & Communication Technology",
    "subClassification": "Developers/Programmers"
  },
  "workTypes": ["Full time"],
  "salaryLabel": "RM 5,000 to RM 8,000 per month",
  "content": "<p>Full job description HTML appears here when includeFullDescription=true.</p>",
  "contentText": "Full job description plain text appears here.",
  "bulletPoints": ["Competitive salary", "Hybrid work", "Career growth"],
  "phoneNumbers": ["+60100000000"],
  "emails": ["careers@example.com"],
  "listingDate": "2026-01-01T00:00:00Z",
  "listingDateDisplay": "2d ago",
  "expiresAt": "2026-02-01T00:00:00Z",
  "isVerified": true,
  "scrapedAt": "2026-01-01T00:00:00.000Z"
}
```

### Plan requirement and proxy

The actor defaults to Apify datacenter proxy, which is included on the free plan, and JobStreet serves its public data fine through it. If your account cannot reach datacenter proxy, the actor automatically falls back to a backup pool, then to a direct connection, so a run never fails just because of proxy access.

For heavy or production workloads, residential proxy gives more rotation and reliability. Set `proxyConfiguration.apifyProxyGroups` to `["RESIDENTIAL"]` (available on Apify paid plans) and optionally pin the country with `apifyProxyCountry` to match the market you are scraping.

# Actor input Schema

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

Country market to search. MY = my.jobstreet.com, SG = sg.jobstreet.com, PH = ph.jobstreet.com, ID = id.jobstreet.com.

## `keywords` (type: `string`):

Job title or keywords (e.g. 'software engineer'). Leave empty to match all jobs in the location.

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

Where to search within the country. Examples: 'Kuala Lumpur', 'Selangor', 'Singapore', 'Metro Manila', 'Jakarta Raya'. Leave empty to search the whole country.

## `classification` (type: `array`):

Filter by one or more top-level job categories. Leave empty for all. Subclassifications can still be used via URL mode.

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

Filter by one or more work types. Leave empty for all.

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

Optional minimum salary, in the local currency (RM / SGD / PHP / IDR). By default treated as a monthly figure.

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

Optional maximum salary, in the local currency. By default treated as a monthly figure.

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

Whether the salary filter values are monthly or annual amounts. JobStreet markets quote monthly salaries by default.

## `daterange` (type: `integer`):

Only return jobs listed in the last N days. Accepted values: 1, 3, 7, 14, 31. Other values are dropped with a warning.

## `sortmode` (type: `string`):

ListedDate = newest first. KeywordRelevance = best match.

## `urls` (type: `array`):

Paste one or more JobStreet search URLs (e.g. https://my.jobstreet.com/software-engineer-jobs/in-Kuala-Lumpur).

## `includeFullDescription` (type: `boolean`):

If true, fetches the full job HTML body, company overview, expiry date, and phone (when available) via the jobseeker graphql endpoint. Batched per page: one extra request covers up to 22 jobs.

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

Total cap across ALL URLs and ALL pages (not per-URL). Example: maxItems=50 with 3 URLs paginates URL 1 until the budget is filled; URLs 2 and 3 run only if budget remains. Set to 0 for unlimited.

## `maxTimeSec` (type: `integer`):

Wall-clock cap across ALL URLs and pages. When exceeded, the actor stops paginating, flushes whatever it already fetched to the dataset, and exits cleanly. Set to 0 for no time limit.

## `resumeFromRunId` (type: `string`):

Paste a previous run ID or dataset ID to continue an interrupted crawl: jobs already collected there are skipped so you get only what's still missing. One-off continuation of a SPECIFIC run - for ongoing scheduled monitoring of the same search, use Incremental mode below instead.

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

For a search you run on a schedule (e.g. daily/weekly): remembers what this exact search returned last time and classifies every job as NEW / UPDATED / UNCHANGED / REAPPEARED / EXPIRED, so you can act on only what changed. Off by default so existing scheduled runs are unaffected. Separate from Resume above, which continues one specific interrupted run. Uses State key below to isolate different searches; see Emit unchanged rows / Emit expired rows to control which of those get pushed (both bill extra rows when on).

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

Names the saved baseline Incremental mode compares against. Leave empty to auto-derive one from your country/keywords/location/classification/workType/salary/daterange/sortmode/urls/includeFullDescription - two differently-configured searches never share a baseline even with this left empty. Set your own text only if you want to explicitly reuse or separate baselines yourself. Ignored when Incremental mode is off.

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

When Incremental mode is on: also push rows classified UNCHANGED (identical to last run), not just NEW/UPDATED/REAPPEARED/EXPIRED. Off by default to save you from paying for rows that didn't change. Turning this on bills the per-result event (and the detail-enrichment surcharge, if Fetch full job description is on) for every unchanged row too.

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

When Incremental mode is on: push a synthetic EXPIRED row for jobs tracked in a previous run that no longer appear in this one. Only fires after a run that scanned every page of every search with no cap, time limit, or resume truncating it - a partial scan can't tell 'gone' apart from 'not reached yet', so it's skipped (with a log line) otherwise. Off by default; turning it on bills the per-result event for each EXPIRED row emitted.

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

Defaults to Apify datacenter proxy. If your account can't access it (free tier), the actor falls back to a backup proxy pool automatically. For heavy/production workloads, set apifyProxyGroups to \["RESIDENTIAL"] on an Apify paid plan.

## `mcpConnectors` (type: `array`):

Optionally send the scraped results into the apps you already use, via Model Context Protocol (MCP) connectors. Authorize a connector once under Apify → Settings → Integrations, then select it here. The connector receives a condensed, human-readable summary per item (title + key fields), not the full JSON — the complete record stays in the dataset. Leave empty to skip. Supported: Notion (https://mcp.notion.com/mcp), Linear (https://mcp.linear.app/sse), Airtable (https://mcp.airtable.com/mcp), Apify (https://mcp.apify.com).

## `notionParentPageUrl` (type: `string`):

URL (or id) of the Notion page under which item pages are created. Required to enable the Notion export; ignored by other connectors.

## `maxNotifyListings` (type: `integer`):

Cap on items written to each connector per run. Does not affect the dataset.

## Actor input object example

```json
{
  "country": "MY",
  "keywords": "software engineer",
  "location": "Kuala Lumpur",
  "classification": [],
  "workType": [],
  "salaryMin": 3000,
  "salaryMax": 8000,
  "salaryType": "monthly",
  "daterange": 7,
  "sortmode": "ListedDate",
  "urls": [],
  "includeFullDescription": true,
  "maxItems": 200,
  "maxTimeSec": 300,
  "incrementalMode": false,
  "emitUnchanged": false,
  "emitExpired": false,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": []
  },
  "maxNotifyListings": 50
}
```

# Actor output Schema

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

Dataset items (one per job)

# 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 = {
    "keywords": "software engineer",
    "proxyConfiguration": {
        "useApifyProxy": true,
        "apifyProxyGroups": []
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("abotapi/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 = {
    "keywords": "software engineer",
    "proxyConfiguration": {
        "useApifyProxy": True,
        "apifyProxyGroups": [],
    },
}

# Run the Actor and wait for it to finish
run = client.actor("abotapi/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 '{
  "keywords": "software engineer",
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": []
  }
}' |
apify call abotapi/jobstreet-scraper --silent --output-dataset

```

## MCP server setup

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

```

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/acts/EdP5fV656YFUiuzP7/builds/9jemURuhXuuEmUbZ7/openapi.json
