- highOne employer is split across two boards
- The same agency is reachable through the legacy /{tenant}/sup/bulpreview.asp route and the modern /oec/{tenant}/Jobs/Bulletin route. Normalise both onto the single /{tenant}/rss.asp board and rebuild one canonical bulletin URL, otherwise a county appears twice with half its jobs each.
- highAn empty bulletin is mistaken for a closed job
- A page that loads but carries no bulletin body is not proof the recruitment closed. Only an HTTP 404 or 410, or the vendor's explicit 'Recruitment Not Found' page, should be treated as removal; anything else — timeouts, 5xx, a WAF challenge — should fail the run rather than expire jobs.
- mediumJSON-LD is missing on some tenants
- Structured data is present on part of the estate and absent on the rest, so requiring it silently drops whole agencies. Use JSON-LD when its identifier matches the recruitment number and otherwise parse #JobBulletinNum, #JobBulletinTitle, #JobBulletinBody and table.DetailTable.
- mediumThe recruitment number is rewritten as a single id
- JobAps identifies a job with a composite of R1, R2 and R3, and each part is needed to rebuild the bulletin and the apply URL. Keep the vendor's own three components rather than synthesising a hash, so a job keeps its identity across snapshots.
JobAps Jobs API.
Collect open recruitments from US counties, cities, states and transit districts on JobAps, where every tenant publishes an authoritative RSS inventory and a server-rendered bulletin per job.
What's in every response.
Data fields, real-world applications, and the companies already running on JobAps.
Data fields
- Authoritative RSS Inventory
- Full Bulletin Text
- Approximate Salary
- Department & Work Location
- Opening and Closing Dates
- Composite Recruitment Numbers
Use cases
- 01Public-Sector Job Aggregation
- 02County & State Agency Feeds
- 03Civic Data Research
- 04Government Hiring Trends
Trusted by
- County of Alameda
- State of Connecticut
- State of Maryland
- City of New Haven
How to scrape JobAps.
Step-by-step guide to extracting jobs from JobAps-powered career pages—endpoints, authentication, and working code.
from urllib.parse import urlparse, parse_qs
HOST = "www.jobapscloud.com"
def parse_url(url: str) -> tuple[str, str | None] | None:
parsed = urlparse(url)
if parsed.scheme != "https" or parsed.netloc.lower() != HOST:
return None
parts = parsed.path.strip("/").split("/")
query = parse_qs(parsed.query)
if len(parts) == 2 and parts[1].lower() == "rss.asp":
return parts[0].lower(), None # the tenant inventory
if len(parts) == 3 and parts[1].lower() == "sup" and parts[2].lower() == "bulpreview.asp":
tenant = parts[0]
elif len(parts) == 4 and parts[0].lower() == "oec" and parts[2:] == ["Jobs", "Bulletin"]:
tenant = parts[1]
else:
return None
tuple_parts = [(query.get(k) or [""])[0].upper() for k in ("R1", "R2", "R3")]
if not all(p.isalnum() for p in tuple_parts):
return None
# The recruitment number is composite; keep the vendor's own three parts.
return tenant.lower(), "-".join(tuple_parts)
def feed_url(tenant: str) -> str:
return f"https://{HOST}/{tenant}/rss.asp"
print(parse_url("https://www.jobapscloud.com/oec/newhaven/Jobs/Bulletin?R1=2606&R2=0974&R3=01"))import requests
import xml.etree.ElementTree as ET
ATOM = "{http://www.w3.org/2005/Atom}link"
def fetch_inventory(session: requests.Session, tenant: str) -> tuple[str, list[ET.Element]]:
response = session.get(
feed_url(tenant),
headers={"Accept": "application/rss+xml, application/xml;q=0.9"},
timeout=30,
)
response.raise_for_status()
channel = ET.fromstring(response.text).find("channel")
if channel is None:
raise RuntimeError("JobAps RSS omitted its channel")
self_link = next(
(l.get("href") for l in channel.findall(ATOM) if l.get("rel") == "self"), None
)
proved = parse_url(self_link or "")
if not proved or proved[0] != tenant:
raise RuntimeError("JobAps RSS contradicted its tenant channel proof")
title = (channel.findtext("title") or "").strip()
for suffix in (" - Current Job Openings", " - Employment Opportunities", " Job Openings"):
if title.endswith(suffix):
title = title[: -len(suffix)].strip()
return title, channel.findall("item")
session = requests.Session()
company, items = fetch_inventory(session, "alameda")
print(company, len(items))def canonical_job_url(tenant: str, recruitment: str) -> str:
r1, r2, r3 = recruitment.split("-")
return f"https://{HOST}/{tenant}/sup/BulPreview.asp?R1={r1}&R2={r2}&R3={r3}"
def map_item(item: ET.Element, tenant: str, company: str) -> dict | None:
title = (item.findtext("title") or "").strip()
link = (item.findtext("link") or "").strip()
guid = (item.findtext("guid") or "").strip()
parsed = parse_url(link)
if not title or not parsed or parsed[0] != tenant or parsed[1] is None:
return None
# When a guid is present it must name the same tenant and recruitment.
if guid and parse_url(guid) != parsed:
return None
return {
"id": parsed[1],
"title": title,
"company": company,
"summary": (item.findtext("description") or "").strip() or None,
"posted_at": item.findtext("pubDate"),
"listing_url": canonical_job_url(tenant, parsed[1]),
}
listings = [m for m in (map_item(i, "alameda", company) for i in items) if m]
print(f"{len(listings)} open recruitments")import json
from urllib.parse import urljoin
from bs4 import BeautifulSoup
def read_detail_table(soup) -> dict:
fields = {}
for row in soup.select("table.DetailTable tr"):
cells = row.select("th, td")
if len(cells) < 2:
continue
key = " ".join(cells[0].get_text().split()).rstrip(":")
value = " ".join(cells[1].get_text().split())
if key and value:
fields.setdefault(key, value)
return fields
def find_job_posting(soup, recruitment: str) -> 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 not isinstance(node, dict) or node.get("@type") != "JobPosting":
continue
identifier = node.get("identifier")
value = identifier.get("value") if isinstance(identifier, dict) else identifier
if (value or "").upper() == recruitment.upper():
return node
return None
def fetch_bulletin(session: requests.Session, listing: dict) -> dict | None:
response = session.get(listing["listing_url"], headers={"Accept": "text/html"}, timeout=30)
if response.status_code in (404, 410):
return None # canonical removal
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
heading = soup.select_one("h1")
if heading and "Recruitment Not Found" in heading.get_text():
return None # the vendor's own explicit "no such recruitment" page
apply_anchor = soup.select_one(".ApplyPanelDiv a[href], a[href*='TermsOfUse'], a[href*='termsofuse']")
apply_url = urljoin(response.url, apply_anchor["href"]) if apply_anchor else None
posting = find_job_posting(soup, listing["id"])
if posting:
return {**listing, "description_html": posting.get("description"),
"posted_at": posting.get("datePosted") or listing["posted_at"],
"apply_url": apply_url}
number = " ".join((soup.select_one("#JobBulletinNum") or soup.new_tag("i")).get_text().split())
body = soup.select_one("#JobBulletinBody")
if number.upper() != listing["id"].upper() or body is None:
raise RuntimeError("JobAps bulletin contradicted its recruitment tuple")
fields = read_detail_table(soup)
return {
**listing,
"title": " ".join(soup.select_one("#JobBulletinTitle").get_text().split()),
"description_html": body.decode_contents().strip(),
"salary_text": fields.get("Approximate Salary") or fields.get("Salary"),
"department": fields.get("Department"),
"location": fields.get("Work Location") or fields.get("Location"),
"closes_at": fields.get("Closing Date") or fields.get("Filing Deadline"),
"apply_url": apply_url,
}
for listing in listings[:3]:
job = fetch_bulletin(session, listing)
if job:
print(job["title"], "-", job.get("salary_text"))- 1Take the tenant from the URL path, never from an aggregator's board token
- 2Treat /{tenant}/rss.asp as the authoritative inventory of what is currently open
- 3Verify the channel's atom self link names the same tenant before mapping items
- 4Normalise legacy and OEC routes onto one canonical bulletin URL per recruitment
- 5Prefer JSON-LD when its identifier matches, and fall back to the native bulletin elements
- 6Follow the terms-of-use anchor to record the real apply URL alongside the bulletin
One endpoint. All JobAps jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=jobaps" \
-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 JobAps
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.