- highWhy does my crawler loop forever on a TalentNest board?
- Drive pagination only from a.next_page[rel=next] and stop as soon as it is absent or carries the disabled class. Add a page bound and a seen-ID set as defensive guards so a board that keeps rendering the same next link cannot spin the crawler.
- highWhy does a detail page belong to a different job than the row?
- Confirm identity twice before storing anything: the page's og:url must name the same tenant and posting ID, and the JobPosting JSON-LD identifier.value must equal the numeric job ID from the row. A disagreement is a hard failure, not a field to overwrite.
- mediumWhen is a TalentNest job actually removed?
- Only a canonical HTTP 404 or 410 on the /{locale}/posting/{id} URL is removal evidence. A redirect, an empty board page, or a detail page missing JSON-LD is a scrape failure — expiring jobs from those states deletes postings that are still live.
- lowWhy do some listing rows fail to resolve to a job ID?
- Rows are located by .job-row[data-job-row-url], and a row whose URL does not match the /{locale}/posting/{numericId} shape cannot be attributed. Count those as rejections and mark the snapshot incomplete rather than dropping them quietly.
TalentNest Jobs API.
Pull every posting from a TalentNest employer board by walking its server-rendered job rows and provider-owned next-page link, then reading JobPosting JSON-LD from each detail page.
What's in every response.
Data fields, real-world applications, and the companies already running on TalentNest.
Data fields
- Full Job Descriptions
- JobPosting JSON-LD
- Numeric Posting IDs
- Server-Rendered Pagination
- Bilingual Board Locales
- Canonical og:url Proof
Use cases
- 01Canadian Job Aggregation
- 02SMB Careers Page Monitoring
- 03Hospitality & Retail Hiring Feeds
- 04ATS Data Pipelines
Trusted by
- 5 Corners
- AAS
- Alyeska Resort
How to scrape TalentNest.
Step-by-step guide to extracting jobs from TalentNest-powered career pages—endpoints, authentication, and working code.
from urllib.parse import urlparse
HOST_SUFFIX = ".talentnest.com"
def parse_talentnest(url: str) -> dict:
parsed = urlparse(url)
host = parsed.netloc.lower()
if not host.endswith(HOST_SUFFIX):
raise ValueError("not a TalentNest host")
tenant = host[: -len(HOST_SUFFIX)]
if not tenant or "." in tenant:
raise ValueError("unexpected TalentNest host shape")
parts = [p for p in parsed.path.strip("/").split("/") if p]
job_id = None
if len(parts) == 3 and parts[1] == "posting" and parts[2].isdigit():
job_id = parts[2]
elif len(parts) == 2 and parts[0] == "posting" and parts[1].isdigit():
job_id = parts[1]
return {
"tenant": tenant,
"job_id": job_id,
"board_url": f"https://{tenant}{HOST_SUFFIX}/en",
}
print(parse_talentnest("https://5corners.talentnest.com/en/posting/239775"))
# {'tenant': '5corners', 'job_id': '239775', 'board_url': 'https://5corners.talentnest.com/en'}import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin
session = requests.Session()
session.headers["Accept"] = "text/html,application/xhtml+xml"
def parse_rows(board_url: str, html: str) -> list[dict]:
soup = BeautifulSoup(html, "html.parser")
rows = []
for row in soup.select(".job-row[data-job-row-url]"):
href = row.get("data-job-row-url")
detail = urljoin(board_url + "/", href)
identity = parse_talentnest(detail)
if not identity["job_id"]:
continue # a row that does not resolve is a rejection
rows.append({
"job_id": identity["job_id"],
"title": " ".join(row.get_text(" ", strip=True).split())[:120],
"listing_url": detail,
})
return rows
board = parse_talentnest("https://5corners.talentnest.com/en")
first = session.get(board["board_url"], timeout=30)
first.raise_for_status()
print(len(parse_rows(board["board_url"], first.text)), "rows on page 1")def crawl_board(board: dict, max_pages: int = 100) -> list[dict]:
collected, seen = [], set()
page_url = board["board_url"]
for _ in range(max_pages):
resp = session.get(page_url, timeout=30)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
for row in parse_rows(board["board_url"], resp.text):
if row["job_id"] not in seen:
seen.add(row["job_id"])
collected.append(row)
nxt = soup.select_one("a.next_page[rel='next']:not(.disabled)")
if nxt is None or not nxt.get("href"):
return collected
candidate = urljoin(board["board_url"] + "/", nxt["href"])
if parse_talentnest(candidate)["tenant"] != board["tenant"]:
raise RuntimeError("next-page link escaped the employer board")
page_url = candidate
raise RuntimeError("TalentNest pagination exceeded its page bound")
listings = crawl_board(board)
print(f"{len(listings)} postings")import json
import time
def find_job_posting(soup) -> dict | None:
for tag in soup.find_all("script", type="application/ld+json"):
try:
data = json.loads(tag.string or "")
except (json.JSONDecodeError, TypeError):
continue
for node in (data if isinstance(data, list) else [data]):
if isinstance(node, dict) and node.get("@type") == "JobPosting":
return node
return None
def hydrate(board: dict, row: dict) -> dict | None:
canonical = f"{board['board_url']}/posting/{row['job_id']}"
resp = session.get(canonical, timeout=30)
if resp.status_code in (404, 410):
return None # the only removal evidence TalentNest gives
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
og_url = (soup.select_one("meta[property='og:url']") or {}).get("content", "")
if parse_talentnest(og_url)["job_id"] != row["job_id"]:
raise RuntimeError("og:url contradicted the requested posting")
posting = find_job_posting(soup)
if not posting:
raise RuntimeError("detail page carried no JobPosting JSON-LD")
identifier = (posting.get("identifier") or {}).get("value")
if str(identifier) != row["job_id"]:
raise RuntimeError("JSON-LD identifier contradicted the requested posting")
return {
"job_id": row["job_id"],
"title": posting.get("title"),
"description_html": posting.get("description"),
"posted_at": posting.get("datePosted"),
"employment_type": posting.get("employmentType"),
"listing_url": canonical,
}
for row in listings[:3]:
print(hydrate(board, row)["title"])
time.sleep(0.15)- 1Take the employer identity from the leading host label, never from a job title
- 2Read detail URLs from data-job-row-url instead of rebuilding them from slugs
- 3Follow a.next_page[rel=next] and verify each next URL stays on the same tenant
- 4Require both og:url and the JSON-LD identifier to match before hydrating a job
- 5Treat only canonical 404/410 responses as removals
- 6Throttle to roughly 150ms between requests with at most three concurrent details
One endpoint. All TalentNest jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=talentnest" \
-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 TalentNest
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.