# Changelog of RemoteOK Jobs Scraper | Remote Tech Jobs, Salary & Stack (`tqm/remoteok-scraper`) Actor

- **URL**: https://apify.com/tqm/remoteok-scraper/changelog.md
- **Full Actor documentation**: https://apify.com/tqm/remoteok-scraper.md

## Changelog

### \[2.2.1] - 2026-09-26 - the Console prefill was an empty intersection, so a buyer's first run failed

The Store form prefilled two filters at once, `categories: ["dev"]` and `regions: ["United States"]`.
Apify applies prefills when the buyer clicks Start, so that pair, which nobody chose, was the input
of every untouched first run. Filters AND together and those two do not overlap, so the run returned
nothing, and with `failOnZeroResults` defaulting to true it returned nothing as a FAILED run.

Over the preceding 30 days the Store actor logged 20 runs: 5 SUCCEEDED, 15 FAILED.

Measured against the live actor, one filter at a time:

| input | result |
|---|---|
| `categories: ["dev"]` | SUCCEEDED, 23 items |
| `regions: ["United States"]` | SUCCEEDED, 6 items |
| both, the Console prefill | **FAILED, 0 items** |
| no filters | SUCCEEDED, 99 items |

The failing run's own numbers say why: `filteredOut {"categories":53,"categories_uncategorised":23,"regions":23}`.
Of 99 postings, 53 are a category other than dev, 23 carry no category at all (RemoteOK leaves about
a quarter of the board unlabelled and the filter rejects those, by design since 2026-09-10), and the
23 dev postings that remain are all outside the United States string.

**Fix: the `regions` prefill is gone.** `categories: ["dev"]` stays, because a prefill that
demonstrates a filter is worth having and that one returns rows on its own.

Two alternatives were rejected:

- *Default `failOnZeroResults` to false.* That hides the symptom and costs the actor a real feature.
  A zero-row run that reports success is indistinguishable from a broken actor, which is the whole
  reason the flag defaults to true, and it was reporting this bug accurately. The internal pipeline
  sets it false for this actor because zero really is a normal daily outcome there, but that is a
  caller's choice, not a sane default for a pay-per-result Store listing.
- *Stop `categories` rejecting unlabelled postings.* That is the fail-closed fix from 2026-09-10 and
  reversing it would silently re-invert the filter. Unrelated to this bug: either prefill works on
  its own.

Verified on the live Store actor: the old prefill pair still FAILED (run `NyCJ09B49Vnz4zllm`, 0
items), the new prefill SUCCEEDED (run `RpUGkygOymq6IT8sj`, 23 items).

Docs updated with the same numbers: the schema now says why `regions` has no prefill, the Store
README explains that filters AND together and what the form arrives with, and the root README's
claim that `categories` "leaks" was corrected, it has failed closed since 2026-09-10.

### 2026-09-10 — `categories` failed OPEN and inverted itself; `regions` advertised values that match nothing

Found by running every filter at a generic, buyer-shaped value against the live board.

#### 🔴 `categories` returned exactly the rows it could not classify

```ts
if (categories.length > 0 && category) { ... }   // before
```

The `&& category` guard meant a posting with **no** detected category **bypassed the filter
entirely**. Combined with a Store README that advertised `["devops", "backend"]` — neither of which
is in `KNOWN_CATEGORIES` — the filter inverted:

