How to Scrape Taleo Job Listings
TL;DR
Oracle Taleo (tbe.taleo.net) career sites have no JSON API: you page through server-rendered HTML with a rowFrom offset, then fetch each requisition's detail page and read its JobPosting JSON-LD block. Not every tenant has JSON-LD, so you need a fallback that reads the description from the page, and closed jobs come back as a 200, not a 404.
Oracle Taleo is an older enterprise applicant tracking system, and it's one of the harder ones to pull data from because there's no public JSON API to call. What you get is server-rendered HTML. This guide covers the Taleo Business Edition career sites hosted on tbe.taleo.net. Other Taleo editions can look different, and this guide doesn't cover them.
Finding a Company's Career Site
A Taleo Business Edition career page URL looks like this:
https://phg.tbe.taleo.net/phg04/ats/careers/v2/searchResults?org=WP3X5Q&cws=37
Four pieces identify one career section: the subdomain (phg), a pod path (phg04), an org code (WP3X5Q) and a cws number (37). There's no lookup for them, so you have to lift them from the company's own careers link. Everything below uses this base:
https://phg.tbe.taleo.net/phg04/ats/careers/v2
Step 1: Page Through the List
The list is HTML, up to 25 jobs per page. The first page is:
curl -A "Mozilla/5.0" "https://phg.tbe.taleo.net/phg04/ats/careers/v2/searchResults?org=WP3X5Q&cws=37"Later pages add next and a rowFrom offset that grows by 25:
curl -A "Mozilla/5.0" "https://phg.tbe.taleo.net/phg04/ats/careers/v2/searchResults?org=WP3X5Q&cws=37&next&rowFrom=25"Each job is an a.viewJobLink element whose href contains rid=<number>, the requisition ID. The title is the link text, and the location and employment type are in sibling elements inside the accordion header (.oracletaleocwsv2-accordion-head-info). Stop when a page has fewer than 25 job links. A page past the end comes back essentially empty. The list has no posting date, so you get it from the detail page.
A board with 10 jobs fit on the first page, and asking for the second returned an empty response.
Step 2: Fetch Each Detail Page
curl -A "Mozilla/5.0" "https://phg.tbe.taleo.net/phg04/ats/careers/v2/viewRequisition?org=WP3X5Q&cws=37&rid=580"Read the data in this order.
1. JSON-LD first. Look for a <script type="application/ld+json"> block whose @type is JobPosting. Where it exists, it holds most of what you need:
{
"@type": "JobPosting",
"title": "Account Manager - Service",
"url": "https://phg.tbe.taleo.net/phg04/ats/careers/v2/viewRequisition?org=WP3X5Q&cws=37&rid=580",
"datePosted": "2025-04-10 00:00:00.0",
"employmentType": "Full time",
"identifier": { "name": "WALKER ENGINEERING INC", "value": "580" },
"hiringOrganization": { "name": "WALKER ENGINEERING INC" },
"description": "<p>…</p>",
"jobLocation": {
"address": {
"addressLocality": "Austin, TX",
"addressRegion": "Texas",
"addressCountry": { "name": "US", "@type": "Country" },
"streetAddress": "18919 N Heatherwilde Blvd Suite 155",
"postalCode": "78660"
}
}
}2. An HTML fallback. Not every tenant emits JSON-LD, so plan for it to be missing. The page itself has the description in a <div name="cwsJobDescription">, the title in the first <strong>, and labeled rows for the location and employment type. Some tenants use a different detail template without that div: the description sits in a two-column table (#table_job_description) of field names and content, and you want only the second column so the labels don't end up in your text.
Fetching Jobs (Node/TypeScript)
This example uses cheerio (npm install cheerio):
import * as cheerio from "cheerio";
const HEADERS = { "user-agent": "Mozilla/5.0", accept: "text/html" };
async function getHtml(url: string) {
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) throw new Error(`Taleo ${res.status} for ${url}`);
return res.text();
}
function parseDetail(html: string) {
// 1. JSON-LD
for (const m of html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)) {
try {
const ld = JSON.parse(m[1]);
if (ld?.["@type"] === "JobPosting") return { source: "jsonld" as const, ld };
} catch {
/* malformed block: try the next one */
}
}
// 2. HTML fallback
const $ = cheerio.load(html);
let description = $('div[name="cwsJobDescription"]').html();
if (!description) {
description = $("#table_job_description tbody tr")
.map((_, tr) => $(tr).find("td").eq(1).html())
.get()
.filter(Boolean)
.join("");
}
return { source: "html" as const, title: $("strong").first().text().trim(), description };
}
// "2025-04-10 00:00:00.0" is not ISO 8601; make it explicit UTC before parsing.
export function parseTaleoDate(raw?: string) {
const m = raw?.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})/);
return m ? new Date(`${m[1]}T${m[2]}Z`) : null;
}
export async function fetchTaleoJobs(base: string, org: string, cws: string) {
const rids = new Set<string>();
for (let rowFrom = 0; rowFrom < 5000; rowFrom += 25) {
const url =
rowFrom === 0
? `${base}/searchResults?org=${org}&cws=${cws}`
: `${base}/searchResults?org=${org}&cws=${cws}&next&rowFrom=${rowFrom}`;
const $ = cheerio.load(await getHtml(url));
const links = $("a.viewJobLink");
links.each((_, el) => {
const rid = ($(el).attr("href") ?? "").match(/[?&]rid=(\d+)/)?.[1];
if (rid) rids.add(rid);
});
if (links.length < 25) break;
}
const jobs = [];
for (const rid of rids) {
const html = await getHtml(`${base}/viewRequisition?org=${org}&cws=${cws}&rid=${rid}`);
if (html.includes("This job has moved or is no longer available")) continue; // closed, but a 200
jobs.push({ rid, ...parseDetail(html) });
}
return jobs;
}Here base is https://phg.tbe.taleo.net/phg04/ats/careers/v2. The detail pages are large (one was around 100 KB), so fetch them a few at a time at most.
Gotchas
Dates aren't ISO. datePosted looks like 2025-04-10 00:00:00.0, with a space instead of T and no timezone. Passing that string straight to new Date() parses it in the machine's local timezone, which can land a day early on a machine east of UTC. Reformat it as explicit UTC before parsing, as parseTaleoDate does. The HTML fallback has no posting date at all.
Closed jobs return 200. A closed requisition doesn't return a 404. It returns a normal page containing "This job has moved or is no longer available". Check for that text before parsing.
Old postings stay up. The board we tested still listed a job first posted in April 2025, so compare the date with today's before treating a job as fresh.
The country is a nested object. In JSON-LD, addressCountry is { "name": "US", "@type": "Country" }, and the name holds a short code rather than a country name. addressLocality can be a "City, ST" string, so don't split it blindly.
Salary, when present, has messy units. If a posting includes baseSalary, the unit text isn't standardized, so match on synonyms (hour, week, month, day, year) and convert before comparing. The board we tested didn't publish salary.
Edits are missed on re-polls. If you skip detail requests for IDs you already stored, you won't see later edits to those postings.
No published rate limits. Keep concurrency low, use a normal browser user agent, and back off and retry on 429, 502, 503 and 504.
How CleanJobData Handles Taleo
CleanJobData reads Taleo career sites together with the other platforms and returns them in one schema: resolved cities, states and countries, an employment type, and company details, with parsed salary when the posting publishes one. See how we normalized four ATS platforms for the general approach and Workday for another enterprise platform. Try it in the Playground or see the API Documentation.