# Changelog of Company Career Page Jobs Scraper — 6 ATS Platforms (`humble-echidna/ats-jobs`) Actor

- **URL**: https://apify.com/humble-echidna/ats-jobs/changelog.md
- **Full Actor documentation**: https://apify.com/humble-echidna/ats-jobs.md

## Changelog

Versions follow MAJOR.MINOR.PATCH (`src/version.py`); Apify shows MAJOR.MINOR from `.actor/actor.json`.
Every run logs its version and records it in the `RUN_STATS` key-value record.

> **About future failures:** these scrapers read each ATS's public job feed. ATS vendors change those feeds
> without notice. If a company that used to work starts failing, or `RUN_STATS` shows `skippedRecords`,
> suspect a site/format change first, not a regression in this code. Check the run log: it names the
> company and says what didn't parse. One broken site never affects the other companies in a run.

### 1.0.3 (2026-09-25)

- **No new platforms: 18 more were checked and none passed.** BambooHR, Jobvite, iCIMS, JazzHR, Pinpoint, Taleo,
  SuccessFactors, Comeet, Freshteam, Homerun, JOIN, Dover, Gem, Polymer, Manatal, Zoho Recruit, Trakstar Hire and
  JobScore were each checked for a documented public job feed, their terms of service and their robots.txt. Every one
  failed at least one of these: the feed needs a key or token, is a private per-company link, or is documented only for
  the company's own careers page, or the terms forbid automated access or reuse (the README FAQ quotes each reason).
  Their URLs, and short forms such as `bamboohr:acme`, are now recognised and reported in `RUN_STATS.unresolved` with
  the reason. A careers page that links only to one of them says why instead of "no supported job board". Nothing is
  requested from these platforms, and they're never guessed from a company name.
- README: says that jobs are fetched live during the run (no pre-crawled index), so every job returned is posted at
  that moment.

### 1.0.2 (2026-09-24)

- **Workday, Workable, Rippling and Breezy HR are no longer read** (10 → 6 platforms: Greenhouse, Lever, Ashby,
  Recruitee, Personio, Teamtailor). A terms-of-service review of every platform kept only those that publish their job
  feed for machines and whose terms allow this use. Workday career sites are only readable through the internal
  backend of Workday's own career-site app, not a documented public API, and Workday's terms require a separate
  agreement for its APIs. Workable's widget endpoint is documented only for embedding a company's jobs on its own
  website. Breezy HR's terms forbid access other than through its publicly supported interfaces and name scraping.
  Rippling's board endpoint isn't a documented public API and the review didn't clear it for automated third-party
  use. Their URLs (and `workday:`/`workable:`/`rippling:`/`breezy:` short forms) are still recognised and reported
  in `RUN_STATS.unresolved` with the reason, like SmartRecruiters since 1.0.1; company names are no longer looked up
  there. The rest of a run is unaffected.
- Default input: 6 small real boards, one per platform (Linear, bunq, Greenhouse, Outreach, ottonova, Teamtailor),
  instead of examples that included a trial account and a 1-job board.
