- highTwo product variants hide behind one brand and behave completely differently.
- Branch on the host before scraping: accesspeople.accessacloud.com uses a path-segment tenant and a JSON grid, while legacy {tenant}.accessacloud.com boards put the tenant in the host label and serve WebForms HTML. Treating both the same way breaks one or the other.
- highThe AccessPeople VacancyPanel POST returns 401/403 without a valid antiforgery pair.
- Bootstrap the tenant root first, then send the __RequestVerificationToken both as a request header and via the matching verification cookie (a requests.Session carries the cookie for you). Re-bootstrap and retry when the token is rejected.
- mediumThe /{tenant}/Home/Vacancy listing route can return HTTP 500 for some tenants.
- Bootstrap from the tenant root /{tenant}/ instead — it serves the same GridParameterObject grid contract, and the canonical /Home/Vacancy?id={id} detail routes remain valid.
- mediumEncrypted document links and marketing pages masquerade as scrapeable tenants.
- Exclude api.my.xd.accessacloud.com document URLs (opaque key1/key2 params resolve no tenant), www.accessacloud.com marketing, and Volcanic career layers that merely link to Access documents. None of these enumerate a public vacancy list.
- lowAccessPeople dates arrive as Microsoft /Date(ms)/ epoch strings.
- Parse the milliseconds out of the /Date(...)/ wrapper for open dates, and convert closing dates to Europe/London end-of-day so UK deadlines don't roll a day early.
Access PeopleHR / rEcruit Jobs API.
Pull live UK vacancies from every Access recruitment tenant on accessacloud.com — the legacy rEcruit WebForms boards and the current AccessPeople JSON grid — normalized into one clean schema.
What's in every response.
Data fields, real-world applications, and the companies already running on Access PeopleHR / rEcruit.
Data fields
- Full Job Descriptions
- Salary Ranges & Free Text
- Contract Type & Basis
- Department & Section
- Open & Closing Dates
- Canonical Apply URLs
Use cases
- 01UK Public Sector Job Aggregation
- 02Education & Academy Trust Hiring Feeds
- 03Salary Benchmarking
- 04Vacancy Monitoring & Alerts
Trusted by
- Apleona
- South Essex College
- Kite Academy Trust
How to scrape Access PeopleHR / rEcruit.
Step-by-step guide to extracting jobs from Access PeopleHR / rEcruit-powered career pages—endpoints, authentication, and working code.
from urllib.parse import urlparse
EXCLUDED_HOSTS = {"www.accessacloud.com", "api.my.xd.accessacloud.com"}
def resolve_target(url: str) -> dict:
parsed = urlparse(url)
host = (parsed.hostname or "").lower()
segments = [s for s in parsed.path.split("/") if s]
# Current AccessPeople: shared host, tenant is the first path segment.
if host == "accesspeople.accessacloud.com":
return {
"variant": "accesspeople",
"root": f"https://{host}",
"tenant": segments[0],
}
# Legacy rEcruit / AccessSelect.rEcruit: tenant is the host label,
# scope is the first path segment ("rEcruit" or "AccessSelect.recruit").
if host.endswith(".accessacloud.com") and host not in EXCLUDED_HOSTS:
return {
"variant": "recruit",
"root": f"https://{host}",
"tenant": host[: -len(".accessacloud.com")],
"scope": segments[0],
}
raise ValueError("URL is not a recognised Access recruitment tenant")import requests
from urllib.parse import urljoin, urlparse, parse_qs
from bs4 import BeautifulSoup
def label_value(card, label: str):
# Legacy rows render as <p><b>Label:</b> value</p>.
for p in card.select("p"):
bold = p.select_one("b, strong")
if bold and bold.get_text(strip=True).rstrip(":").strip().lower() == label.lower():
text = p.get_text(strip=True)
return text.split(":", 1)[1].strip() if ":" in text else text
return None
def scrape_recruit_listings(target: dict) -> list[dict]:
listing_url = f"{target['root']}/{target['scope']}/Recruitment/Vacancies.aspx"
html = requests.get(listing_url, headers={"Accept": "text/html"}, timeout=30).text
soup = BeautifulSoup(html, "html.parser")
container = soup.select_one("ul.vacancyList")
if container is None:
raise ValueError("rEcruit board omitted its vacancyList structure")
jobs = []
for card in container.select("li[id^='ContentPlaceHolder1_RepeaterVacancies_liVacancy_']"):
anchor = card.select_one("h4 a[href*='VacancyDetails.aspx']")
if not anchor or not anchor.get_text(strip=True):
continue
detail_url = urljoin(listing_url, anchor["href"])
vac_no = parse_qs(urlparse(detail_url).query).get("VacNo", [None])[0]
if not vac_no:
continue
jobs.append({
"external_id": vac_no,
"title": anchor.get_text(strip=True),
"listing_url": detail_url,
"apply_url": detail_url,
"reference": label_value(card, "Reference"),
"location": label_value(card, "Location"),
"salary": label_value(card, "Salary"),
"category": label_value(card, "Category"),
})
return jobsimport json
def extract_json_object(text: str, marker: str):
# Faithfully brace-match the first {...} after a JS call marker.
i = text.find(marker)
if i < 0:
return None
i += len(marker)
while i < len(text) and text[i].isspace():
i += 1
if i >= len(text) or text[i] != "{":
return None
depth, in_str, esc = 0, False, False
for j in range(i, len(text)):
ch = text[j]
if in_str:
if esc:
esc = False
elif ch == "\\":
esc = True
elif ch == '"':
in_str = False
continue
if ch == '"':
in_str = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
try:
return json.loads(text[i:j + 1])
except json.JSONDecodeError:
return None
return None
def bootstrap_accesspeople(target: dict) -> dict:
session = requests.Session() # carries the verification cookie automatically
bootstrap_url = f"{target['root']}/{target['tenant']}/"
resp = session.get(bootstrap_url, headers={"Accept": "text/html"}, timeout=30)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
token_input = soup.select_one("input[name='__RequestVerificationToken']")
token = token_input["value"].strip() if token_input and token_input.get("value") else None
if not token:
raise ValueError("AccessPeople bootstrap omitted its request verification token")
grid = extract_json_object(resp.text, "GridParameterObject(")
if grid is None or not grid.get("Key") or not grid.get("ActionUrl"):
raise ValueError("AccessPeople bootstrap omitted the vacancy grid contract")
return {
"session": session,
"bootstrap_url": bootstrap_url,
"token": token,
"key": grid["Key"], # GUID
"panel": grid["UniqueName"],
"action_url": urljoin(bootstrap_url, grid["ActionUrl"]), # /{tenant}/Home/VacancyPanel?view=VacancyPanel...
"sort_order": grid.get("DefaultSortDirection", "Desc"),
"sort_name": grid.get("SortBy", "ClosingDate"),
"columns": [c["FieldName"] for c in grid.get("FlexigridColumns", []) if c.get("FieldName")],
}def scrape_accesspeople_listings(target: dict, session_info: dict, page_size: int = 100) -> list[dict]:
s = session_info["session"]
jobs, page = [], 1
while True:
body = {
"PanelName": session_info["panel"],
"key": session_info["key"],
"page": page,
"rp": page_size,
"qtype": None, "query": None,
"sortorder": session_info["sort_order"],
"sortname": session_info["sort_name"],
"total": None, "StartDate": None, "EndDate": None,
"PageWidth": 1200,
"gridColumns": [
{"FieldName": f, "SearchText": None, "SelectedItem": None, "Options": []}
for f in session_info["columns"]
],
"treeParams": [],
}
resp = s.post(
session_info["action_url"],
json=body,
headers={
"Accept": "application/json",
"Referer": session_info["bootstrap_url"],
"X-Requested-With": "XMLHttpRequest",
"__RequestVerificationToken": session_info["token"],
},
timeout=30,
)
resp.raise_for_status() # 401/403 => re-bootstrap; token or cookie was rejected
payload = resp.json().get("Json") or {}
params = payload.get("Parameters") or {}
results = payload.get("Results") or []
total = params.get("total")
if payload.get("success") is not True or total is None:
raise ValueError("AccessPeople VacancyPanel omitted success or total")
for row in results:
if not row.get("Id") or not row.get("Description"):
continue
vid = str(row["Id"])
tenant = target["tenant"]
jobs.append({
"external_id": vid,
"title": row["Description"].strip(),
"reference": row.get("Reference"),
"salary": row.get("SalaryDescription") or row.get("SalaryFreeText"),
"listing_url": f"{target['root']}/{tenant}/Home/Vacancy?id={vid}&returnurl=/{tenant}/",
"apply_url": f"{target['root']}/{tenant}/Application/Apply/{vid}?ApplicationId=0",
})
# Defensive termination: stop when this page completes the reported total.
if not results or (page - 1) * page_size + len(results) >= total:
break
page += 1
return jobsdef labeled_control(form, label: str):
if form is None:
return None
for li in form.select("li"):
lbl = li.find(class_="label", recursive=False)
ctrl = li.find(class_="control", recursive=False)
if lbl and ctrl and lbl.get_text(strip=True).lower() == label.lower():
return ctrl.get_text(strip=True) or None
return None
def scrape_detail(listing_url: str) -> dict:
html = requests.get(listing_url, headers={"Accept": "text/html"}, timeout=30).text
soup = BeautifulSoup(html, "html.parser")
# Current AccessPeople canonical detail.
container = soup.select_one(".vacancy-container")
if container is not None:
structured = extract_json_object(html, "GoogleJobsData(") or {}
heading = container.select_one("h3")
body = container.select_one(".vacancy-description-html")
return {
"title": heading.get_text(strip=True) if heading else None,
"description_html": body.decode_contents() if body else None,
"department": structured.get("DepartmentString"),
"contract_type": structured.get("ContractTypeDescription"),
"salary": structured.get("SalaryFreeText"),
"location": structured.get("Location") or structured.get("SectionAddressPostCode"),
"closes_at": structured.get("ClosingDate"),
}
# Legacy rEcruit WebForms detail.
form = soup.select_one("#ContentPlaceHolder1_ulForm")
body = soup.select_one("#ContentPlaceHolder1_PlaceHolderAdvertText .control")
return {
"title": labeled_control(form, "Advert Title"),
"description_html": body.decode_contents() if body else None,
"location": labeled_control(form, "Location"),
"reference": labeled_control(form, "Reference"),
}- 1Branch on the host: path-segment tenant for accesspeople.accessacloud.com, host-label tenant for legacy *.accessacloud.com boards.
- 2Reuse the bootstrap token and cookie for every VacancyPanel POST in a session; re-bootstrap when a 401/403 appears.
- 3Page the VacancyPanel with rp=100 and stop once the accumulated offset covers Parameters.total.
- 4Validate each detail's structured ID (GoogleJobsData Id or legacy VacNo) against the listing ID before trusting the page.
- 5Throttle to ~150ms between requests and cap concurrent detail fetches at 3.
- 6Exclude api.my.xd.accessacloud.com document links and Access marketing hosts from your target list.
One endpoint. All Access PeopleHR / rEcruit jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=access peoplehr / recruit" \
-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 Access PeopleHR / rEcruit
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.