Skip to main content

ats-fetch

tests

Archive a job posting verbatim, from its URL, using the applicant tracking system's own API.

git clone https://github.com/jloor/ats-fetch
cd ats-fetch
python3 ats_fetch.py https://jobs.ashbyhq.com/ramp/34413f8d-26bf-4bbc-8ade-eb309a0e2245

Python 3.9 or newer. No dependencies, standard library only.

Why

A job posting is the record of what you applied to, and postings do not stay put. They get pulled, rewritten, and quietly re-priced. The fields that move are the ones that decide whether you want the job: the compensation range, the remote status, and the work hours.

Aggregators are not the record either. They publish estimated salaries the employer never stated, and they drop the posting when the employer does.

This saves the real text, from the source, before it changes.

It is not a job scraper. Scrapers exist to discover jobs: you point one at a company, or at many companies, and it returns everything currently open. This does a different job. You give it one posting you already care about, and it keeps that posting exactly as published.

The difference is not cosmetic, because the two have different standards of correctness. A scraper that misses two percent of listings is working fine. An archive that alters one word is not an archive.

When this matters

The range moves between the posting and the offer. You applied against a published band. Weeks later the number on the table is lower, and the live posting no longer says what it said when you read it. The archived copy is what was published on the day you applied.

The requisition is pulled while you are still in process. Your interview is Monday and the posting is gone. You cannot prepare against a page that returns a 404, and asking the recruiter to resend the description is not a strong opening move.

Remote turns out to mean something else. "Remote - US" on the day you applied, "hybrid, three days on site" by the time it comes up on a call. One of you is misremembering and only one of you can prove it.

You cannot remember which one wanted what. Eleven applications in six weeks, and the company asking you about Terraform is not the company you think it is.

You are working out what a level actually pays. Published employer ranges, gathered across many companies, instead of an aggregator's estimate. Several jurisdictions now require a range in the posting, which makes the posting the primary source and worth keeping.

You want to see what an employer changed. Two captures of the same requisition, weeks apart, show you exactly which words moved. Nothing here does that comparison for you yet, and it is the most obvious thing to build next.

I wrote this after three postings I was working on disappeared before I finished applying to them.

The part worth stealing

If you only take one thing from this repository, take these. Each returns the full posting as JSON, publicly, with no key.

Platform Endpoint
Greenhouse https://boards-api.greenhouse.io/v1/boards/{token}/jobs/{id}?content=true
Ashby https://api.ashbyhq.com/posting-api/job-board/{token}?includeCompensation=true
Lever https://api.lever.co/v0/postings/{token}/{id}?mode=json
Workday https://{tenant}.{sub}.myworkdayjobs.com/wday/cxs/{tenant}/{site}/job/{path}

Two of them have a catch.

Ashby returns the whole board, not one job. You filter for your posting by matching the UUID against each entry's jobUrl. includeCompensation=true is what gets you the pay range, and it is off by default.

Greenhouse embedded boards have no token in the URL. When a company hosts its own careers page, the posting link looks like company.com/careers/detail/?gh_jid=7961297. The board token you need for the API is nowhere in it. ats_fetch.py recovers it: it takes the hostname labels, strips careers, jobs, and apply, then strips company suffixes like hq, inc, labs, technologies, and probes the API with each candidate until one returns a real job. That is how it works on boards that do not look like Greenhouse at all.

Usage

python3 ats_fetch.py <url>                  print the archive block to stdout
python3 ats_fetch.py <url> --new <dir>      write a fresh job-description.md
python3 ats_fetch.py <url> --append <dir>   append the block to an existing one
python3 ats_fetch.py <url> --json           dump the raw API payload

The output is a metadata table followed by the full posting text. This is real output, not a tidied-up version of it:

