- criticalThe listing endpoint does not parse as JSON
- ccp_jobs.aspx returns JavaScript with the payload after a ccpInfo: marker, so a direct json.loads fails. Locate the single marker and brace-match the object while tracking strings and escapes; splitting on the last brace breaks on descriptions that contain one.
- highA /job/{id} link cannot be attributed to an employer
- Detail URLs are tenantless by design. Fetch the page and take the account from the single InAccountID that appears in its script; if zero or more than one is present, leave the job unattributed rather than assigning the first match.
- highThe distributor code is mistaken for the tenant
- CCPCode is a distributor or feed code, not an employer identity — many pages publish none at all and others carry a code belonging to a different distributor. Key the employer on the numeric InAccountID and keep CCPCode as metadata only.
- mediumMulti-location postings are counted as duplicates
- The same numeric ID appears once per site when a posting runs at several locations. Merge those rows into one job with a list of locations instead of emitting one record per row, which otherwise inflates the board count.
OurCareerPages (Arcoro/BirdDogHR) Jobs API.
Read hiring from the contractors, manufacturers and trades employers on Arcoro's OurCareerPages, where one anonymous account endpoint returns the whole board and each job page carries the full advert.
What's in every response.
Data fields, real-world applications, and the companies already running on OurCareerPages (Arcoro/BirdDogHR).
Data fields
- Complete Account Snapshot
- Full Job Descriptions
- Category Headings
- City, State and Postal Code
- Multi-Location Postings
- Last Update Dates
Use cases
- 01Construction & Trades Job Aggregation
- 02Manufacturing Hiring Research
- 03Careers Page Extraction
- 04ATS Data Pipelines
Trusted by
- 128 Plumbing
- Crowder Constructors
- Waupaca Foundry
How to scrape OurCareerPages (Arcoro/BirdDogHR).
Step-by-step guide to extracting jobs from OurCareerPages (Arcoro/BirdDogHR)-powered career pages—endpoints, authentication, and working code.
from urllib.parse import urlparse
HOST = "jobs.ourcareerpages.com"
LISTING_PATH = "/WebServices/ccp_jobs.aspx"
def parse_detail(url: str) -> str | None:
parsed = urlparse(url)
if parsed.scheme != "https" or parsed.netloc.lower() != HOST:
return None
parts = parsed.path.strip("/").split("/")
# /job/{id} is deliberately tenantless — the account must be proved from the page.
if len(parts) != 2 or parts[0].lower() != "job" or not parts[1].isdigit():
return None
return parts[1]
def board_url(account_id: str) -> str:
return (
f"https://{HOST}{LISTING_PATH}?AutoGenerate=yes&GroupBy=&CCPCode="
f"&InAccountID={account_id}&ElementID=jobs&JobOrderBy="
)
print(parse_detail("https://jobs.ourcareerpages.com/job/996702?source=128Plumbing"))import json
import requests
def extract_ccp_info(script: str) -> dict:
marker = "ccpInfo:"
start = script.find(marker)
# Exactly one marker must be present; a second means the shape has changed.
if start < 0 or script.find(marker, start + len(marker)) >= 0:
raise RuntimeError("OurCareerPages payload did not carry a single ccpInfo block")
start = script.index("{", start + len(marker))
depth, in_string, escaped = 0, False, False
for index in range(start, len(script)):
char = script[index]
if in_string:
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == '"':
in_string = False
continue
if char == '"':
in_string = True
elif char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
return json.loads(script[start:index + 1])
raise RuntimeError("OurCareerPages ccpInfo block was not terminated")
def fetch_feed(session: requests.Session, account_id: str) -> dict:
response = session.get(board_url(account_id), timeout=60)
response.raise_for_status()
feed = extract_ccp_info(response.text)
if str(feed.get("InAccountID")) != str(account_id):
raise RuntimeError("OurCareerPages feed contradicted the requested account")
return feed
session = requests.Session()
feed = fetch_feed(session, "3862")
print(feed.get("CCPCode"), len(feed.get("CategoryList") or []))import re
from datetime import datetime, timezone
MS_DATE = re.compile(r"^/Date\((-?[0-9]+)(?:[+-][0-9]{4})?\)/$")
def parse_ms_date(value: str | None) -> str | None:
match = MS_DATE.match(value or "")
if not match:
return None
return datetime.fromtimestamp(int(match.group(1)) / 1000, tz=timezone.utc).isoformat()
def flatten(feed: dict) -> list[dict]:
merged: dict[str, dict] = {}
for category in feed.get("CategoryList") or []:
heading = category.get("Heading")
for row in category.get("JobList") or []:
job_id = str(row.get("ID") or "")
if not job_id.isdigit():
continue
location = row.get("Location") or ", ".join(
p for p in [row.get("City"), row.get("StateAbbrev") or row.get("StateFull")] if p
)
entry = merged.setdefault(job_id, {
"id": job_id,
"title": (row.get("JobTitle") or "").strip() or None,
"company": (row.get("CompanyName") or "").strip() or None,
"summary": (row.get("BriefDesc") or "").strip() or None,
"category": heading,
"postal_code": row.get("PostalCode"),
"updated_at": parse_ms_date(row.get("LastUpdateDate")),
"locations": [],
"listing_url": f"https://{HOST}/job/{job_id}",
})
# Repeated ids are multi-location postings, not duplicates.
if location and location not in entry["locations"]:
entry["locations"].append(location)
return list(merged.values())
listings = flatten(feed)
print(f"{len(listings)} distinct postings")from bs4 import BeautifulSoup
ACCOUNT_ID = re.compile(r"\bInAccountID\s*:\s*[\"']?([1-9][0-9]{0,9})[\"']?")
TRACKER = re.compile(r"<img\b[^>]*\bsrc\s*=\s*[\"'][^\"']*/JobStat\.aspx[^\"']*[\"'][^>]*>", re.I)
def fetch_detail(session: requests.Session, listing: dict, account_id: str) -> dict | None:
response = session.get(listing["listing_url"], timeout=30)
if response.status_code in (404, 410):
return None # canonical removal
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
number = soup.select_one("#jobNumberStringHF")
accounts = set(ACCOUNT_ID.findall(response.text))
tracker = soup.select_one("img[src*='/JobStat.aspx']")
proved = (
number is not None and (number.get("value") or "").strip() == listing["id"]
and len(accounts) == 1 and accounts.pop() == str(account_id)
and soup.select_one("script[src*='ccp_widget_support.js']") is not None
and tracker is not None
)
if not proved:
# A 200 without an exact job/account proof is inconclusive, never a removal.
raise RuntimeError("OurCareerPages detail page omitted its job or account proof")
heading = soup.select_one("#pageheader h2")
body = TRACKER.sub("", tracker.parent.decode_contents()).strip()
location = soup.select_one(".job_location")
return {
**listing,
"title": " ".join(heading.get_text().split()) if heading else listing["title"],
"description_html": body or None,
"location": " ".join(location.get_text().split()) if location else None,
"account_id": str(account_id),
}
for listing in listings[:3]:
job = fetch_detail(session, listing, "3862")
if job:
print(job["title"], "-", job["location"])- 1Key the employer on the numeric InAccountID and never on the CCPCode
- 2Brace-match the ccpInfo payload rather than trying to parse the response as JSON
- 3Confirm the feed's InAccountID equals the account you requested before mapping
- 4Merge repeated job IDs into a single posting with multiple locations
- 5Require #jobNumberStringHF and a single InAccountID on the detail page before trusting it
- 6Strip the JobStat.aspx tracking pixel out of the description HTML
One endpoint. All OurCareerPages (Arcoro/BirdDogHR) jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=ourcareerpages (arcoro/birddoghr)" \
-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 OurCareerPages (Arcoro/BirdDogHR)
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.