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.

Get API access

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

  1. 01Staffing & Recruiting Agency Feeds
  2. 02Enterprise Job Aggregation
  3. 03Salesforce Careers Site Extraction
  4. 04ATS Data Pipelines

Trusted by

  • Radial
  • DC Public Schools
  • Cirque du Soleil
DIY GUIDE

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.

API type
HTML
Difficulty
intermediate
Rate limit
No published limit; ~300ms between requests and at most 2 concurrent detail fetches
Authentication
No auth

Identify the site, package and tenant

Jobscience boards live on Salesforce Sites at {tenant}.my.salesforce-sites.com, with an optional site path in front of the Visualforce page. Two managed-package namespaces are in the wild — ts2 and ts2mmx — and the board is always {package}__JobSearch while a posting is {package}__JobDetails with a jobId query parameter.

Step 1: Identify the site, package and tenant
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"])

Fetch the board and confirm the package artifacts

The JobSearch page renders the whole anonymous snapshot in one server-rendered response — the RichFaces calls behind the search controls only mutate state and expose no complete listing API. Confirm the response carries Jobscience package artifacts before parsing, so a redirect to a generic Salesforce Sites page is not read as an empty board.

Step 2: Fetch the board and confirm the package artifacts
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"])

Read the results table by header name, not column position

Vacancies are anchors pointing at {package}__JobDetails with a jobId — that id is the Salesforce record id and is the job's identifier. Column layouts differ per tenant, so build a header-name to index map from the table head and look up location, posted date and job number by name instead of by position.

Step 3: Read the results table by header name, not column position
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")

Read the detail page's label and value pairs

The JobDetails page renders its fields as table rows where atsJobDetailsTdLeft is the label and atsJobDetailsTdRight is the value. Normalise each label to a bare key so tenant-specific wording still matches, and take the description from the largest job-description block on the page.

Step 4: Read the detail page's label and value pairs
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"])
Common issues
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.
Best practices
  1. 1Detect the ts2 or ts2mmx package prefix from the page name and keep it for every URL you build
  2. 2Preserve the optional site path — the same Salesforce host can serve several boards
  3. 3Use the jobId query parameter, the Salesforce record id, as the job identifier
  4. 4Map results-table columns by normalised header name rather than position
  5. 5Require Jobscience package artifacts on both board and detail pages before parsing
  6. 6Take the description from the largest qualifying block and ignore short chrome elements
Or skip the complexity

One endpoint. All Jobscience / Talent Rover jobs. No scraping, no sessions, no maintenance.

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=jobscience / talent rover" \
  -H "X-Api-Key: YOUR_KEY"
Developer tools

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.

Ready to integrate

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.

99.9%API uptime
<200msAvg response
50M+Jobs processed