# Changelog of Multi-ATS Job Scraper & API (`dalleyne/ats-job-scraper`) Actor

- **URL**: https://apify.com/dalleyne/ats-job-scraper/changelog.md
- **Full Actor documentation**: https://apify.com/dalleyne/ats-job-scraper.md

## Changelog

### Unreleased

#### Note for consumers of the `salary` field

The fixes below change what `salary` contains for Greenhouse records. This is not
a schema change — `salary` was `object | null` and still is — but two value
domains widen, so downstream code may need a look:

- **`min`/`max` can now be fractional.** An hourly range like `$28.10 — $34.50`
  is reported as `28.1`/`34.5`. Every amount used to be an integer, so anything
  storing these as integers should be checked.
- **`currency` gained `SGD`, `MXN`, `NZD` and `HKD`.** The previously reachable
  set was `USD`, `CAD`, `AUD`, `GBP` and `EUR`. Treat it as an open set — the
  parser now reports whatever ISO code a posting states, from a fixed allowlist
  that may grow.

Greenhouse `salary` goes from populated on 13% of jobs to 63%. No record in that
sample loses a salary it previously had — a measurement across 1893 jobs on 19
boards, not an invariant of the code. The failure
was concentrated rather than uniform: boards using Greenhouse's structured
`pay-range` widget extracted nothing at all, while boards writing the range
inline in a sentence were largely unaffected — which is why the actor appeared to
be extracting salary.

- Fix Greenhouse salary extraction on boards that use the structured `pay-range`
  block. `src/ats/greenhouse.js` passed the API's `content` field straight to
  `parseSalaryFromText`, but Greenhouse serves that field as an HTML document
  that has itself been escaped, so a pay range arrives as
  `&lt;span&gt;$115,000&lt;/span&gt;&lt;span&gt;&amp;mdash;&lt;/span&gt;&lt;span&gt;$194,000 USD&lt;/span&gt;`
  — markup between the two amounts, and the separator an `&amp;mdash;` entity.
  The regex requires the amounts be separated by whitespace and a hyphen or en
  dash, so it matched nothing. This is why the bug was invisible: the escaping
  only defeats the regex when markup sits *between* the two amounts, which is
  exactly what the `pay-range` block does, so those boards yielded 0% while
  boards writing the range inline in prose kept working. Fixed with two changes
  that are only
  effective together: a new `htmlToText` helper that flattens the content
  (decode → strip tags → decode, since the content is doubly encoded), and an em
  dash added to the separator class. Measured across 1193 live jobs on 10
  Greenhouse boards, salary extraction went from 17 to 727 records; applying
  either change alone still yielded 17. Verified end-to-end against the gitlab
  board (0 → 75 of 188), with no change to any record that already parsed.
- Accept `to` as a range separator (`"$144,000 to $162,000"`). Some boards write
  every range this way — Discord yielded salary on 0 of 44 jobs before this.
  Unlike a dash, `to` requires whitespace on both sides, so it cannot be read out
  of the middle of a word. A live posting advertising `"managing $1M to $5M+ in
  annual paid media budgets"` returns `null`, because the magnitude suffix sits
  between the amount and the separator — but note that requiring two
  symbol-prefixed amounts is not on its own enough to exclude prose; see the
  magnitude guard below. Only lowercase `to` is accepted; no capitalised form
  occurs anywhere in the 1893-job sample.
- Accept amounts written with cents (`"$221,000.00 - $300,000.00"`). Vercel and
  SoFi write annual figures this way and the trailing `.00` previously blocked
  the separator, losing the range entirely. The cents are captured rather than
  discarded, so an hourly `"$28.10 — $34.50"` reports 34.5 and not 34, and a
  fractional `"$1.5k"` scales to 1500 and not 1000.
- Combined effect of the two items above, measured across 1893 live jobs on 19
  Greenhouse boards: 1100 -> 1187 records with salary, with **zero** records lost
  or changed except one strict improvement — a Gusto posting listing three
  regional ranges previously matched only the trailing abbreviated `"$170-$190
  CAD"` and reported a salary of 170; it now matches the leading
  `"$185,000 to $200,000"`.
