This repository shows how to use the RemoteOK Jobs Scraper from JavaScript. A single ES module built on Apify's official client starts the Actor, waits for the run and returns RemoteOK postings as plain objects with title, employer, tags, salary bounds, a text-only description, the job page and the apply link. RemoteOK lists remote-only roles, and the Actor reads its complete live board on each run, then narrows it to the tags, keywords, company or salary floor you configure. That fits a Node backend, a serverless timer or a bot that posts new roles to a channel. Run node index.js to save results.json and results.csv, or move the core call into your own service.
Actor: RemoteOK Jobs Scraper Β· Publisher: Devil Scrapes Β· Also in Python: remoteok-scraper-python
- The whole board in one request: RemoteOK serves every live listing as a single feed, so a run fetches it once and does all filtering afterwards. There is no paging and no visit to individual job pages.
- Four filters that stack: tags, keywords, a company fragment and a salary floor. Different filters must all pass; within the tag list or the keyword list, any one hit is enough.
- Loose tag matching on purpose: a wanted tag counts when it appears anywhere inside a posting's tag, ignoring case, so
devcatchesdevopsas well asdev. Choose specific tags when you want a narrow cut. - Missing values stay missing: RemoteOK writes
0for an unstated salary and an empty string for an unknown location; both becomenullso averages and group-bys are not skewed by fake values. - Readable descriptions: the HTML body of each ad is flattened to plain text with entities decoded and runs of whitespace collapsed.
- A stable key on every row:
job_idis RemoteOK's numeric id, with the URL slug used as a fallback, andepoch_postedsits next to the ISO date for cheap range filters. - Upstream trouble is reported, not hidden: timeouts, rate limits and temporary unavailability are retried with growing pauses; if the feed still cannot be read, the run fails with the reason in its status message instead of returning an empty dataset.
Run the module every hour from a scheduled function, remember which job_id values you have already announced, and send only the new ones to an incoming webhook. A minSalary value in the input keeps the channel limited to roles worth reading.
A board for, say, remote Go or Elixir developers can call the Actor with the right tags, upsert the rows into Postgres by job_id and render them from its own API. The url field links each card back to the original RemoteOK ad.
Companies posting several remote engineering roles are usually growing. A queue worker can group the latest results by company and create or update accounts in your CRM, with the job titles attached as context for the rep.
An Express route or a chat assistant can take a user's skill list, map it to tags, start a run and reply with the newest matches. Because description is plain text, it can be shown or summarised without an HTML sanitiser.
You need an Apify account and an API token from Console β Settings β Integrations. Apify's free plan includes monthly usage credit; this Actor's event prices are listed under Pricing below.
git clone https://github.com/devilscrapes/remoteok-scraper-javascript.git
cd remoteok-scraper-javascript
npm install
export APIFY_TOKEN=your_token_here
node index.js # uses input.json
node index.js my-input.json --out data --max-charge 2The script starts the RemoteOK Jobs Scraper, waits for it to finish, prints a link to the run in Apify Console and saves every result to results.json and results.csv. It exits with code 1 and the run's status message if the run fails.
The integration fits in a few statements. call() waits until the Actor run has completed, then listItems() returns the stored postings from its default dataset as an array.
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: 'YOUR_APIFY_TOKEN' });
const run = await client.actor('DevilScrapes/remoteok-jobs-scraper').call({"tags": ["python"], "maxItems": 10, "proxyConfiguration": {"useApifyProxy": false}});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
console.log(items);index.js forwards input.json untouched. The sample keeps up to ten ads whose tags include python, so a trial costs barely more than the start fee. Put several values in tags to accept any of them, or add keywords to search titles, descriptions and company names as well.
{
"tags": [
"python"
],
"maxItems": 10,
"proxyConfiguration": {
"useApifyProxy": false
}
}| Field | Type | Required | Default | Allowed values |
|---|---|---|---|---|
tags |
array | no | [] |
β |
keywords |
array | no | [] |
β |
company |
string | no | "" |
β |
minSalary |
integer | no | β | β |
maxItems |
integer | no | 100 |
β |
proxyConfiguration |
object | no | {"useApifyProxy": false} |
β |
Every object is one advert, ready for JSON.stringify into a response or an INSERT into a table. Optional fields come back as null, never as 0 or an empty string, so simple truthiness checks in your templates do what you expect.
| Field | Type | Always present |
|---|---|---|
job_id |
string | yes |
slug |
string | yes |
position |
string | yes |
company |
string | yes |
company_url |
string or null | no |
location |
string or null | no |
tags |
array | yes |
salary_min |
integer or null | no |
salary_max |
integer or null | no |
description |
string | yes |
apply_url |
string | yes |
url |
string | yes |
date_posted |
string | yes |
epoch_posted |
integer | yes |
logo |
string or null | no |
| Event | Price (USD) |
|---|---|
| Actor start | $0.2 |
| Result emitted | $0.0015 |
Prices as configured on 2026-08-20. The Actor page always shows the current price. Runs are billed to the Apify account that owns the token. Use --max-charge to cap what a single run can cost.
This module needs nothing beyond Node 20 built-ins, including global fetch. It finds postings that are absent from seen-job-ids.json, prints them newest first, posts them to the URL in WEBHOOK_URL when that variable is set, and records their IDs for the next run.
import { readFile, writeFile } from 'node:fs/promises';
const jobs = JSON.parse(await readFile('results.json', 'utf8'));
const seenFile = 'seen-job-ids.json';
const seen = new Set(JSON.parse(await readFile(seenFile, 'utf8').catch(() => '[]')));
const fresh = jobs
.filter((job) => !seen.has(job.job_id))
.sort((a, b) => b.epoch_posted - a.epoch_posted);
const lines = fresh.map((job) => {
const pay = job.salary_max ? ` (top of range ${job.salary_max.toLocaleString('en-US')})` : '';
return `${job.position} at ${job.company}${pay}: ${job.url}`;
});
if (lines.length > 0 && process.env.WEBHOOK_URL) {
await fetch(process.env.WEBHOOK_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ text: lines.join('\n') }),
});
}
console.log(lines.join('\n') || 'No new postings since the last run.');
await writeFile(seenFile, JSON.stringify([...seen, ...fresh.map((job) => job.job_id)]));Every run reads the entire live board, so frequency only changes how fast you notice new ads. Hourly suits an alert bot; daily is plenty for research. Each run costs a $0.20 start fee plus $0.0015 per posting returned, so tight filters keep frequent schedules cheap.
There is no location filter, because RemoteOK's location is free text that many ads leave empty. Filter on the location field in your own code after the run, and expect plenty of null values.
Yes. apify-client includes type definitions, and the record shape is fixed, so you can declare an interface for a posting once and cast items to it after listItems().
A failure means RemoteOK could not be reached or answered with an error after the retries ran out; the status message names the cause. A filter that simply matches nothing finishes successfully with zero items.
Set APIFY_TOKEN first: export the token in the same shell, or put it in a local.envfile your shell loads. Never commit it.- Run finished with
FAILEDorABORTED: open the printed Console link; the run log says why. Input validation errors name the field. - Fewer rows than expected: a
--max-chargecap also limits how many results can be charged. Raise the cap or lower the limits in your input. - Something looks wrong in the data: open an issue on the Actor page so the maintainers see it.
- RemoteOK Jobs Scraper on Apify Store
- Python version of this example
- All Devil Scrapes Actors
- Apify web scraping cookbook
Related Actors:
- We Work Remotely Jobs Scraper β We Work Remotely postings from its main or category RSS feeds, with region and expiry dates.
- Remotive Remote Jobs Scraper β the Remotive board, filterable by category and job type, to widen coverage.
- Himalayas Remote Jobs Scraper β himalayas.app openings filtered by keyword, category or country.
MIT licensed example code. The Actor itself runs on Apify and is maintained by Devil Scrapes. Not affiliated with RemoteOK (remoteok.com).