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.
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.
What's in every response.
Data fields, real-world applications, and the companies already running on Salesforce Sites fRecruit.
- Full Job Descriptions
- Employment Type
- Work Locations
- Department & Team
- Posting & Closing Dates
- Vacancy Reference Numbers
- 01Job board aggregation
- 02Careers page monitoring
- 03Talent market tracking
- 04Recruiting data pipelines
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))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.
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.
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.
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.
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.
- 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" Access Salesforce Sites fRecruit
job data today.
One API call. Structured data. No scraping infrastructure to build or maintain — start with the free tier and scale as you grow.