- Derive currency from a code stated immediately after the range, falling back to
  the previous +/-200 character context guess only when none is stated. `£` and
  `€` name their own currency and are untouched; only `$` needed the work. 74% of
  live postings state a code, so this replaces a guess with the poster's own
  answer in most cases. Two classes of error are fixed: a posting listing several
  regional ranges used to attach a neighbouring region's currency to the wrong
  range (a Gusto posting quoting USD for Denver and CAD for Toronto tagged the
  Denver range CAD), and currencies the context heuristic never knew about were
  silently reported as USD — Airbnb's `"Mexico Monthly Pay Range $100,000 —
  $125,000 MXN"` was emitted as 100000 USD, and Coinbase's Singapore ranges as
  USD rather than SGD.
  The accepted codes are an explicit allowlist rather than any three-letter
  token, because non-currency tokens really do follow ranges in live postings:
  `USA` (Affirm's next heading), `OTE` (on-target earnings) and `CAN` all occur,
  and matching `[A-Z]{3}` would read each as a currency.
  Measured across the same 1893 jobs: 9 records change currency (5 to SGD, 3 to
  MXN, 1 to USD), all of them corrections verified against the code the posting
  states, and no record changes amount.
- Guards added after an independent review of the above (GLM-5.2 and Claude
  Fable 5, run against the same diff). All were verified against live board data
  before being acted on:
  - **Reject amounts a magnitude word shows are not a salary.** `"$1 to $5
    million"` parsed as a salary of 1 to 5. Requiring two symbol-prefixed
    amounts excludes the *suffixed* form (`"$1M to $5M"`, where the `M` breaks
    the separator) but not the spelled-out one, and the code comment claiming
    otherwise was wrong.
  - **Rank candidates instead of taking the first match.** Greenhouse appends
    its `pay-range` block at the END of the content, so an earlier money range
    won outright: a live Instacart posting emitted `"an average deal size of
    $80k–$120k"` as the salary in place of its real `$153,000 — $161,500 USD`.
    A range followed by a currency code, or near pay wording, now wins; ties
    keep the earliest.
  - **Reject inverted ranges** rather than emitting `min > max`, falling through
    to a later valid range in the same document.
  - **Do not silently drop precision** — `"$34.505"` reported `34.5`.
  - **Decode numeric character references.** A board serialising its divider as
    `&#8212;` instead of `&mdash;` would have returned silently to zero yield,
    reintroducing the very bug this release fixes one encoding variant away.
  - **Keep literal angle brackets in prose.** Stripping any `<...>` deleted the
    text between them once entities were decoded, so `"&lt; $100,000 -
    $200,000 &gt;"` flattened to an empty string. HTML comments are now dropped
    outright, since superseded pay figures are exactly what hides in them.
- Fix `parseSalaryFromText` reporting k-suffixed salary ranges 1000x too small
  (`"$120k - $150k"` yielded `min: 120, max: 150`). The `[kK]?` quantifiers sat
  outside the regex capture groups, so the scaling branch in `parseAmount` never
  fired. Regression introduced when the previous `num < 1000` heuristic was
  removed — that heuristic had been masking the regex flaw on the k path while
  wrongly inflating plain sub-1000 amounts. Both paths are now correct.
- Add a unit test suite for `parseSalaryFromText` (`npm test`, via the built-in
  `node --test` runner — no new dependencies).
- Known limitation, unchanged: the range regex backreferences the currency
  symbol, so `"$120,000 - 140,000"` (symbol on one side only) does not match and
  returns `null`. Left as-is pending a decision; see the characterisation test in
  `test/normalize.test.js`.

### 1.0.0

- Initial release.
- Scrape Greenhouse, Ashby, and Lever job boards from a single `urls` input with automatic ATS detection per URL.
- One normalized output record across all sources (tagged with an `ats` field).
- Filter by `departments` / `teams` (ATS-specific values) and `daysBack` before storing; `maxJobs` cap per board.
- Composes the standalone `greenhouse-job-scraper`, `ashby-job-scraper`, and `lever-job-scraper` logic.
