- highPagination never advances past page one
- The Next link is a jsfcljs() JSF postback tied to the form's hidden ViewState, not a page-number URL. Collect every form input plus the field/value in the onclick and POST them url-encoded to the form action with Referer and Origin headers, reusing one session so cookies persist.
- highColumns like location or department come back wrong
- fRecruit lets each tenant rename and reorder its table columns, so fixed positions can mislabel a posting date as a location. Map cells by the th header text (or the responsive data-label) and only fall back to fixed positions when the table has no header row.
- mediumDetail page exposes no JSON-LD JobPosting
- Only some tenants embed schema.org JSON-LD; older deployments render labelled Visualforce rows instead. Try the JSON-LD first, then fall back to the .labelCol -> .data2Col/.dataCol field map keyed on labels like Vacancy Name, Role Details, Employment Type, and Location.
- mediumA salesforce-sites.com host is not a job board
- The same *.my.salesforce-sites.com domain also serves non-recruiting community pages. Only treat hosts whose path is under /recruit/ and contains fRecruit__ApplyJob (or the bare /recruit/) as fRecruit boards before scraping.
- lowVacancies drop out with thin or empty descriptions
- fRecruit records need at least 80 characters of substantive role text; title-only rows are skipped. Enforce the same 80-character minimum, and treat a 404 or 410 on a detail fetch as a removed vacancy rather than a hard error.
Salesforce Sites fRecruit Jobs API.
Pull structured vacancies from fRecruit careers portals hosted on Salesforce Sites, where jobs render as server-side Visualforce tables behind JSF postback pagination — Jobo normalizes them into one clean jobs feed.
What's in every response.
Data fields, real-world applications, and the companies already running on Salesforce Sites fRecruit.
Data fields
- Full Job Descriptions
- Employment Type
- Work Locations
- Department & Team
- Posting & Closing Dates
- Vacancy Reference Numbers
Use cases
- 01Job board aggregation
- 02Careers page monitoring
- 03Talent market tracking
- 04Recruiting data pipelines
Trusted by
- Brit Insurance
- Immunocore
- Acora
- Compugen
How to scrape Salesforce Sites fRecruit.
Step-by-step guide to extracting jobs from Salesforce Sites fRecruit-powered career pages—endpoints, authentication, and working code.
import requests
tenant = "peoplecore" # the {tenant}.my.salesforce-sites.com subdomain
portal = None # optional ?portal= value, e.g. "Brit Careers"
origin = f"https://{tenant}.my.salesforce-sites.com"
board_url = f"{origin}/recruit/fRecruit__ApplyJobList"
if portal:
board_url += "?portal=" + requests.utils.quote(portal)
session = requests.Session()
session.headers["User-Agent"] = "Mozilla/5.0 (compatible; jobs-bot/1.0)"
html = session.get(board_url, timeout=20).textimport re
from urllib.parse import urljoin, urlparse, parse_qs
from bs4 import BeautifulSoup
def _norm(text):
return re.sub(r"[^a-z0-9]", "", (text or "").lower())
def build_header_index(table):
headers = {}
header_row = next((tr for tr in table.select("tr") if tr.find("th")), None) if table else None
if header_row:
for i, th in enumerate(header_row.select("th")):
key = _norm(th.get_text())
if key:
headers.setdefault(key, i)
return headers
def read_cell(cells, headers, labels, fallback):
# Responsive fRecruit tables carry the column name on data-label.
for cell in cells:
if _norm(cell.get("data-label")) in labels:
return cell.get_text(strip=True) or None
# Otherwise map by the <th> header index.
for key, idx in headers.items():
if any(key == l or key.startswith(l) for l in labels) and idx < len(cells):
return cells[idx].get_text(strip=True) or None
# Fixed column order is only safe when the table exposes no headers.
if not headers and 0 <= fallback < len(cells):
return cells[fallback].get_text(strip=True) or None
return None
def parse_listings(html, base_url):
soup = BeautifulSoup(html, "html.parser")
jobs = []
for row in soup.select("tr.dataRow"):
anchor = row.select_one("a[href*='fRecruit__ApplyJob'][href*='vacancyNo=']")
if not anchor:
continue
detail_url = urljoin(base_url, anchor.get("href", ""))
vacancy_no = parse_qs(urlparse(detail_url).query).get("vacancyNo", [None])[0]
title = anchor.get_text(strip=True)
if not vacancy_no or not title:
continue
cells = row.select("td.dataCell")
headers = build_header_index(row.find_parent("table"))
jobs.append({
"vacancy_no": vacancy_no,
"title": title,
"detail_url": detail_url,
"employment_type": read_cell(cells, headers,
{"employmenttype", "contracttype", "jobtype", "type"}, 1),
"location": read_cell(cells, headers,
{"worklocation", "joblocation", "locationofrole", "location"}, 2),
"department": read_cell(cells, headers,
{"department", "team", "function"}, 3),
"posting_date": read_cell(cells, headers,
{"postingdate", "dateposted", "posteddate", "posted"}, -1),
})
return jobs
jobs = parse_listings(html, board_url)
print(f"Found {len(jobs)} vacancies")POSTBACK = re.compile(
r"jsfcljs\(document\.getElementById\('(?P<form>[^']+)'\),'(?P<field>[^,']+),(?P<value>[^']*)'",
re.IGNORECASE)
def build_next_post(soup, base_url):
nxt = next((a for a in soup.select("a.link-pagination")
if a.get_text(strip=True).lower() == "next"), None)
if not nxt or not nxt.get("onclick"):
return None
m = POSTBACK.search(nxt["onclick"])
if not m:
return None
form = soup.find("form", id=m.group("form"))
if form is None:
return None
fields = {}
for inp in form.select("input[name]"):
itype = (inp.get("type") or "").lower()
if itype in ("checkbox", "radio") and not inp.has_attr("checked"):
continue
fields[inp["name"]] = inp.get("value", "")
fields[m.group("field")] = m.group("value")
action = urljoin(base_url, form.get("action") or base_url)
return action, fields
all_jobs = list(jobs)
soup = BeautifulSoup(html, "html.parser")
while True:
nxt = build_next_post(soup, board_url)
if not nxt:
break
action_url, fields = nxt
parts = urlparse(board_url)
resp = session.post(action_url, data=fields, timeout=20, headers={
"Referer": board_url,
"Origin": f"{parts.scheme}://{parts.netloc}",
"Content-Type": "application/x-www-form-urlencoded",
})
resp.raise_for_status()
page_jobs = parse_listings(resp.text, board_url)
if not page_jobs: # defensive: no new vacancyNo rows -> stop
break
all_jobs.extend(page_jobs)
soup = BeautifulSoup(resp.text, "html.parser")import json
def _plain_len(value):
return len(re.sub(r"<[^>]+>", "", value or ""))
def parse_details(html, listing):
soup = BeautifulSoup(html, "html.parser")
# Preferred: schema.org JobPosting JSON-LD, embedded by some tenants.
for tag in soup.find_all("script", type="application/ld+json"):
try:
data = json.loads(tag.string or "")
except (TypeError, ValueError):
continue
for node in (data if isinstance(data, list) else [data]):
if isinstance(node, dict) and node.get("@type") == "JobPosting":
desc = node.get("description", "")
if _plain_len(desc) >= 80:
org = node.get("hiringOrganization") or {}
return {
"vacancy_no": listing["vacancy_no"],
"title": node.get("title") or listing["title"],
"description": desc,
"employment_type": node.get("employmentType"),
"posted_at": node.get("datePosted"),
"closes_at": node.get("validThrough"),
"company_name": org.get("name") if isinstance(org, dict) else None,
}
# Fallback: labelled Visualforce field rows.
fields = {}
for row in soup.select("tr"):
label = row.select_one(".labelCol")
value = row.select_one(".data2Col, .dataCol, .dataCell")
if label and value:
fields[label.get_text(strip=True).rstrip(":")] = value
def first_text(*names):
for n in names:
v = fields.get(n)
if v and v.get_text(strip=True):
return v.get_text(strip=True)
return None
description = None
for name in ("Role Details", "Description", "Job Description", "Key Responsibilities"):
v = fields.get(name)
if v and len(v.get_text(strip=True)) >= 80:
description = v.decode_contents()
break
if not description:
raise ValueError("fRecruit detail omitted substantive role details")
return {
"vacancy_no": listing["vacancy_no"],
"title": first_text("Vacancy Name") or listing["title"],
"description": description,
"employment_type": first_text("Employment Type"),
"location": first_text("Location", "Location of role", "Work Location"),
}
for job in all_jobs[:3]:
detail_html = session.get(job["detail_url"], timeout=20).text
print(parse_details(detail_html, job))- 1Persist one requests.Session so Salesforce Sites cookies carry across the JSF pagination POSTs
- 2Map listing columns by their th header or data-label, never by fixed position — fRecruit tenants reorder columns
- 3Prefer JSON-LD JobPosting on the detail page and fall back to labelled Visualforce fields
- 4Space requests ~300ms apart and keep concurrent detail fetches to 2 to stay polite
- 5Terminate pagination when a page returns no new vacancyNo rows
- 6Treat 404/410 on a vacancy detail as a removal signal, not a failure
One endpoint. All Salesforce Sites fRecruit jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=salesforce sites frecruit" \
-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 Salesforce Sites fRecruit
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.