- criticalThe board has no page that lists every job
- Browse renders only the category index; there is no all-jobs route and no pagination on top of it. The complete inventory is the union of every category page, so a crawler that scrapes Browse alone comes back with zero jobs and no error.
- highA category returns fewer rows than it advertises
- Each category anchor states its own vacancy count in brackets. Compare that number against the rows you parsed and fail the run on a shortfall — silently accepting the smaller set makes downstream reconciliation close vacancies that are still open.
- mediumThe same job appears several times
- A requisition can be filed under more than one category, so unioning the category pages produces duplicates. Deduplicate on the requisition code from /candidateapp/Jobs/View/{code} rather than on the title, which repeats across regions.
- mediumWithdrawn jobs return HTTP 200
- eRecruit serves its own Not found page instead of a 404 for a requisition that has closed. Detect that page explicitly and treat it as a removal; otherwise the job is retried forever as a JSON-LD parse failure.
eRecruit Jobs API.
eRecruit hosts candidate portals at {tenant}.erecruit.co, used by South African mining, engineering and government employers. Its board has no all-jobs page — the complete inventory is the union of its categories.
What's in every response.
Data fields, real-world applications, and the companies already running on eRecruit.
Data fields
- Full Job Descriptions
- JobPosting JSON-LD
- Advertised Category Counts
- Native Requisition Codes
- Labelled Requisition Fields
- Direct Apply URLs
Use cases
- 01African Job Market Aggregation
- 02Public Sector Hiring Trackers
- 03Mining & Engineering Talent Feeds
- 04Careers Page Monitoring
Trusted by
- Exxaro
- Pragma
- Western Cape Government
How to scrape eRecruit.
Step-by-step guide to extracting jobs from eRecruit-powered career pages—endpoints, authentication, and working code.
import re
from urllib.parse import urlparse
RESERVED = {"api", "app", "cdn", "mail", "support", "www"}
DETAIL_ROUTE = re.compile("^/candidateapp/Jobs/View/([A-Z0-9][A-Z0-9-]{2,63})$",
re.IGNORECASE)
def parse_erecruit(url: str) -> dict | None:
parsed = urlparse(url)
if parsed.scheme != "https":
return None
labels = parsed.netloc.lower().rstrip(".").split(".")
if len(labels) != 3 or labels[1] != "erecruit" or labels[2] != "co":
return None
tenant = labels[0]
if tenant in RESERVED:
return None
path = parsed.path.rstrip("/")
detail = DETAIL_ROUTE.match(path)
return {
"tenant": tenant,
"board_url": f"https://{tenant}.erecruit.co/candidateapp/Jobs/Browse",
"job_id": detail.group(1).upper() if detail else None,
}
print(parse_erecruit("https://westerncapegov.erecruit.co/candidateapp/Jobs/View/WCG260611-3"))import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin
CATEGORY_COUNT = re.compile("[(]([0-9]+)[)]\\s*$")
def fetch_categories(session, tenant: str) -> list[dict]:
board_url = f"https://{tenant}.erecruit.co/candidateapp/Jobs/Browse"
resp = session.get(board_url, headers={"Accept": "text/html"}, timeout=30)
resp.raise_for_status()
if "/candidateapp/Jobs/Browse" not in resp.text:
raise RuntimeError("eRecruit board omitted its CandidateApp markers")
soup = BeautifulSoup(resp.text, "html.parser")
categories, seen = [], set()
for anchor in soup.select("a[href*='/candidateapp/Jobs/Categories/']"):
url = urljoin(board_url, anchor["href"])
text = " ".join(anchor.get_text().split())
match = CATEGORY_COUNT.search(text)
identity = parse_erecruit(url)
if not match or not identity or identity["tenant"] != tenant or url in seen:
continue
seen.add(url)
categories.append({
"name": text[: match.start()].strip(),
"expected": int(match.group(1)),
"url": url,
})
return categories
session = requests.Session()
categories = fetch_categories(session, "exxaro")
print(sum(c["expected"] for c in categories), "advertised vacancies")import time
from html import unescape
DETAIL_PATH = re.compile("/candidateapp/Jobs/View/[A-Z0-9][A-Z0-9-]{2,63}",
re.IGNORECASE)
def parse_category(session, category: dict, tenant: str) -> list[dict]:
resp = session.get(category["url"], headers={"Accept": "text/html"}, timeout=30)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
rows = []
for row in soup.select("tr.item[onclick]"):
match = DETAIL_PATH.search(unescape(row.get("onclick") or ""))
if not match:
continue
url = urljoin(category["url"], match.group(0))
identity = parse_erecruit(url)
if not identity or not identity["job_id"]:
continue
cells = [" ".join(cell.get_text().split()) for cell in row.select("td")]
rows.append({
"id": identity["job_id"],
"title": cells[0] if cells else None,
"category": category["name"],
"url": url,
})
if len(rows) != category["expected"]:
raise RuntimeError(
f"category {category['name']} returned {len(rows)} "
f"of {category['expected']} advertised jobs")
return rows
def scrape_board(session, tenant: str) -> list[dict]:
jobs, seen = [], set()
for category in fetch_categories(session, tenant):
if category["expected"] == 0:
continue
for row in parse_category(session, category, tenant):
if row["id"] not in seen: # a job can sit in several categories
seen.add(row["id"])
jobs.append(row)
time.sleep(0.15)
return jobsimport json
def find_job_posting(html: str) -> dict | None:
soup = BeautifulSoup(html, "html.parser")
for tag in soup.find_all("script", type="application/ld+json"):
try:
data = json.loads(tag.string or "")
except (json.JSONDecodeError, TypeError):
continue
for node in (data if isinstance(data, list) else [data]):
if isinstance(node, dict) and node.get("@type") == "JobPosting":
return node
return None
def fetch_detail(session, listing: dict) -> dict | None:
resp = session.get(listing["url"], headers={"Accept": "text/html"}, timeout=30)
if resp.status_code in (404, 410):
return None
resp.raise_for_status()
# eRecruit renders a first-party "Not found" page for withdrawn requisitions.
soup = BeautifulSoup(resp.text, "html.parser")
if (soup.title and "not found" in soup.title.get_text(strip=True).lower()):
return None
posting = find_job_posting(resp.text)
if not posting:
return None
fields = {}
for row in soup.select("tr.item"):
label = row.select_one("td.label")
value = row.select_one("td.value")
if label and value:
key = " ".join(label.get_text().split()).rstrip(":").lower()
fields[key.replace(" ", "_")] = " ".join(value.get_text().split())
address = ((posting.get("jobLocation") or {}).get("address")) or {}
return {
"id": listing["id"],
"title": posting.get("title") or listing["title"],
"description_html": posting.get("description"),
"employment_type": posting.get("employmentType"),
"posted_at": posting.get("datePosted"),
"closes_at": posting.get("validThrough"),
"city": address.get("addressLocality"),
"region": address.get("addressRegion"),
"fields": fields,
"url": listing["url"],
}- 1Build the inventory from the union of category pages, never from Browse alone
- 2Capture each category's advertised count and verify your row count against it
- 3Deduplicate on the requisition code, since jobs appear in multiple categories
- 4Read detail routes from the row onclick handler after HTML-unescaping it
- 5Treat the first-party Not found page as a removal, not a parse error
- 6Exclude www and other reserved labels when deriving the tenant subdomain
One endpoint. All eRecruit jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=erecruit" \
-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 eRecruit
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.