To scrape job postings, collect public job listings from boards and careers pages. A scraper turns them into structured data rows: title, company, location, salary, posting date, apply URL, source URL and description. The real challenge is not downloading HTML. It is keeping those fields stable when layouts, selectors or ATS templates change. Most projects first crawl listing pages to build detail-page URLs, then extract each page separately.
Job board scraping turns public sources into records you can compare. Typical uses include:
Web scraping job postings works best when you need repeated collection. Public Greenhouse and Lever boards are common starting points. For example, an agency can pull every new Data Engineer opening from competitor careers pages into one spreadsheet each morning.
Public visibility does not create a right to collect. Before you scrape job postings, read the site’s terms of service first and treat robots.txt as a policy signal, not a technical guarantee. Even polite crawling can conflict with contract rules. One German job-ad pipeline study (2026) followed robots.txt and request throttling. Yet StepStone’s terms could still be read to prohibit automated collection. Do not bypass login walls or CAPTCHAs. Recruiter names, applicant details and some salary records can be personal data under GDPR or CCPA, so exclude them unless you have a lawful basis. If a field identifies a recruiter, drop it. Prefer public company boards, official APIs or feeds, keep request volume low, and document your checks for each source. This is not legal advice. Consult counsel for your use case.
Pick the method by the number of sources and the anti-bot pressure. For job board scraping, official ATS feeds are the safest first choice. Public Greenhouse, Lever and Ashby boards often return structured JSON, so you skip fragile HTML selectors. If a site has no feed, choose between a no-code scraper, a hosted scraping API, or a custom script.
Method | Best for | Skill level | JavaScript/anti-bot handling | Maintenance |
|---|---|---|---|---|
ATS JSON feed | Many employers on Greenhouse, Lever or Ashby | Low to medium | Minimal; clean structured data | Low until the feed schema changes |
No-code scraper | Fast tests on a few pages | Low | Limited; struggles with complex pagination, login walls or dynamic grids | High when templates break |
Scraping API | Teams that want managed fetching and parsed HTML or JSON | Low to medium | Depends on provider; may handle JavaScript and blocks | Medium; still monitor output |
Custom script | Large, repeatable pipelines | Medium to high | Use Python plus BeautifulSoup for static pages; Playwright or another headless browser for rendered pages | Ongoing; selectors and retries need care |
The trade-off is clear: a no-code scraper is fast on day one, but you give up stability when markup changes. A custom script costs more setup and upkeep, yet it gives control over pagination, cleaning and alerts. To scrape job listings at scale, prefer feeds, then write code only for what the feed cannot provide.
How to scrape job postings reliably: run the same five-step workflow on any listing source or careers page.
Pipeline: career page URL → JSON-LD or HTML → eight clean fields → CSV every morning.
Before writing any scraper, decide exactly which job postings you need. Pick the boards or career pages, the roles, locations, and freshness window, such as the last seven days. Then fix a schema. A useful minimum is job ID, title, company, location, remote flag, salary range, posting date, description, apply URL, source URL, and collection timestamp. For example, if you track Product Designer roles in Berlin from the last seven days, every row should fill the same columns. This keeps results comparable across different boards. It also makes row validation and missing-field checks easier, preventing painful cleanup later.
Before you scrape job listings, inspect the markup for JobPosting JSON-LD. Without structured data, Google can only guess whether a page is a job posting. For scraping, JSON-LD removes that ambiguity by giving title, location, datePosted and salary as machine-readable fields. Public ATS boards often expose clean structured feeds directly. If the grid loads dynamically, open DevTools, choose the Network tab, filter by Fetch/XHR, and look for a JSON endpoint returning the whole list. It is usually more stable than parsing rendered HTML.
The snippet below uses Python requests to pull a career page and reads JobPosting schema blocks from JSON-LD. If no such source exists, fall back to CSS selectors, but keep them narrow. Layout redesigns often break selector-based extraction, while structured fields change less often. Save the endpoint URL, request headers and required query parameters before writing the extractor. A reliable response reduces cleanup and makes validation easier.
import requests
import json
import re
page = requests.get(URL, headers={"User-Agent": "Mozilla/5.0"})
page.raise_for_status()
blocks = re.findall(
r'<script[^>]*application/ld\+json[^>]*>(.*?)</script>',
page.text,
flags=re.S
)
for block in blocks:
data = json.loads(block)
items = data if isinstance(data, list) else [data]
for item in items:
if isinstance(item, dict) and item.get("@type") == "JobPosting":
print(item.get("title"), item.get("datePosted"))
Fetch listing pages first, then open a detail page only when the list lacks salary, description, or apply URL. Prefer the JSON endpoint found earlier. Parse HTML only when no feed exists. In many developers’ experience, such feeds are faster and more reliable than automating dynamic pages. Use requests for static or JSON responses. Reserve Playwright or another headless browser for JavaScript-rendered boards, infinite scroll, or AJAX-loaded grids. Set a realistic User-Agent. Apply rate limiting with random delays and low concurrency. Throttling slows incoming requests to prevent overwhelmed servers, per Balancing Security and Privacy (2025). Add retry logic with a hard cap so one bad URL cannot loop forever. Extract only the schema fields, store source URL and timestamp, and flag rows with missing required values instead of saving them silently. For web scraping job postings, this keeps the run predictable and the output stable. The pattern below follows pagination links, saves each job as a dictionary, and pauses between attempts.
import time
import random
import requests
HEADERS = {
"User-Agent": "USER_AGENT"
}
def fetch_jobs():
rows = []
# Read pages 1 through 10.
for page in range(1, 11):
url = f"https://HOST/api/jobs?page={page}"
# Try each page up to three times.
for attempt in range(3):
response = requests.get(url, headers=HEADERS, timeout=20)
if response.ok:
data = response.json()
for job in data.get("jobs", []):
rows.append({
"job_id": job["id"],
"title": job["title"],
"location": job.get("location"),
"apply_url": job.get("url"),
"source_url": url,
})
break
time.sleep(random.uniform(2, 4))
# Pause before the next page.
time.sleep(random.uniform(2, 4))
return rows
Raw job listings need cleaning before analysis. Flatten nested salary and poster objects. Use HTML stripping for descriptions, then apply normalization to locations, currencies, and pay ranges. Convert relative dates such as “3 days ago” into absolute dates. Deduplication uses a job ID or canonical URL. If a board relies on session-specific links, fall back to company, title, and region. In U.S. employer postings, a 2020 study, Medicaid expansion job postings, removed repeats with the same role, company, and area inside a two-month window. Save source_url and a collected_at timestamp on every record. Run validation against the schema with Pydantic so missing fields fail loudly. For example, “80k to 95k USD” becomes salary_min and salary_max.
Export each run to a durable format. CSV and JSON work for files. Google Sheets suits shared review. A database helps when you query history.
Schedule the scraper with cron, a scheduler, or a cloud job. A daily 7:00 run can append fresh rows and alert you if fewer than 10 arrive. Also record expired postings. Compare current job IDs with yesterday’s set. Mark disappeared roles as closed and log the row count. Send another notification after repeated failures or blocked requests. That makes a sudden zero visible immediately.
For job board scraping, public company boards and ATS pages like Greenhouse or Lever are usually the most reliable starting point. Large aggregators such as Indeed and LinkedIn invest heavily in bot detection, and their terms of service often restrict automated access.
Public careers subdomain pages from Greenhouse, Lever, and Ashby are the best first targets for job listings. They usually expose a clean JSON endpoint behind the listing grid, so parsing is more stable than scraping rendered HTML. Their bot pressure is low, and one schema can cover many employers with minimal changes. Since most companies do not sell a jobs API, these public boards often become the practical source.
Indeed and LinkedIn render job grids with JavaScript and tie results to location cookies, accounts, and session state. Simple requests often receive a login wall, CAPTCHA, or rate limit. Even an HTTP 200 can contain empty results, with no visible job rows. Their terms of service restrict automated access, so review them first. When the same role appears on the employer’s career page, use that source or an official product.
To scrape job postings with proxies, route requests through an intermediary that conceals the original IP. Boards reject heavy traffic from one address through rate limits, reputation checks, or flagged datacenter ranges. IP rotation spreads the load. Geo-targeting can also match the city or country a board expects, because listings may change by location. One query may produce separate results for Berlin and Munich.
Choose session behavior by task. Rotating endpoints suit many independent searches. A sticky session keeps one address while following pagination, filters, or detail pages, so the site does not treat each fetch as a new visitor. For example, if collecting the same “Nurse” search in six cities, assign one persistent identity per city, add a three-second request delay, and cap retries.
Source category changes trust and maintenance. As Balancing Security and Privacy (2025) notes, datacenter addresses come from cloud providers, not ISPs. Residential alternatives route through real home connections and usually look more ordinary. Carrier-based options use telephone-network addresses. OnlineProxy serves mobile proxies through modems and SIM routers on real 4G/5G carrier IPs, so they resemble ordinary mobile-network traffic. They use username and password authentication and support HTTP or SOCKS5 in scraping scripts. A dedicated device with sticky sessions and IP change by link or timer fits paginated crawls.
Use this Python requests with a proxy pattern for an HTTP proxy:
import requests
proxies = {
"http": "http://USERNAME:PASSWORD@HOST:PORT",
"https": "http://USERNAME:PASSWORD@HOST:PORT",
}
response = requests.get("https://BOARD.example/jobs", proxies=proxies, timeout=20)
print(response.status_code)
For SOCKS5, install the extra requests[socks] support and change the scheme to socks5. A proxy lowers block risk, but it does not fix broken selectors, grant permission, or make restricted collection appropriate. Keep delays, respect terms, and verify the target before publishing.
When you scrape job postings, treat failures as loud signals. HTTP 403 or HTTP 429 usually mean the board sees pressure. Slow the pace, add delays, rotate IPs and check headers. Balancing Security and Privacy (2025) notes that rule-based bot detection checks User-Agent, Accept-Language and Referer.
A CAPTCHA is a stop sign. Lower the request rate or change the source instead of building the pipeline around bypassing it. If the page returns HTTP 200 but zero rows, suspect JavaScript rendering, missing cookies or a regional layout. Use check your proxy's IP location to see what region the site sees.
Broken selectors are common after small page updates. Prefer JSON, JSON-LD or schema-based fields over brittle CSS paths. Deduplicate by job ID or canonical URL, and mark stale rows when postings disappear. Add monitoring: alert on zero-result runs and sudden row-count drops before they corrupt the dataset. A normal status code makes the failure easy to miss. For example, if a layout change leaves zero rows, the script can still log success. Two weeks later, a salary report may rely on stale listings.
Turn the rows into decisions, not archives. In Google Sheets, group postings by role, location and date to see labor market movement. Compare posted pay ranges for salary benchmarking, but normalize currency and benefits first. Create job alerts for roles that match your team or candidates.
For B2B outreach, treat new openings as hiring signals. A company hiring for a function you serve can be a warm prospect. Build lead lists only when the trigger is relevant, and respect privacy rules. For example, if an agency filters for Growth Marketing Manager in London, it can contact those employers with a tailored pitch. Drop posts older than 30 to 45 days unless they are reposted.
It depends on the site’s terms of service, the data you collect, and local privacy law. Public pages can still be restricted. Check the terms first before scraping, avoid personal data and login-protected pages, and consult counsel for commercial use, especially for recurring, large-scale collection.
You can technically attempt both, but they are hard targets. Indeed and LinkedIn use bot detection, login walls, CAPTCHAs, and may return empty results. LinkedIn’s user agreements explicitly prohibit automated scraping. Prefer official APIs, official products, or the employer’s own public career page when possible.
Usually, no. You can scrape job listings from a few public career pages without a proxy. Keep the request rate low. Add one for rate limits, frequent or large crawls, or geo-targeting for local results. On protected boards like Indeed, rotation, throttling and session reuse are not optional where allowed.
Match your schedule to how fast the source changes. For most career pages, scrape once daily. Daily runs suit alerts and fast-changing contract roles. Hourly runs are rarely needed and raise block risk. Use a time filter, such as last 24 hours, to fetch only new or updated roles.
A reliable workflow starts with public boards and structured JSON, fixes one schema, then extracts, cleans, deduplicates and adds monitoring. Pace requests, respect each site's terms of service, and use proxies only when volume, blocks, or location-specific results require them. For the first run, scrape job postings from one career page. Validate 20 rows against the schema, then schedule repeat runs and row-count alerts before expanding.