Google Careers Jobs API.
Pull Google's first-party job listings — titles, global locations, authoritative result counts and full descriptions — parsed from the server-rendered careers pages and delivered through one normalized Jobo endpoint.
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.
What's in every response.
Data fields, real-world applications, and the companies already running on Google Careers.
- Full Job Descriptions
- Native Google Job IDs
- Global Office Locations
- First-Party Apply URLs
- Authoritative Result Counts
- Canonical Listing URLs
- 01Big Tech Hiring Trends
- 02Competitive Talent Intelligence
- 03Job Board Aggregation
- 04Location & Result Coverage Analysis
How to scrape Google Careers.
Step-by-step guide to extracting jobs from Google Careers-powered career pages—endpoints, authentication, and working code.
import requests
from bs4 import BeautifulSoup
LISTINGS_URL = "https://www.google.com/about/careers/applications/jobs/results"
HEADERS = {"Accept": "text/html"}
def first_page_url() -> str:
return f"{LISTINGS_URL}?page=1"
def fetch_html(url: str) -> str:
resp = requests.get(url, headers=HEADERS, timeout=30)
resp.raise_for_status()
return resp.textimport re
from urllib.parse import urljoin, urlsplit, urlunsplit
JOB_PATH_RE = re.compile(
r"^/about/careers/applications/jobs/results/(\d+)-[^/]+/?$"
)
TOTAL_RE = re.compile(
r"Showing\s+\d[\d,]*\s+to\s+\d[\d,]*\s+of\s+(\d[\d,]*)\s+rows?",
re.I,
)
DOC_BASE = "https://www.google.com/about/careers/applications/"
LABEL_PREFIX = "Learn more about "
def parse_listings(html: str, page_url: str) -> tuple[list[dict], int, str | None]:
soup = BeautifulSoup(html, "html.parser")
base_tag = soup.find("base", href=True)
doc_base = urljoin(page_url, base_tag["href"]) if base_tag else None
if doc_base != DOC_BASE:
raise ValueError("Google Careers document base changed")
main = soup.find("main")
status = main.select_one("[role='status']") if main else None
total_match = TOTAL_RE.search(status.get_text(" ", strip=True)) if status else None
if not total_match:
raise ValueError("authoritative result total is missing")
advertised_total = int(total_match.group(1).replace(",", ""))
jobs = []
anchors = main.select(
"a[aria-label^='Learn more about '][href*='jobs/results/']"
) if main else []
for anchor in anchors:
card = anchor.find_parent("li")
href = anchor.get("href")
label = anchor.get("aria-label", "")
title = label.removeprefix(LABEL_PREFIX).strip()
if not title and card:
heading = card.find("h3")
title = heading.get_text(" ", strip=True) if heading else ""
if not card or not href or not title:
raise ValueError("malformed Google Careers result candidate")
detail_url = urljoin(doc_base, href)
parts = urlsplit(detail_url)
if parts.scheme != "https" or parts.netloc != "www.google.com":
raise ValueError("out-of-scope Google Careers detail URL")
match = JOB_PATH_RE.fullmatch(parts.path)
if not match:
raise ValueError("detail route omitted its numeric job ID")
locations = []
place = next((node for node in card.select("[aria-hidden='true']")
if node.get_text(" ", strip=True).lower() == "place"), None)
if place and place.parent:
for node in place.parent.find_all("span", recursive=False):
value = node.get_text(" ", strip=True).lstrip(";").strip()
if value and not re.fullmatch(r"\+\d+\s+more", value, re.I):
locations.append(value)
jobs.append({
"external_id": match.group(1),
"listing_url": urlunsplit((parts.scheme, parts.netloc, parts.path, "", "")),
"title": title,
"company": "Google",
"locations": list(dict.fromkeys(locations)),
})
next_link = main.select_one("a[aria-label='Go to next page'][href]") if main else None
next_url = urljoin(page_url, next_link["href"]) if next_link else None
return jobs, advertised_total, next_urlimport time
from urllib.parse import parse_qs, urlsplit
def validate_page_url(url: str, expected_page: int) -> None:
parts = urlsplit(url)
query = parse_qs(parts.query)
valid = (
parts.scheme == "https"
and parts.netloc == "www.google.com"
and parts.path == "/about/careers/applications/jobs/results"
and query == {"page": [str(expected_page)]}
)
if not valid:
raise ValueError("inconsistent Google Careers next-page URL")
def scrape_all_listings() -> list[dict]:
page, url = 1, first_page_url()
jobs_by_id, advertised_total = {}, None
while url:
validate_page_url(url, page)
page_jobs, page_total, next_url = parse_listings(fetch_html(url), url)
if advertised_total is not None and page_total != advertised_total:
raise ValueError("advertised total changed between pages")
advertised_total = page_total
if not page_jobs and (page > 1 or page_total > 0):
raise ValueError("unexpected zero-row page")
for job in page_jobs:
if job["external_id"] in jobs_by_id:
raise ValueError(f"duplicate job ID: {job['external_id']}")
jobs_by_id[job["external_id"]] = job
if next_url:
validate_page_url(next_url, page + 1)
url, page = next_url, page + 1
time.sleep(0.1) # ~100ms between requests
if len(jobs_by_id) != advertised_total:
raise ValueError(
f"coverage mismatch: emitted {len(jobs_by_id)} of {advertised_total}"
)
return list(jobs_by_id.values())DETAIL_HEADINGS = {
"minimum qualifications",
"preferred qualifications",
"about the job",
"responsibilities",
}
def fetch_job_details(listing: dict) -> dict | None:
resp = requests.get(listing["listing_url"], headers=HEADERS, timeout=30)
if resp.status_code in (404, 410):
return None # the role was taken down
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
base_tag = soup.find("base", href=True)
document_base = urljoin(listing["listing_url"], base_tag["href"]) if base_tag else None
if document_base != DOC_BASE:
raise ValueError("Google Careers detail document base changed")
title_meta = soup.select_one("meta[property='og:title'][content]")
title = title_meta["content"].strip() if title_meta else None
main = soup.find("main")
headings = [
heading for heading in (main.find_all("h3") if main else [])
if heading.get_text(" ", strip=True).rstrip(":").lower() in DETAIL_HEADINGS
]
section_parents, seen = [], set()
for heading in headings:
parent_key = id(heading.parent)
if parent_key not in seen:
seen.add(parent_key)
section_parents.append(heading.parent)
description = "\n\n".join(
section.get_text(" ", strip=True) for section in section_parents
)
apply_el = soup.select_one("a#apply-action-button[href]")
apply_url = urljoin(document_base, apply_el["href"]) if apply_el else None
apply_parts = urlsplit(apply_url) if apply_url else None
if not apply_parts or not (
apply_parts.scheme == "https"
and apply_parts.netloc == "www.google.com"
and apply_parts.path == "/about/careers/applications/apply"
and parse_qs(apply_parts.query).get("jobId", [""])[0]
and not apply_parts.fragment
):
raise ValueError("detail omitted its first-party apply route")
location_note = next((bold for bold in (main.find_all("b") if main else [])
if "preferred working location from the following"
in bold.parent.get_text(" ", strip=True).lower()), None)
locations = ([part.strip() for part in location_note.get_text().split(";")]
if location_note else listing["locations"])
return {
"title": title,
"description": description,
"apply_url": apply_url,
"locations": locations,
"detail_sections": [h.get_text(" ", strip=True) for h in headings],
}
for job in scrape_all_listings():
detail = fetch_job_details(job)
if detail:
print(job["external_id"], detail["title"])Resolve the Apply anchor through the detail document's exact `<base href="https://www.google.com/about/careers/applications/">`, then require `/about/careers/applications/apply` with a non-empty `jobId` query.
Read the document's declared <base href> (https://www.google.com/about/careers/applications/) and resolve every detail href through it, then validate the result is a www.google.com jobs/results URL before requesting it.
Anchor on semantic contracts: numeric /jobs/results/{id}- routes, 'Learn more about …' accessible labels, main/list landmarks, role=status totals, Open Graph metadata, and recognized h3 section headings.
Strip the query string and persist only the canonical /jobs/results/{id}-{slug} path as the job's stable key.
Follow the exact accessible next-page link, require the role=status total to stay consistent on every page, and reject the snapshot unless the final unique-ID count equals that total.
Catch 404/410 on the detail request and mark the job as removed, letting the rest of the run continue.
- 1Resolve every relative link through the document's <base href> before requesting it.
- 2Send an Accept: text/html header and a realistic browser User-Agent for consistent markup.
- 3Throttle to roughly one request every 100ms and cap concurrent detail fetches at about 6.
- 4Fail loudly on a missing result total, malformed candidate, inconsistent next link, or unexpected zero-row continuation.
- 5Reconcile unique emitted IDs against the advertised role=status total before accepting a snapshot.
- 6Store the canonical, query-free detail URL as each job's stable identifier.
- 7Treat 404/410 detail responses as removals, not scrape failures.
One endpoint. All Google Careers jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=google careers" \
-H "X-Api-Key: YOUR_KEY" Access Google Careers
job data today.
One API call. Structured data. No scraping infrastructure to build or maintain — start with the free tier and scale as you grow.