如何不抓取 HTML,從 Greenhouse、Lever 和 Ashby 擷取職位空缺
Pulling open jobs from Greenhouse, Lever and Ashby without scraping HTML
作者建立 Apify actor,從 Greenhouse、Lever、Ashby 等招聘系統擷取職位空缺,無須抓取 HTML。它支援六種 ATS,並可在單一招聘頁面使用 schema.org JobPosting 作後備方案。收費為每個職位 $0.002,另加每次執行 $0.00005 的啟動費。
I built an Apify actor that reads job boards from several applicant tracking systems (ATS), so keep that bias in mind. An AI assistant helped me draft this article, and I reviewed and edited it. The free route comes first, because for a handful of companies a short script is enough.
The problem
Recruiters, job-board builders and sales teams that watch hiring signals all want the same thing: a clean list of open roles at specific companies. Career pages are often JavaScript-heavy, but most companies run their hiring on an ATS that exposes a public JSON feed so the career page can render. You do not need to scrape HTML. You need the company's board slug, which is the last part of the careers URL (for example boards.greenhouse.io/airbnb, jobs.lever.co/spotify, jobs.ashbyhq.com/ramp).
The free route
import requests
def greenhouse(slug):
r = requests.get(f"https://boards-api.greenhouse.io/v1/boards/{slug}/jobs", timeout=30)
r.raise_for_status()
return [(j["title"], j["location"]["name"], j["absolute_url"]) for j in r.json()["jobs"]]
def lever(slug):
r = requests.get(f"https://api.lever.co/v0/postings/{slug}", params={"mode": "json"}, timeout=30)
r.raise_for_status()
return [(j["text"], j["categories"].get("location"), j["hostedUrl"]) for j in r.json()]
def ashby(slug):
r = requests.get(f"https://api.ashbyhq.com/posting-api/job-board/{slug}", timeout=30)
r.raise_for_status()
return [(j["title"], j["location"], j["jobUrl"]) for j in r.json()["jobs"]]
for name, fn, slug in [("greenhouse", greenhouse, "airbnb"),
("lever", lever, "spotify"),
("ashby", ashby, "ramp")]:
jobs = fn(slug)
print(name, slug, len(jobs), jobs[0])
I checked these against the live endpoints. Greenhouse returns jobs with title, location.name, absolute_url, updated_at and first_published. Lever returns a list with text, categories (including location, department, commitment), hostedUrl, applyUrl and createdAt. Ashby returns jobs with title, department, location, isRemote, workplaceType, publishedAt and jobUrl. Field sets differ between vendors and can change, so check each response's keys before depending on one. For descriptions, Greenhouse needs ?content=true on the request.
What the free route does not do
- A shared schema. Look at the three functions above: each uses different keys for title, location and date. Remote flags, employment type, department and salary live in different places, or nowhere. Mapping them into one shape is the real work.
- Salary and seniority. Some boards publish pay ranges, many do not. Seniority has to be inferred from titles.
- Finding the slug. You need the right board name for each company, and two companies can share a slug.
- Only-new tracking. To get "jobs posted since yesterday" you must store what you saw and diff it, including changed and removed jobs.
- Other vendors. Workable, SmartRecruiters and Recruitee each have their own formats, and some companies only publish schema.org JobPosting markup on their pages.
- Hosting. A schedule, retries and a place to keep state.
The actor
The actor covers Greenhouse, Lever, Ashby, Workable, SmartRecruiters and Recruitee, plus a schema.org JobPosting fallback for single career pages. You can paste company slugs or careers URLs. The input below is the README's real test input:
{
"companies": [
"airbnb",
"https://jobs.lever.co/spotify",
"https://jobs.ashbyhq.com/ramp"
],
"onlyNewSinceLastRun": false,
"maxJobsPerCompany": 3,
"maxJobsTotal": 3,
"includeDescription": false
}
Other real fields include keywords, excludeKeywords, locations, departments, employmentTypes, remoteOnly, workModels, postedAfter, minSalary, skillsInclude and monitorMode (return only new or changed jobs, optionally also removed ones).
Excerpt of real sample output (description removed, a few fields kept):
{
"company": "Spotify",
"ats": "lever",
"title": "Senior Product Manager, Consumer - Audiobooks",
"department": "Product",
"location": "London",
"remote": false,
"workplaceType": "hybrid",
"employmentType": "FULL_TIME",
"postedAt": "2026-10-12T13:42:44.230Z",
"jobUrl": "https://jobs.lever.co/spotify/ddc10181-6ecc-4dfc-9d02-2c5ce820cfdd",
"salaryMin": null,
"seniority": "senior"
}
The Greenhouse row in the same file shows the vendor differences: Airbnb's "Senior Software Engineer, Payments Transaction Risk" has remote, workplaceType and employmentType all null, because Greenhouse's board feed does not carry them.
Honest limits and pricing
- Six ATS vendors plus schema.org. Workday, iCIMS, Taleo, BambooHR, Personio and JavaScript-only career sites are not supported. An unsupported page returns zero rows and costs nothing.
- Fields depend on the vendor. Greenhouse has no remote flag (the actor sets
remote: trueonly when the location says remote), Lever has noupdatedAt, and most boards publish no salary. -
seniorityis a heuristic from the title only. Treat it as a best guess. - No recruiter names or emails are collected.
- Pricing is pay per result: $0.002 per job ($2.00 per 1,000) plus a $0.00005 start fee per run. Jobs removed by your filters, or already delivered when dedupe is on, are not charged. The store listing mentions lower per-job prices on higher Apify plans, so check the page for your plan.
For a few boards, run the script. For a watchlist with filters and a daily new-jobs feed, the actor is here: https://apify.com/transparent_meteorite/ats-open-roles
Disclosure again: I built the actor, and an AI assistant helped draft this article. I reviewed it and checked the three endpoints before writing the snippet.
來源:DEV Community · dev.to