- highOnly one package namespace is handled
- Boards ship under both ts2__ and ts2mmx__, and a scraper that matches only ts2__JobSearch silently misses every Talent Rover Media Exchange tenant. Detect the prefix from the page name and reuse it for both the board and detail URLs.
- highColumn positions differ from tenant to tenant
- Each customer configures its own results table, so reading location or posted date by column index puts the wrong value in the wrong field. Build a header-name to index map from the table head and look up every column by its normalised name.
- mediumAn empty board is indistinguishable from a broken page
- Check for the Jobscience package artifacts before concluding anything. A page with those artifacts and a visible 'no open positions' message is a genuinely empty board, while a page without them is a redirect or an error and must not be recorded as zero jobs.
- mediumThe description picks up navigation chrome
- Several elements on the detail page can match a description selector. Collect the candidate blocks, drop anything under about eighty characters of text, and keep the longest — that reliably lands on the vacancy body rather than a sidebar or a breadcrumb.
Jobscience / Talent Rover Jobs API.
Extract vacancies from Jobscience and Talent Rover career sites, the Salesforce-managed job boards served as Visualforce pages under the ts2 and ts2mmx package namespaces.
What's in every response.
Data fields, real-world applications, and the companies already running on Jobscience / Talent Rover.
Data fields
- Full Job Descriptions
- Job Numbers
- Employment Type
- Location Columns
- Salesforce Record IDs
- Posted Dates
Use cases
- 01Staffing & Recruiting Agency Feeds
- 02Enterprise Job Aggregation
- 03Salesforce Careers Site Extraction
- 04ATS Data Pipelines
Trusted by
- Radial
- DC Public Schools
- Cirque du Soleil
How to scrape Jobscience / Talent Rover.
Step-by-step guide to extracting jobs from Jobscience / Talent Rover-powered career pages—endpoints, authentication, and working code.
from urllib.parse import urlparse, parse_qs
SUFFIX = ".my.salesforce-sites.com"
def parse_url(url: str) -> dict | None:
parsed = urlparse(url)
host = parsed.netloc.lower()
if parsed.scheme != "https" or not host.endswith(SUFFIX):
return None
path = parsed.path
page = path.rsplit("/", 1)[-1]
package = next((p for p in ("ts2mmx", "ts2") if page.lower().startswith(f"{p}__")), None)
if package is None or page.lower() not in (f"{package}__jobsearch", f"{package}__jobdetails"):
return None
site_path = path[: path.rfind("/")] if "/" in path.strip("/") + "/" else ""
return {
"tenant": host.split(".")[0],
"package": package,
"site_path": site_path,
"board_url": f"https://{host}{site_path}/{package}__JobSearch",
"job_id": (parse_qs(parsed.query).get("jobId") or [None])[0],
}
board = parse_url("https://radial.my.salesforce-sites.com/careers/ts2__JobSearch")
print(board["tenant"], board["package"], board["board_url"])import re
import requests
ARTIFACTS = re.compile(r"(?:ts2|ts2mmx)__Job(?:Search|Details)|Jobscience|atsSearchResultsTable", re.I)
def fetch_board(session: requests.Session, board_url: str) -> str:
response = session.get(
board_url, headers={"Accept": "text/html,application/xhtml+xml"}, timeout=30
)
response.raise_for_status()
if not ARTIFACTS.search(response.text):
raise RuntimeError("Page did not contain Jobscience package artifacts")
return response.text
session = requests.Session()
html = fetch_board(session, board["board_url"])from urllib.parse import urljoin
from bs4 import BeautifulSoup
def normalize(value: str) -> str:
return re.sub(r"[^a-z0-9]", "", (value or "").strip().lower())
def header_map(table) -> dict:
headers = {}
if table:
cells = table.select("thead th") or table.select("tr:first-child th")
for index, cell in enumerate(cells):
key = normalize(cell.get_text())
headers.setdefault(key, index)
return headers
def read_cell(cells, headers: dict, names: list[str]) -> str | None:
for key, index in headers.items():
if any(key == n or n in key for n in names) and index < len(cells):
return " ".join(cells[index].get_text().split()) or None
return None
def parse_listings(html: str, board: dict) -> list[dict]:
soup = BeautifulSoup(html, "html.parser")
selector = (
"a[href*='ts2__JobDetails'][href*='jobId='], "
"a[href*='ts2mmx__JobDetails'][href*='jobId=']"
)
listings = []
for anchor in soup.select(selector):
detail_url = urljoin(board["board_url"], anchor["href"])
parsed = parse_url(detail_url)
title = " ".join(anchor.get_text().split())
if not parsed or not parsed["job_id"] or not title:
continue
row = anchor.find_parent("tr")
cells = row.find_all("td") if row else []
headers = header_map(row.find_parent("table") if row else None)
listings.append({
"id": parsed["job_id"],
"title": title,
"listing_url": detail_url,
"location": read_cell(cells, headers, ["location", "office", "citystate"]),
"posted_at": read_cell(cells, headers, ["dateposted", "posteddate"]),
"job_number": read_cell(cells, headers, ["jobnumber", "jobno"]),
})
return listings
listings = parse_listings(html, board)
print(f"{len(listings)} vacancies")LABEL = ".atsJobDetailsTdLeft, th, .labelCol"
VALUE = ".atsJobDetailsTdRight, .data2Col, .dataCol"
def fetch_detail(session: requests.Session, listing: dict) -> dict | None:
response = session.get(listing["listing_url"], timeout=30)
if response.status_code in (404, 410):
return None # canonical removal
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
if not ARTIFACTS.search(response.text):
raise RuntimeError("Page did not contain Jobscience detail artifacts")
fields = {}
for row in soup.select("tr"):
label, value = row.select_one(LABEL), row.select_one(VALUE)
key = normalize(label.get_text()) if label else ""
text = " ".join(value.get_text().split()) if value else ""
if key and text:
fields.setdefault(key, text)
blocks = soup.select(
".atsJobDetailsTdTwoColumn, .atsJobDescription, [class*='jobDescription']"
)
blocks = [b for b in blocks if len(b.get_text(strip=True)) >= 80]
body = max(blocks, key=lambda b: len(b.get_text()), default=None)
if body is None:
raise RuntimeError("Jobscience detail omitted its substantive description")
return {
**listing,
"title": fields.get("jobtitle") or fields.get("positiontitle") or listing["title"],
"description_html": body.decode_contents().strip(),
"job_number": fields.get("jobnumber") or fields.get("jobno") or listing["job_number"],
"employment_type": fields.get("employmenttype") or fields.get("jobtype"),
"location": next(
(v for k, v in fields.items() if "location" in k or k in ("office", "citystate")),
listing["location"],
),
}
for listing in listings[:3]:
job = fetch_detail(session, listing)
if job:
print(job["title"], "-", job["location"])- 1Detect the ts2 or ts2mmx package prefix from the page name and keep it for every URL you build
- 2Preserve the optional site path — the same Salesforce host can serve several boards
- 3Use the jobId query parameter, the Salesforce record id, as the job identifier
- 4Map results-table columns by normalised header name rather than position
- 5Require Jobscience package artifacts on both board and detail pages before parsing
- 6Take the description from the largest qualifying block and ignore short chrome elements
One endpoint. All Jobscience / Talent Rover jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=jobscience / talent rover" \
-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 Jobscience / Talent Rover
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.