- mediumThere is no structured jobs API — the board loads cards from a server-rendered HTML partial at /Home/_JobCard.
- Request /Home/_JobCard?Skip={offset} in increments of 12 and parse the returned HTML. Treat totalMatchRecords as the authoritative total and stop once the rows you have collected reach it.
- highA page can arrive blank, without the totalMatchRecords/totalCurrentRecords markers, or with a card count that disagrees with totalCurrentRecords.
- Require both hidden count inputs on every page and verify totalCurrentRecords equals the number of div.job-card elements. Treat any blank, markerless, malformed or mismatched page as an incomplete snapshot rather than a valid empty result.
- mediumTenants exist in two layouts — path-hosted (www.jobtrain.co.uk/{tenant}) and subdomain-hosted ({tenant}.jobtrain.co.uk) — and reserved segments like www, account, cms, session and talentpool are not tenants.
- Derive the card partial and detail URLs from the exact board host, and reject reserved path segments so you never mint a scraper target from a marketing, account or asset URL.
- lowA removed or expired posting responds HTTP 200 with a zero-length body (or 404/410) instead of a JobPosting.
- Treat an empty response body and 404/410 as a removed posting and skip it — do not attempt to parse JSON-LD from an empty document.
- lowbaseSalary is free text such as '32000' or 'c. £60,000' with HTML entities, not a schema.org MonetaryAmount object.
- Read baseSalary as a string and HTML-decode it; do not assume a numeric value or a nested currency/value structure.
Jobtrain Jobs API.
Sweep UK public-sector, NHS and transport vacancies from a multi-tenant HTML board: paginated 12-card partials hand you an authoritative job count, and every detail page embeds a full schema.org JobPosting with salary, dates and locations.
What's in every response.
Data fields, real-world applications, and the companies already running on Jobtrain.
Data fields
- Full HTML Job Descriptions
- Free-text Salary Bands
- Employment Type
- Structured Locations & Postcodes
- Posted & Closing Dates
- Hiring Organization & Logo
Use cases
- 01UK Public-Sector Hiring Monitoring
- 02NHS Job Tracking
- 03Salary Benchmarking
- 04Recruitment Aggregation
Trusted by
- Odeon
- Citizens Advice
- Yorkshire Building Society
- Voyage Care
- HFT
- SIG Plc
How to scrape Jobtrain.
Step-by-step guide to extracting jobs from Jobtrain-powered career pages—endpoints, authentication, and working code.
import requests
from urllib.parse import urlsplit
def board_urls(board_url: str) -> tuple[str, str]:
# "https://www.jobtrain.co.uk/sigplc/Home/Job" (path-hosted)
# "https://hft.jobtrain.co.uk/Home/Job" (subdomain-hosted)
parts = urlsplit(board_url)
origin = f"{parts.scheme}://{parts.netloc}"
prefix = parts.path[: -len("/Job")] # ".../Home"
listing_url = f"{origin}{prefix}/Job"
card_url = f"{origin}{prefix}/_JobCard"
return listing_url, card_url
listing_url, card_url = board_urls("https://www.jobtrain.co.uk/sigplc/Home/Job")
print(card_url)
# https://www.jobtrain.co.uk/sigplc/Home/_JobCardfrom bs4 import BeautifulSoup
PAGE_SIZE = 12
MAX_PAGES = 200 # 12 * 200 = 2,400-record audited safety cap
def hidden_int(soup, element_id):
el = soup.select_one(f"#{element_id}")
value = el.get("value") if el is not None else None
return int(value) if value and value.isdigit() else None
cards, total, received = [], None, 0
for page in range(MAX_PAGES):
resp = requests.get(f"{card_url}?Skip={page * PAGE_SIZE}", timeout=30)
if resp.status_code in (404, 410):
raise SystemExit("Tenant board not found")
if resp.status_code in (401, 403, 429):
raise SystemExit(f"Blocked or auth-gated (HTTP {resp.status_code})")
resp.raise_for_status()
if not resp.text.strip():
break # empty page terminates pagination
soup = BeautifulSoup(resp.text, "html.parser")
page_total = hidden_int(soup, "totalMatchRecords")
current = hidden_int(soup, "totalCurrentRecords")
page_cards = soup.select("div.job-card")
if page_total is None or current is None or current != len(page_cards):
raise SystemExit("Incomplete page — missing/mismatched count markers")
total = page_total if total is None else total
received += len(page_cards)
cards.extend(page_cards)
if received >= total or len(page_cards) < PAGE_SIZE:
break
print(f"Collected {received} of {total} advertised jobs")import re
from urllib.parse import urljoin
JOB_ID_RE = re.compile(r"[?&]JobId=(\d+)", re.IGNORECASE)
def detail_item(card, class_name):
el = card.select_one(f".jobdetailsitem.{class_name}")
if el is None:
return None
full = el.get_text(" ", strip=True)
label_el = el.select_one("strong")
label = label_el.get_text(" ", strip=True) if label_el is not None else ""
value = full[len(label):].strip() if label and full.startswith(label) else full
return value or None
jobs = []
for card in cards:
anchor = (card.select_one("a[data-testid^='a-job-detail-']")
or card.select_one("a[href*='JobDetail']"))
href = anchor.get("href") if anchor is not None else None
if not href:
continue
detail_url = urljoin(listing_url, href)
match = JOB_ID_RE.search(detail_url)
title = anchor.get_text(" ", strip=True) if anchor is not None else None
if not (match and title):
continue # skip cards with no numeric JobId or title
loc = card.select_one(".job-card__location strong")
jobs.append({
"external_id": match.group(1),
"title": title,
"location": loc.get_text(" ", strip=True) if loc is not None else None,
"reference": detail_item(card, "jobreference"),
"employment_type": detail_item(card, "employmenttype"),
"detail_url": detail_url,
})
print(f"Parsed {len(jobs)} cards")import json
def scrape_detail(job: dict) -> dict:
resp = requests.get(job["detail_url"], timeout=30)
if resp.status_code in (404, 410) or not resp.text.strip():
return {**job, "removed": True}
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
posting = None
for tag in soup.select('script[type="application/ld+json"]'):
try:
data = json.loads(tag.string or "")
except (ValueError, TypeError):
continue
for node in (data if isinstance(data, list) else [data]):
if isinstance(node, dict) and node.get("@type") == "JobPosting":
posting = node
break
if posting:
break
if posting is None:
raise ValueError("No JSON-LD JobPosting on detail page")
org = posting.get("hiringOrganization") or {}
raw_locs = posting.get("jobLocation")
raw_locs = raw_locs if isinstance(raw_locs, list) else [raw_locs] if raw_locs else []
apply_anchor = (
soup.select_one("a[data-testid='a-btn-decide-internal-external'][href*='JobId=']")
or soup.select_one("a[href*='/DecideInternalExternal/'][href*='JobId=']")
)
apply_url = urljoin(job["detail_url"], apply_anchor["href"]) if apply_anchor else None
return {
**job,
"description_html": posting.get("description"),
"posted_at": posting.get("datePosted"),
"closes_at": posting.get("validThrough"),
"employment_type": posting.get("employmentType") or job.get("employment_type"),
"salary": posting.get("baseSalary"), # free-text string, not a schema.org object
"company_name": org.get("name"),
"logo": org.get("logo"),
"locations": [loc.get("address", {}) for loc in raw_locs if isinstance(loc, dict)],
"apply_url": apply_url,
}
detailed = [scrape_detail(j) for j in jobs]
print(f"Scraped {len(detailed)} Jobtrain job details")- 1Derive the card partial and detail URLs from the exact board host — path-hosted and subdomain-hosted tenants both exist.
- 2Page /Home/_JobCard in Skip increments of 12 and stop when the rows you have collected reach totalMatchRecords.
- 3Trust totalMatchRecords as the authoritative total; treat missing markers or a totalCurrentRecords/card-count mismatch as an incomplete snapshot.
- 4Read rich fields — description, salary, dates and locations — from the detail page's schema.org JobPosting, not the listing cards.
- 5Self-throttle to about two requests per second (500ms apart) and keep concurrent detail fetches to roughly three.
- 6Skip reserved path segments (www, account, cms, session, talentpool, assets) — they are never tenant boards.
One endpoint. All Jobtrain jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=jobtrain" \
-H "X-Api-Key: YOUR_KEY"Try the API.
Test Jobs and Feed endpoints against https://connect.jobo.world with live request/response examples, then copy ready-to-use curl commands.
Access Jobtrain
job data today.
One API call. Structured data. No scraping infrastructure to build or maintain — start with the $5 free starting balance and scale as you grow.