- Teamtailor: career sites with more than 100 open jobs return every job. The feed lists 100 at a time and only the
  first 100 were read; the rest are now read page by page (Teamtailor's `offset` parameter).
- Personio: jobs written only in another language keep their description. Personio's English feed leaves that text
  out, so it's read from the company's own-language feed (one extra request, only for companies that need it).
  "Full or part time" jobs now have no employment type (`null`) instead of `PART_TIME`.
- Recruitee: salary from Recruitee's own fields comes back as numbers (Recruitee sends strings), and a range left at
  0 is no salary instead of `0`-`0` with a currency.

### 1.0.1 (2026-09-24)

- Shared safety code (`mms_common`, the same in every actor of this portfolio): robots.txt per RFC 9309 (byte-order
  marks, product-token matching, percent-encoding, a 500 KiB cap counted while streaming), a Crawl-delay above 30 s
  reports that site instead of stalling the run, `Retry-After` honoured (seconds or a date; above 30 s reported),
  the private-network guard on every request and redirect hop (resolve-and-check, then connect to the checked
  address; ports 80 and 443 only; IPv6 forms that wrap an address, 6to4 and site-local addresses refused), a time
  limit per attempt and one for the whole request (redirects, retries and robots.txt included), and decompression in
  small steps with a capped size. robots.txt rules are matched in linear time, so a hostile file can't stall a run;
  a robots.txt cut at the size cap loses its partial last line, and its retries honour `Retry-After`. A malformed
  address or redirect target is reported for that company instead of crashing the run.
- Company names: a guess that fails for robots.txt or network reasons (e.g. an unknown Personio name redirecting to
  Personio's site, whose robots.txt answered 429) now just means "not on that platform", with no retries and no
  misleading robots.txt message; an unknown name is answered in seconds.
- A careers domain that doesn't exist is reported as such (it was reported as a robots.txt problem).
- Jobs reserved against the run's limit for a company whose job details robots.txt then disallows are given back to
  the other companies in the run.
- Honest identity and robots.txt: requests now carry an identifying User-Agent
  (`HumbleEchidnaApify/1.0 (+https://apify.com/humble-echidna)`) instead of a desktop-browser one, and every host is
  checked against its robots.txt first (job APIs, board pages and careers pages alike). A page a site disallows is
  never requested: that company is reported as failed with the reason. A site's Crawl-delay is respected (Lever asks
  for 1 second between requests, so large Lever runs are a little slower).
- **SmartRecruiters is no longer read**: its robots.txt disallows its job API for every automated client except
  LinkedIn's. SmartRecruiters URLs are recognised and reported with that reason; names are no longer looked up there.
  10 platforms remain. The default input drops its SmartRecruiters example.
- The ATS code now lives in a shared package (`common/mms_ats`) that the single-platform
  actors (Workday, Greenhouse, Lever, Ashby Jobs Scraper) also use, so a fix to one platform reaches all of them.
- An empty `companies` list now also writes the `RUN_STATS` record before failing the run.
- **The `proxy` input is removed.** Every platform read here is a public API that needs no proxy, and a proxied
  connection can't be checked against private networks. Saved inputs and tasks that still contain `proxy` keep
  working: the field is ignored, with a note in the log.
- **A run where no company can be read now ends successfully with 0 jobs**, with a status message saying so, instead
  of failing: for example when every company is on SmartRecruiters (not read, see above) or no name could be found.
  `RUN_STATS` (`unresolved`) and the log say why for each company.
- "Only jobs new since my last run": a job is remembered as returned only once it's in your dataset. A run that
  stopped after collecting jobs but before writing them, or that cut them at the max cost per run, used to mark them
  as seen, so later runs never returned them.
- One malformed company line (e.g. `http://[bad`) is reported as unresolved with the reason and the rest of the run
  continues; it used to crash the whole run.
- `RUN_STATS` has the same keys on every exit path (`dropped_invalid`, `failure` and `error` included), so a run
  that stops early is still readable.
- Once every job the run can pay for is reserved, companies still being fetched are stopped right away instead of
  finishing for nothing; if a company holding reserved jobs then fails, the stopped ones get another turn.
- A job record with a missing required field no longer uses up a place under max jobs / max cost per run.
- Personio and Teamtailor feeds are parsed with DTDs forbidden (no entity expansion, no external entities).
- Workday: when a title keyword itself matches more than Workday's 2,000-job list limit, the log says so.

### 1.0.0 (2026-09-24)

First release.

- 11 ATS platforms: Workday, Greenhouse, Lever, Ashby, SmartRecruiters, Workable, Recruitee, Personio,
  Rippling, Breezy, Teamtailor.
- "Max jobs per run" input, and the maximum cost per run is honoured: once either is covered, companies
  still being fetched are cancelled and the rest are skipped (listed as `boardsSkipped` in `RUN_STATS`).
- Workday: an empty first page is retried once, and a missing job total no longer stops paging (a busy board
  could otherwise return 0 jobs with no error).
- Companies can be given as a **name** (looked up on every platform except Workday; only boards whose own
  company name agrees are used), a **careers page** (scanned for a link to its job board), or a job-board URL.
  What each resolved to, and what couldn't be found and why, is in the log and `RUN_STATS`.
- Board slugs are normalized to lowercase (fixes capitalized Rippling URLs, which Rippling rejects, and stops
  "Linear"/"linear" being fetched twice).
- Charged per job returned, through Apify's standard `apify-default-dataset-item` event (no custom event, so
  nothing is billed twice).
- One output schema for all of them; employment type (`FULL_TIME`, `PART_TIME`, `CONTRACT`, `TEMPORARY`,
  `INTERNSHIP`, `VOLUNTEER`) and workplace type (`remote`, `hybrid`, `onsite`) normalized across platforms.
- Salary: structured where the ATS provides it, otherwise extracted from pay-transparency text in the
  description (`salarySource` says which).
- Company display name filled in for Lever, Ashby and Rippling (their job APIs omit it).
- Filters: title keywords (pushed down to Workday's server-side search), exclude keywords, locations, remote,
  posted within N days, max per company; "only jobs new since my last run" (remembered per company and per
  filter combination in the user's own `ats-jobs-seen` store; keys are restricted to Apify's allowed characters).
- Failure isolation: a failing company, a malformed job record, or a failed detail request only affects itself.
- Performance: parallel pagination (SmartRecruiters 4,800 jobs: 23s → ~3s) and descriptions fetched only for jobs
  the run returns.
