- criticalThe result window silently caps at 100 rows
- Many deployments stop returning rows at 100 even when the board's own count is larger. Compare the number of mapped rows with the row-count element and mark the snapshot incomplete when they disagree — a capped snapshot must never be used to conclude that the missing postings were withdrawn.
- highThe path case is normalised and the board stops resolving
- PeopleSoft paths are case-sensitive: /psc/HRMS/ and /psc/hrms/ are not the same route, and environments such as hrprdrs are lowercase while sites such as HRMS are not. Preserve the exact casing from the first-party URL when rebuilding board and detail addresses.
- highDetail links built with sequence 1 bounce to the search page
- Search rows do not expose PostingSeq. Sequence 1 is canonical, but real boards also publish sequence 2. Try 1, then 2, and only treat a same-board redirect back to the search component after both as evidence the posting has gone.
- mediumThe first request lands on a login page
- Candidate Gateway establishes its session through native redirects that set cookies. Use a cookie-retaining client that follows them; a page with no form[name='win0'] is a login or error surface and must not be treated as an empty board.
Oracle PeopleSoft Candidate Gateway Jobs API.
Extract postings from customer-hosted PeopleSoft Candidate Gateway boards, where every employer runs the same HRS_CG_SEARCH_FL component on its own domain behind a stateful search form.
What's in every response.
Data fields, real-world applications, and the companies already running on Oracle PeopleSoft Candidate Gateway.
Data fields
- Full Posting Descriptions
- Job Opening IDs
- Department & Location
- Employment Schedule and Duration
- Opened Dates
- Labelled Description Sections
Use cases
- 01Enterprise Job Aggregation
- 02Government & Utility Careers Feeds
- 03Self-Hosted ATS Extraction
- 04ATS Data Pipelines
Trusted by
- Consolidated Communications
- State of Tennessee (Edison)
- Federated Hermes
How to scrape Oracle PeopleSoft Candidate Gateway.
Step-by-step guide to extracting jobs from Oracle PeopleSoft Candidate Gateway-powered career pages—endpoints, authentication, and working code.
import re
from urllib.parse import urlparse, parse_qs, quote
COMPONENT = "HRS_HRAM_FL.HRS_CG_SEARCH_FL.GBL"
SEARCH_PAGE = "HRS_APP_SCHJOB_FL"
DETAIL_PAGE = "HRS_APP_JBPST_FL"
def parse_board(url: str) -> dict | None:
parsed = urlparse(url)
parts = parsed.path.strip("/").split("/")
# /psc/{environment}/{accessMode}/{site}/c/{component}
if len(parts) != 6 or parts[0] != "psc" or parts[4] != "c" or parts[5] != COMPONENT:
return None
query = parse_qs(parsed.query)
site_id = (query.get("SiteId") or [""])[0]
if (query.get("Page") or [""])[0] != SEARCH_PAGE or not site_id.isdigit():
return None
return {
"authority": parsed.netloc.lower(), # the customer's own host is the tenant
"environment": parts[1], # case matters when rebuilding the path
"access_mode": parts[2],
"site": parts[3],
"site_id": site_id,
}
def board_url(board: dict) -> str:
return (
f"https://{board['authority']}/psc/{board['environment']}/{board['access_mode']}"
f"/{board['site']}/c/{COMPONENT}"
f"?Page={SEARCH_PAGE}&Action=U&FOCUS=Applicant&SiteId={board['site_id']}"
)
board = parse_board(
"https://careers.edison.tn.gov/psc/hrprdrs/EMPLOYEE/HRMS/c/"
"HRS_HRAM_FL.HRS_CG_SEARCH_FL.GBL?Page=HRS_APP_SCHJOB_FL&Action=U&FOCUS=Applicant&SiteId=1"
)
print(board_url(board))import requests
from bs4 import BeautifulSoup
ROW_PREFIX = "HRS_AGNT_RSLT_I$0_row_"
ROW_COUNT_ID = "win0divHRS_AGNT_RSLT_Irowcnt$0"
def load_board(session: requests.Session, board: dict) -> BeautifulSoup:
response = session.get(
board_url(board),
headers={"Accept": "text/html,application/xhtml+xml"},
timeout=60,
allow_redirects=True, # PeopleSoft sets its session cookie via redirects
)
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
if soup.select_one("form[name='win0']") is None:
raise RuntimeError("Response was not a PeopleSoft Candidate Gateway page")
return soup
def provider_row_count(soup: BeautifulSoup) -> int | None:
node = soup.find(id=ROW_COUNT_ID)
match = re.search(r"([0-9][0-9,]*)", node.get_text() if node else "")
return int(match.group(1).replace(",", "")) if match else None
session = requests.Session()
soup = load_board(session, board)
print("board reports", provider_row_count(soup), "results")def cell(row, element_id: str) -> str | None:
node = row.find(id=element_id)
return " ".join(node.get_text().split()) if node else None
def detail_url(board: dict, opening_id: str, sequence: str) -> str:
return (
f"https://{board['authority']}/psc/{board['environment']}/{board['access_mode']}"
f"/{board['site']}/c/{COMPONENT}"
f"?Page={DETAIL_PAGE}&Action=U&FOCUS=Applicant"
f"&JobOpeningId={opening_id}&PostingSeq={sequence}&SiteId={board['site_id']}"
)
def parse_rows(soup: BeautifulSoup, board: dict) -> list[dict]:
listings = []
for row in soup.select(f"li[id^='{ROW_PREFIX}']"):
index = row["id"][len(ROW_PREFIX):]
opening_id = cell(row, f"HRS_APP_JBSCH_I_HRS_JOB_OPENING_ID${index}")
title = cell(row, f"SCH_JOB_TITLE${index}")
if not opening_id or not opening_id.isdigit() or not title:
continue
listings.append({
"id": opening_id,
"title": title,
"location": cell(row, f"LOCATION${index}"),
"department": cell(row, f"HRS_APP_JBSCH_I_HRS_DEPT_DESCR${index}"),
"posted_at": cell(row, f"SCH_OPENED${index}"),
# Search rows omit PostingSeq; 1 is canonical, 2 is the known fallback.
"listing_url": detail_url(board, opening_id, "1"),
})
return listings
listings = parse_rows(soup, board)
print(f"{len(listings)} rows visible")from urllib.parse import urljoin
def request_more(session: requests.Session, soup: BeautifulSoup, board: dict) -> BeautifulSoup:
form = soup.select_one("form[name='win0']")
fields = {
node["name"]: node.get("value", "")
for node in form.select("input[type='hidden'][name]")
}
fields["ICAction"] = "HRS_AGNT_RSLT_I$hdown$0" # the native "show more" action
response = session.post(
urljoin(board_url(board), form.get("action") or ""),
data=fields,
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=60,
)
response.raise_for_status()
return BeautifulSoup(response.text, "html.parser")
widened = request_more(session, soup, board)
listings = parse_rows(widened, board)
reported = provider_row_count(widened)
complete = reported is not None and len(listings) == reported
if not complete:
# A capped window cannot authorise concluding that missing jobs were withdrawn.
print(f"incomplete snapshot: {len(listings)} of {reported}")def fetch_posting(session: requests.Session, board: dict, listing: dict) -> dict | None:
for sequence in ("1", "2"):
url = detail_url(board, listing["id"], sequence)
response = session.get(url, timeout=60)
if response.status_code in (404, 410):
return None # canonical removal
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
proved = soup.find(id="HRS_SCH_WRK2_HRS_JOB_OPENING_ID")
title = soup.find(id="HRS_SCH_WRK2_POSTING_TITLE")
if not proved or " ".join(proved.get_text().split()) != listing["id"]:
continue # this sequence bounced back to the search page — try the next
sections = []
for block in soup.select("[id^='win0divHRS_SCH_PSTDSC_row$']"):
index = block["id"].rsplit("$", 1)[-1]
label = soup.find(id=f"HRS_SCH_WRK_DESCR100${index}lbl")
body = soup.find(id=f"HRS_SCH_PSTDSC_DESCRLONG${index}")
if body is None or not body.get_text(strip=True):
continue
if label and label.get_text(strip=True):
sections.append(f"<h2>{label.get_text(strip=True)}</h2>")
sections.append(body.decode_contents().strip())
location = soup.find(id="HRS_SCH_WRK_HRS_DESCRLONG")
schedule = soup.find(id="HRS_SCH_WRK_HRS_FULL_PART_TIME")
return {
**listing,
"listing_url": url,
"posting_sequence": sequence,
"title": " ".join(title.get_text().split()) if title else listing["title"],
"description_html": "\n".join(sections) or None,
"location": " ".join(location.get_text().split()) if location else listing["location"],
"employment_schedule": " ".join(schedule.get_text().split()) if schedule else None,
}
return None # both sequences bounced: structured unavailable, not an error
for listing in listings[:3]:
job = fetch_posting(session, board, listing)
print(job["title"] if job else f"{listing['id']} is no longer posted")- 1Treat the customer's own host plus environment, access mode, site and SiteId as one board identity
- 2Preserve exact path casing — PeopleSoft routes are case-sensitive
- 3Use a cookie-keeping session that follows the gateway's native redirects
- 4Re-post the win0 hidden form state with ICAction to widen the result window
- 5Reconcile mapped rows against the provider's row count before trusting a snapshot
- 6Retry posting sequence 2 before treating a search-page redirect as removal
One endpoint. All Oracle PeopleSoft Candidate Gateway jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=oracle peoplesoft candidate gateway" \
-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 Oracle PeopleSoft Candidate Gateway
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.