ats-fetch
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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
61f7903aafd9157687c6be4059315359d92b4c514868e645c36deef78130d963
|
|
| MD5 |
9dbc074d4c0f4f6917bbd6ce32b1c3aa
|
|
| BLAKE2b-256 |
fcd5c07c3b2c19443d10534ed605046898d55bce2f5396ac240698d2227bc950
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3cfe0b8307df5430d2803aaed363b02821d998f3847dc61aa649cc10760045bc
|
|
| MD5 |
bc2d2b26ec713406eccf2b8609994060
|
|
| BLAKE2b-256 |
1ac7adb6515c02ce72098eb2aeaec55ddd5cd7e485d56d3a11d0e70c28022410
|