- criticalWhy does a removed job still return HTTP 200?
- OpenHire answers a pulled posting with 200 and a status-changed page, so status code alone keeps dead jobs alive forever. Accept removal only when the element #jobStatusChangeDiv is present and #jobTitleDiv is absent; a generic empty page is a parse error, not removal.
- highWhy does the same tenant show completely different jobs?
- The version query parameter selects a posting portal, not a schema revision. One audited tenant serves an English board on version 1 and a Spanish-language board with three entirely different jobs on version 2. Scope every snapshot by version, or one portal will expire the other's postings.
- highWhy do location and tracking-code columns come back empty?
- Column order varies per tenant and the visible header text is translated on non-English boards. Build a column index from the header anchor IDs (header_trackingCode, header_jobTitle, header_location), which stay English, instead of assuming fixed table positions.
- mediumIs there a JSON or RSS feed I can use instead?
- No. Probes of app.jobRSS, app.rss, app.jobSearchRSS, app.jobs, and app.feed all return the application's generic error page, and the shipped JavaScript bundles declare only an authenticated recruiter-side grid-preferences call. Adding format=json to a job URL returns the same HTML.
- lowWhy can I not find a posting date on any job?
- OpenHire publishes no posting date anywhere on the public board — not in the row, not on the detail page, and not in any meta tag. Derive freshness from when your own crawler first observed the job id rather than expecting a provider-supplied date.
SilkRoad OpenHire Jobs API.
Extract the complete vacancy inventory from a legacy SilkRoad OpenHire board in a single unpaginated ColdFusion request, then hydrate each posting from its stable element IDs.
What's in every response.
Data fields, real-world applications, and the companies already running on SilkRoad OpenHire.
Data fields
- Full Job Descriptions
- Required Skills & Experience
- Tracking Codes
- Position Type
- Location Details
- Multi-Portal Versions
Use cases
- 01Enterprise Job Aggregation
- 02Legacy ATS Migration Audits
- 03Multi-Language Board Coverage
- 04Careers Page Monitoring
Trusted by
- Southland
- WilmerHale
- Hallmark Aviation Services
- Kubota Engine
- CPKC
How to scrape SilkRoad OpenHire.
Step-by-step guide to extracting jobs from SilkRoad OpenHire-powered career pages—endpoints, authentication, and working code.
from urllib.parse import urlparse, parse_qs
DOMAIN = "silkroad.com"
# The modern SilkRoad product lives on these hosts and is a different contract.
EXCLUDED = {"jobs.silkroad.com", "jobs-ca.silkroad.com"}
def parse_openhire(url: str) -> dict:
parsed = urlparse(url)
host = parsed.netloc.lower()
if host in EXCLUDED or not host.endswith("." + DOMAIN):
raise ValueError("not a legacy OpenHire host")
if "/epostings" not in parsed.path.lower():
raise ValueError("not an /epostings route")
# ColdFusion query keys are case-insensitive and the wild data mixes cases.
query = {k.lower(): v[0] for k, v in parse_qs(parsed.query).items()}
version = (query.get("version") or "1").lstrip("0") or "0"
if not version.isdigit():
raise ValueError("version must be numeric")
return {
"tenant": host.split(".")[0],
"version": version,
"job_id": query.get("jobid"),
"fuseaction": (query.get("fuseaction") or "").lower(),
}
print(parse_openhire("https://kcsouthern.silkroad.com/epostings/index.cfm"
"?fuseaction=app.jobinfo&jobid=1234&version=2"))import requests
from bs4 import BeautifulSoup
def board_url(tenant: str, version: str) -> str:
return (f"https://{tenant}.silkroad.com/epostings/index.cfm"
f"?fuseaction=app.jobsearch&version={version}")
def job_url(tenant: str, version: str, job_id: str) -> str:
return (f"https://{tenant}.silkroad.com/epostings/index.cfm"
f"?fuseaction=app.jobinfo&jobid={job_id}&version={version}")
session = requests.Session()
session.headers["Accept"] = "text/html,application/xhtml+xml"
resp = session.get(board_url("southland", "1"), timeout=60)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
print(f"{len(resp.text)} bytes in one response")def column_index(soup) -> dict:
"""Header anchor ids stay English (header_location) even when the
visible header text is translated, e.g. on the Spanish CPKC board."""
index = {}
for position, header in enumerate(soup.select("a[id^='header_']")):
index[header.get("id").lower()] = position
return index
def parse_rows(soup) -> list[dict]:
columns = column_index(soup)
anchors = soup.select("a[id^='jobTitle_']")
if not anchors and soup.select_one("a#header_jobTitle") is None:
raise RuntimeError("this is not an OpenHire search page")
rows = []
for anchor in anchors:
job_id = anchor.get("id")[len("jobTitle_"):]
if not job_id.isdigit():
continue
cells = anchor.find_parent("tr").select("td") if anchor.find_parent("tr") else []
def cell(name):
position = columns.get(name)
if position is None or position >= len(cells):
return None
return " ".join(cells[position].get_text().split())
rows.append({
"job_id": job_id,
"title": " ".join(anchor.get_text().split()),
"tracking_code": cell("header_trackingcode"),
"location": cell("header_location"),
})
return rows
listings = parse_rows(soup)
print(f"{len(listings)} postings")import time
DETAIL_IDS = {
"title": "#jobTitleDiv",
"description": "#jobDesciptionDiv", # vendor's own typo — do not "fix" it
"required_skills": "#jobRequiredSkillsDiv",
"required_experience": "#jobExperienceRqdDiv",
"location": "#jobPositionLocationDiv",
"tracking_code": "#jobCodeDiv",
"position_type": "#translatedJobPostingTypeDiv",
}
def fetch_detail(tenant: str, version: str, job_id: str) -> dict | None:
url = job_url(tenant, version, job_id)
resp = session.get(url, timeout=45)
if resp.status_code in (404, 410):
return None
resp.raise_for_status()
page = BeautifulSoup(resp.text, "html.parser")
# A pulled posting answers HTTP 200, so status code alone proves nothing.
if page.select_one("#jobStatusChangeDiv") and page.select_one("#jobTitleDiv") is None:
return None
if page.select_one("#jobTitleDiv") is None:
raise RuntimeError("neither a posting nor the status-changed marker — parse error")
detail = {"job_id": job_id, "listing_url": url}
for field, selector in DETAIL_IDS.items():
node = page.select_one(selector)
detail[field] = node.decode_contents() if field == "description" and node else (
" ".join(node.get_text().split()) if node else None
)
return detail
for row in listings[:3]:
print(fetch_detail("southland", "1", row["job_id"]))
time.sleep(0.6)- 1Treat the first host label as the tenant and ignore any upstream board token
- 2Scope every snapshot by the version parameter, defaulting to 1 when absent
- 3Read job IDs from the jobTitle_{id} anchor id, never from the volatile href
- 4Build column positions from the header_* anchor IDs so translated boards still parse
- 5Require #jobStatusChangeDiv plus a missing #jobTitleDiv before recording a removal
- 6Throttle to roughly two requests per second with at most two concurrent detail fetches
One endpoint. All SilkRoad OpenHire jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=silkroad openhire" \
-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 SilkRoad OpenHire
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.