| input | result |
|---|---|
| `categories: ["devops","backend"]` (the README's own example) | **14 rows, every one `category: null`** |
| | 85 classifiable rows rejected; 14 unclassifiable rows kept |

A buyer asking for devops jobs was handed the postings the actor knew nothing about, under a status
message reading *"That is every posting on the board matching your filters"*.

**Now fails closed.** A row with no category cannot satisfy a category filter, so it is rejected and
reported as its own reason, `categories_uncategorised` — so an empty run distinguishes *"your value
was wrong"* from *"the board is unlabelled"*. 14 of 99 postings carry no category.

**And the documented values were wrong everywhere.** The only labels that exist are `dev`, `design`,
`sales`, `marketing`, `support`, `admin`. Measured live: `dev` 29, `marketing` 27, `design` 27,
`sales` 2. Corrected in `INPUT_SCHEMA.json`, the README field table, the input sample, and a new
table of real counts.

#### 🔴 `regions` was documented as a taxonomy; it is a substring match

The schema offered *"e.g., USA, Europe, Asia"* and the README *"e.g. \`\["Europe"]"*. Measured on the
same 99-posting board:

| value | postings |
|---|---:|
| `Remote` | 34 |
| `United States` | 8 |
| `US` | 7 |
| `USA` (the schema's example) | **1** |
| `Europe` (the README's example) | **0 — the run FAILS** |
| `Asia` · `Germany` · `Anywhere` · `Worldwide` | **0** |

Behaviour unchanged — it matches against the employer's free-form location text, which is all it can
do. The documentation now says so, with real counts and a working prefill.

> ⚠️ **An example value in a listing is a promise that it works.** Both of this Actor's array filters
> shipped with examples that returned 1 row and 0 rows from a 99-row board.

**Not a bug, recorded so nobody re-files it:** 8 of 99 postings carry mojibake in `location`
(*"Heart\u00e2\u0080\u0099s Content"*, garbled Arabic). Checked against `https://remoteok.com/api`
directly — **the upstream API serves it already mangled**, identically. The Actor passes through what
it is given.

### Unreleased

#### 2026-09-08 — a short result set now says whose limit it was

Measured against the live Store listing: asking for the advertised maximum of **100** returned
**100** (100%), and the count reproduced exactly across separate runs — so this is the board's real
inventory, not a flaky scrape. The status message said only `Returned 100 RemoteOK postings.`, which a
buyer who asked for 100 cannot tell apart from a broken Actor.

`finishRun()` now receives `requested: maxJobs` and appends the reason:

```
Returned 100 RemoteOK postings. That is every posting on the board matching your
filters — asking for 100 will not return more today.
```

`RUN_STATS` also carries `requested` and `boardExhausted`. Logic and tests live in
`apify-actor-kit` (`inventory.ts`); `src/kit/` here is generated.

### \[2.1.0] - 2026-08-27

#### Added — a zero-row run now says why, and fails

Adopts the shared `src/kit/`, generated from [`apify-actor-kit`](https://github.com/Atredies/apify-actor-kit) — do not edit in place; `check-sync.sh` fails a build on any hand-edit.
This is the first consumer.

Until now a run that returned nothing printed `✓ Scraping complete!` and exited
`SUCCEEDED`. A buyer paying per result could not tell a too-narrow filter from a
broken actor from a RemoteOK format change. Now:

| outcome | run |
|---|---|
| Filters removed everything | **FAILS** — `Read 99 postings but every one was removed by your filters (techStackFilter=99). Loosen the filters and re-run.` |
| API returned records, none usable | **FAILS** — a RemoteOK format change, reported as one |
| Feed genuinely empty | **FAILS** — and says how to tell the two apart |

- **`failOnZeroResults`** (default `true`) lets a scheduled user opt back into
  quiet green runs.
- **`RUN_STATS` now carries a per-filter breakdown** (`filteredOut`), and is
  written *before* the failure — a failed run is exactly when someone reads it.

Verified locally against the live feed, no platform spend:
`99 fetched → 99 parsed → filteredOut {techStackFilter: 98} → 1 pushed`, exit 0;
and with a filter matching nothing, exit **1** with `RUN_STATS.zeroRowReason =
filtered_out` on disk.

#### Fixed — `maxJobs` advertised a ceiling it cannot reach

The input schema offered up to **1,000**. RemoteOK's public feed is a single
un-paginated page of ~100 live postings, so anything above that was unreachable
by construction. Maximum corrected to **100** and the description now states the
real limit. Selling a cap that cannot be delivered is a refund request.

#### Changed

- One batched `Actor.pushData()` per run instead of one call per row.

### Unreleased — 2026-08-13

Documentation only. **No behavior changes**; build `0.0.5` is unmodified.

#### Added

- Full documentation suite: `docs/ARCHITECTURE.md`, `docs/DEVELOPMENT.md`, `docs/REMOTEOK-DOM.md`,
  `docs/IMPROVEMENT-PLAN.md`, and `CLAUDE.md`.
- `.actor/dataset_schema.json` — the output surface was previously undeclared.
- README rewritten as a buyer-facing listing, with an explicit known-limitations table.

#### Verified against live RemoteOK

Two platform runs on 2026-08-13:

| Input | Rows | Cost | Duration |
|---|---:|---:|---:|
| `maxJobs: 10` | 10 | $0.0042 | 34 s |
| `maxJobs: 100` | 49 | $0.0025 | 17 s |

**~$0.05 per 1,000 rows** — the cheapest actor in the portfolio. 49 rows is source exhaustion: the
RemoteOK homepage is the only page read and it carries ~49 listings.

#### Findings recorded (not fixed)

- A zero-row run exits **SUCCEEDED** — total scrape failure is indistinguishable from success.
- `maxJobs` advertises a maximum of 1,000; ~49 is achievable.
- `description` is not a description — it is the row's embedded JSON-LD blob captured via
  `textContent` and truncated at 1,000 chars, mid-string.
- `verified` (hard-coded `false`) and `currency` (hard-coded `"USD"`) are fabricated.
- `postedAt`, `companyUrl`, `applyUrl` are declared but never populated.
- `skills` duplicates `techStack`.
- The `jobId` fallback embeds `Date.now()`, so it differs every run.
- Salary regex caps at three digits — `$1500` would parse as `$150,000`.
- The `categories` filter is bypassed for jobs with no detected category.

All are itemized and prioritized in `docs/IMPROVEMENT-PLAN.md`.

***

### 0.0.5 and earlier

No changelog was kept. Build `0.0.5` is the deployed build as of 2026-08-13; the last source commit
was 2025-11-16.
