- highJob IDs change whenever a title is edited
- The path is /jobs/{numericId}-{slug} and the slug tracks the title. Keying on the whole path segment creates a duplicate record every time a recruiter rewords a heading. Take only the leading numeric run as the identifier and discard the slug.
- highPagination walks off the board
- The rel=next control sometimes resolves to a filtered or unrelated route rather than the tenant's /jobs/search path. Validate every candidate URL against the tenant and the search path before following it, and stop the crawl instead of drifting onto another portal.
- mediumA redirect renders as an empty board
- Retired portals redirect to a marketing page that parses cleanly and yields zero rows, which reads downstream as every job closing at once. Require the connect.healthcaretalentsource.com fingerprint on each page and fail the run when it is missing.
- lowThe location is missing on some records
- The location sits in a single detail-list span that recruiters sometimes leave unset, and the muted sibling spans hold labels rather than values. Exclude the muted class when reading it, and fall back to the listing row instead of writing an empty string.
Healthcare Talent Source Jobs API.
Healthcare Talent Source runs recruitment portals for hospitals and health systems on hctsportals.com. Each tenant serves a paginated search page whose rows link to fully rendered job records.
What's in every response.
Data fields, real-world applications, and the companies already running on Healthcare Talent Source.
Data fields
- Full Job Descriptions
- Numeric Job IDs
- Facility Locations
- Paginated Search
- Direct Apply URLs
- First-Party Page Proof
Use cases
- 01Hospital Job Aggregation
- 02Clinical Recruitment Feeds
- 03Health System Hiring Trackers
- 04Careers Page Monitoring
Trusted by
- Waterbury
- CKHS
- National Jewish
How to scrape Healthcare Talent Source.
Step-by-step guide to extracting jobs from Healthcare Talent Source-powered career pages—endpoints, authentication, and working code.
import re
from urllib.parse import urlparse, unquote
SUFFIX = ".hctsportals.com"
JOB_PATH = re.compile("^([0-9]+)(?:-|$)")
TENANT = re.compile("^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$", re.IGNORECASE)
def parse_hcts(url: str) -> dict | None:
parsed = urlparse(url)
host = parsed.netloc.lower().rstrip(".")
if not host.endswith(SUFFIX):
return None
tenant = host[: -len(SUFFIX)]
if tenant in ("www", "") or "." in tenant or not TENANT.match(tenant):
return None
segments = [s for s in parsed.path.split("/") if s]
job_id = None
if segments:
if segments[0] != "jobs" or len(segments) > 2:
return None
if len(segments) == 2 and segments[1] != "search":
match = JOB_PATH.match(unquote(segments[1]))
if not match:
return None
job_id = match.group(1)
return {"tenant": tenant, "job_id": job_id,
"board_url": f"https://{tenant}.hctsportals.com/jobs/search"}
print(parse_hcts("https://careers-waterbury.hctsportals.com/jobs/2191162"))import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin
import time
FINGERPRINT = "connect.healthcaretalentsource.com"
def is_same_search(candidate: str, tenant: str) -> bool:
identity = parse_hcts(candidate)
parsed = urlparse(candidate)
return bool(identity and identity["tenant"] == tenant
and parsed.path.lower() == "/jobs/search")
def scrape_board(session, tenant: str, max_pages: int = 25) -> list[dict]:
url = f"https://{tenant}.hctsportals.com/jobs/search"
jobs, seen = [], set()
for _ in range(max_pages):
resp = session.get(url, headers={"Accept": "text/html"}, timeout=30)
resp.raise_for_status()
if FINGERPRINT not in resp.text:
raise RuntimeError("page omitted the Healthcare Talent Source fingerprint")
soup = BeautifulSoup(resp.text, "html.parser")
for row in parse_rows(soup, url, tenant):
if row["id"] not in seen:
seen.add(row["id"])
jobs.append(row)
nxt = soup.select_one("a.next_page[rel='next']")
if not nxt or not nxt.get("href"):
break
candidate = urljoin(url, nxt["href"])
if not is_same_search(candidate, tenant):
break
url = candidate
time.sleep(0.1)
return jobsfrom urllib.parse import urlsplit, urlunsplit
def canonical(url: str) -> str:
parts = urlsplit(url)
return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
def parse_rows(soup: BeautifulSoup, page_url: str, tenant: str) -> list[dict]:
rows = []
for item in soup.select(".jobs-section__item"):
anchor = item.select_one("a[href*='/jobs/']")
if not anchor or not anchor.get("href"):
continue
detail_url = canonical(urljoin(page_url, anchor["href"]))
identity = parse_hcts(detail_url)
title = " ".join(anchor.get_text().split())
if not identity or not identity["job_id"] or not title:
continue
if identity["tenant"] != tenant:
continue
rows.append({
"id": identity["job_id"],
"title": title,
"url": detail_url,
"apply_url": detail_url,
})
return rows
session = requests.Session()
listings = scrape_board(session, "careers-waterbury")
print(f"{len(listings)} open jobs")def fetch_detail(session, listing: dict) -> dict | None:
resp = session.get(listing["url"], headers={"Accept": "text/html"}, timeout=30)
if resp.status_code in (404, 410):
return None # job removed
resp.raise_for_status()
body_html = resp.text
if FINGERPRINT not in body_html:
return None
# The page publishes its own route; require it to match the job we requested.
marker = 'tm_vars.search_url = "/jobs/' + listing["id"] + '"'
if marker not in body_html:
return None
soup = BeautifulSoup(body_html, "html.parser")
heading = soup.select_one("#job h1")
body = soup.select_one("#job .job__details-copy")
if not heading or not body:
return None
location = soup.select_one("#job .job__details-list li span:not(.text-muted)")
return {
"id": listing["id"],
"title": " ".join(heading.get_text().split()),
"description_html": body.decode_contents().strip(),
"location": (" ".join(location.get_text().split())
if location else None),
"url": listing["url"],
"apply_url": listing["url"],
}
for listing in listings[:3]:
print(fetch_detail(session, listing))
time.sleep(0.1)- 1Take the leading numeric run of the path segment as the job identifier
- 2Require the platform fingerprint on every listing and detail page
- 3Follow rel=next only while it stays on the tenant's /jobs/search path
- 4Strip query strings from detail URLs so stored links stay stable
- 5Verify the page's own route variable names the job you requested
- 6Throttle to ~100ms between requests with at most three concurrent detail fetches
One endpoint. All Healthcare Talent Source jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=healthcare talent source" \
-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 Healthcare Talent Source
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.