| Field | Value |
|---|---|
| **ATS** | Ashby |
| **Title** |  Security Engineer, Cloud |
| **Canonical URL** | https://jobs.ashbyhq.com/ramp/34413f8d-26bf-4bbc-8ade-eb309a0e2245 |
| **Apply URL** | https://jobs.ashbyhq.com/ramp/34413f8d-26bf-4bbc-8ade-eb309a0e2245/application |
| **Location as stated** | New York, NY (HQ) |
| **Comp as stated** | $211.4K – $290.6K • Offers Equity |
| **isRemote flag** | True |
| **Posted / updated** | 2026-04-07 |
| **Captured** | 2026-08-13 |

Note the leading space in the title and the bullet characters in the comp field. Those come from the employer and they are left alone, which is the whole point.

Supported: Greenhouse (including gh_jid embedded boards and the .eu domains), Ashby, Lever, Workday. Anything else falls back to fetching the page and stripping tags, which is lossier but usually still usable.

Three decisions worth knowing about

It saves the text unedited. No summary, no cleanup, and it does not fix the employer's typos. An archive is only worth having if it is what was actually published.

It refuses to archive a board page. A directory of open roles returns plenty of text, so length cannot tell it apart from a posting. ats-fetch checks the URL shape and exits with an explanation rather than writing a file that looks like an archive and contains no job.

It unescapes before it strips tags. Several of these APIs return the description entity-escaped, and at least one returns it escaped twice. Strip the tags first and the markup survives as literal words: span, div class, data-ccp-parastyle. Those then poison anything you do downstream with the text.

What it does not do

  • Gated boards. If a posting needs a login, a session, or an invitation, this cannot reach it. There is no authentication anywhere in the tool and none is planned.
  • Crawling or batch. One posting per invocation, by design. See the note below.
  • Retries. A failed fetch returns nothing and says so. Run it again.
  • Change detection. It captures a posting at a moment. Comparing two captures to find what an employer edited is a genuinely useful thing to build, and it is not built here.

Tests

python3 tests/test_ats_fetch.py

No network. They cover ATS detection including the embedded-board case, the board-page refusal, and the unescape order.

Contributions

New ATS adapters are very welcome. If a platform publishes a JSON posting API, add a fetcher following the existing pattern and a row in the detection test. I review when I can, which may take a couple of weeks.

I am unlikely to merge refactors, restyling, or anything that adds a dependency. Being standard-library-only is a feature, not an oversight.

A note on politeness

This calls public, unauthenticated endpoints that these platforms publish to serve their own job boards, and it identifies itself honestly as ats-fetch/1.0. It fetches one posting per invocation. Please keep it that way. If you want a corpus, ask the platform.

On AI use

AI was used to write, organize, and maintain this project, including most of this README.

Scope, review, and the decision to ship are human gated by me. I read what goes out.

Licence

MIT. See LICENSE.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

ats_fetch-1.1.0.tar.gz (12.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ats_fetch-1.1.0-py3-none-any.whl (10.7 kB view details)

Uploaded Python 3

File details

Details for the file ats_fetch-1.1.0.tar.gz.

File metadata

  • Download URL: ats_fetch-1.1.0.tar.gz
  • Upload date:
  • Size: 12.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.12.13

File hashes

Hashes for ats_fetch-1.1.0.tar.gz
Algorithm Hash digest
SHA256 61f7903aafd9157687c6be4059315359d92b4c514868e645c36deef78130d963
MD5 9dbc074d4c0f4f6917bbd6ce32b1c3aa
BLAKE2b-256 fcd5c07c3b2c19443d10534ed605046898d55bce2f5396ac240698d2227bc950

See more details on using hashes here.

File details

Details for the file ats_fetch-1.1.0-py3-none-any.whl.

File metadata

  • Download URL: ats_fetch-1.1.0-py3-none-any.whl
  • Upload date:
  • Size: 10.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.12.13

File hashes

Hashes for ats_fetch-1.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 3cfe0b8307df5430d2803aaed363b02821d998f3847dc61aa649cc10760045bc
MD5 bc2d2b26ec713406eccf2b8609994060
BLAKE2b-256 1ac7adb6515c02ce72098eb2aeaec55ddd5cd7e485d56d3a11d0e70c28022410

See more details on using hashes here.

Release history Release notifications | RSS feed

1.2.0

2 files

1.1.1

2 files

This release

1.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page