# How to Scrape Taleo Jobs (No Public API)

> Taleo has no public JSON API. See how tbe.taleo.net career pages paginate, how to read JSON-LD with an HTML fallback, and the gotchas that matter.

Source: https://cleanjobdata.com/articles/how-to-scrape-taleo-job-listings
Published: 2026-09-19

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.

Taleo Business Edition has no public JSON jobs API: to scrape Taleo jobs, page through the server-rendered `.../careers/v2/searchResults` pages, then fetch each requisition's detail page and read its JSON-LD. 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.

That's the DIY route, and it works for a handful of companies you already know. If you'd rather not build and maintain a Taleo scraper, CleanJobData already ingests ATS platforms and company career sites and serves the jobs normalized through one REST API — see the [Jobs API docs](https://cleanjobdata.com/docs/api/jobs).

## 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:

```bash
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:

```bash
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

```bash
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:

```json
{
  "@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](https://cheerio.js.org) (`npm install cheerio`):

```ts
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 normalize jobs from many ATS platforms](https://cleanjobdata.com/articles/how-we-normalize-ats-job-data) for the general approach and [Workday](https://cleanjobdata.com/articles/how-to-scrape-workday-job-listings) for another enterprise platform. Try it in the [Playground](https://cleanjobdata.com/playground) or see the [API Documentation](https://cleanjobdata.com/docs).

## Skip the scraper: get this data from the CleanJobData API

If you'd rather not maintain a Taleo collector, you can query normalized jobs from ATS platforms and company career sites with a single request. The response has structured city/state/country locations, parsed salary when the employer publishes one, a remote type, and company metadata, and you page through results with the cursor in `pagination.next_page`.

```bash
curl "https://api.cleanjobdata.com/jobs?title=engineer&remote=true&limit=20" \
  -H "Authorization: Bearer $CLEANJOBDATA_API_KEY"
```

Jobs from ATS-hosted career sites come back in the same schema as every other source, so there's no per-platform parsing on your side. Use `company_website_url` or `employer_id` to scope to one employer. Coverage depends on which employers are in the dataset, so check that the companies you care about appear before you rely on it.

- [Jobs API reference](https://cleanjobdata.com/docs/api/jobs) for every filter and response field
- [Playground](https://cleanjobdata.com/playground) to run queries without writing code
- [Pricing](https://cleanjobdata.com/pricing) — the Trial is free with no card required
