🎉 Premium Proxies · 3-Day Free TrialClaim Now →
Guides

How to Scrape Indeed Jobs Without Getting Blocked

Scrape Indeed jobs the ethical way: pull the title, company, salary, and job ID, get past Cloudflare, paginate search results, and build clean job-market data.

S SparkProxy 7 21 min read
Share
How to Scrape Indeed Jobs Without Getting Blocked

To scrape Indeed jobs at any useful scale, you have to win two fights at once: parsing a listing page whose class names are deliberately obfuscated, and getting past Cloudflare, which sits in front of Indeed and blocks naive scrapers before the HTML ever loads. Most tutorials show a requests.get and one CSS selector, then quietly die the first time Cloudflare serves a "Just a moment..." challenge. This guide covers the full pipeline for public job data: which fields to pull, how to catch a soft block, how to geo-target and paginate search results, and how to turn raw postings into a clean job-market dataset. Every code sample uses SparkProxy's Scraping API, so the anti-bot layer is one request parameter instead of an infrastructure project you babysit.

What job data you can extract (fields reference)

A public Indeed search result card and the job detail page behind it expose a consistent set of fields. The class names churn, but the fields are stable. Here is the reference set worth pulling, with selectors that work as of mid-2026. Indeed obfuscates and rotates class names, so treat every selector as one candidate in a list, not a guarantee.

Search result card (https://www.indeed.com/jobs?q=...):

FieldWhere it livesSelector / attribute to tryNotes
Job titleCard heading`h2.jobTitle span[title]`, `a.jcs-JobTitle`The role name
Job ID (`jk`)Title anchor`a.jcs-JobTitle` `data-jk` attributeThe unique key; the anchor for everything
CompanyCard body`[data-testid="company-name"]`Employer or staffing agency
LocationCard body`[data-testid="text-location"]`City/state, or "Remote"
SalaryMetadata row`[data-testid="attribute_snippet_testid"]`, `.salary-snippet-container`May be employer-posted or Indeed-*estimated*
Posted dateMetadata footer`span.date`, `[data-testid="myJobsStateDate"]`Relative ("3 days ago", "Just posted")
SnippetCard footer`[data-testid="jobsnippet_footer"]`, `.job-snippet`Teaser; full text lives on the detail page

Job detail page (https://www.indeed.com/viewjob?jk=):

FieldSelector to tryNotes
Title`h1.jobsearch-JobInfoHeader-title`, `[data-testid="jobsearch-JobInfoHeader-title"]`Cleaner than the card title
Company`[data-testid="inlineHeader-companyName"] a`Links to the company profile
Location`[data-testid="inlineHeader-companyLocation"]`Full location string
Salary + job type`#salaryInfoAndJobType`One block holds both
Full description`#jobDescriptionText`The complete HTML body
Job ID`jk` query param in the URLSame key as the card

The jk value is the anchor for everything. It is the hexadecimal job key (for example a1b2c3d4e5f6a7b8) that uniquely names a posting. Store it as your primary key and hang every other field off it. One caveat that matters later: the same real-world role is often posted under several different jk values by different staffing agencies, so jk uniqueness is not the same as job uniqueness.

Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

Why Indeed is hard to scrape

Indeed sits behind Cloudflare and runs its own bot detection on top. Four things break a naive Indeed job scraper:

The Cloudflare challenge. When Cloudflare suspects automation, it serves an interstitial titled "Just a moment..." (or "Attention Required!") that runs a JavaScript and Turnstile check before it hands over the real page. A plain HTTP client never runs that JavaScript, so it receives the challenge HTML, often with a 403 status, and never sees a single job. Sometimes the challenge returns 200 with challenge markers in the body, so trusting the status code alone will store a block as if it were data.

IP reputation. Plain datacenter IP ranges get flagged fast on Cloudflare-fronted sites. Residential IPs blend in with real job seekers and last far longer. Rotating the exit IP per request is the difference between a scraper that runs for ten minutes and one that runs for a day.

Obfuscated, shifting markup. Indeed A/B tests its result layout and mangles class names, so a selector that works today can return nothing next week. data-testid attributes are more stable than visual classes, which is why the fields table leans on them. Match a list of candidates and expect drift.

Pagination and rate limits. Fire requests too fast and Indeed throttles or hard-blocks the IP. It also caps how deep you can page into any single query, so broad searches leave most postings unreachable.

SignalWhat you'll seeHow to handle it
Cloudflare challenge403 or 200 with "Just a moment..." / Turnstile in the bodyRender JS, rotate IP, use stealth, retry
IP blockPersistent 403 on one IPFresh residential IP per request
Layout driftA selector returns nothingPrefer `data-testid`, match multiple candidates
Rate limit429 or blocks after burstsSpace requests, back off, lower concurrency

A managed scraping API absorbs the first two for you and helps with the fourth. The markup drift stays your problem, because it lives in the HTML. For the proxy-side theory behind staying unblocked, How to Avoid Getting Your Proxy Blocked goes deep on it.

Set up the SparkProxy Scraping API

The SparkProxy Scraping API takes a target URL and handles the proxy, rotation, browser rendering, and anti-bot layer for you. You send one request; you get the rendered HTML back. For Cloudflare-fronted Indeed, four parameters carry the weight:

  • render_js=true: Indeed and Cloudflare both depend on JavaScript, so a raw fetch never clears the challenge. A real Chromium render does.
  • premium_proxy=true: routes through residential IPs, which survive Cloudflare where datacenter IPs get flagged.
  • stealth=true: adds the extra fingerprint and behavior layers that clear Turnstile-style checks (it requires render_js=true).
  • country_code: the ISO alpha-2 code of the market you are targeting (US, GB, DE), which sets the exit country so localized results match the domain.

The base endpoint is https://scrape.sparkproxy.io/api/v1 and auth is a single header, X-API-Key. A minimal request in cURL:

curl -G "https://scrape.sparkproxy.io/api/v1" \
  -H "X-API-Key: YOUR_API_KEY" \
  --data-urlencode "url=https://www.indeed.com/viewjob?jk=a1b2c3d4e5f6a7b8" \
  --data-urlencode "render_js=true" \
  --data-urlencode "premium_proxy=true" \
  --data-urlencode "stealth=true" \
  --data-urlencode "country_code=US"

The full parameter list and response fields live in the Scraping API docs. If you are weighing this against running your own proxy pool and headless browsers, Web Scraping API vs Self-Managed Proxies lays out the trade-off.

Scrape a single job posting

Start with one posting. Wrap the request so every call carries the Indeed-specific parameters, add wait_for so the API waits until the job body has actually rendered, and give it a generous timeout since a rendered request drives a real browser.

import requests

API = "https://scrape.sparkproxy.io/api/v1"
API_KEY = "YOUR_API_KEY"

def fetch(url: str, wait_for: str, country: str = "US") -> str:
    resp = requests.get(
        API,
        headers={"X-API-Key": API_KEY},
        params={
            "url": url,
            "render_js": "true",      # clear the Cloudflare JS challenge
            "premium_proxy": "true",  # residential IPs survive Cloudflare
            "stealth": "true",        # extra layers for Turnstile-style checks
            "country_code": country,  # match the market to the domain
            "wait_for": wait_for,     # block until this selector exists
        },
        timeout=90,
    )
    resp.raise_for_status()
    return resp.text

def fetch_job(jk: str, country: str = "US") -> str:
    url = f"https://www.indeed.com/viewjob?jk={jk}"
    return fetch(url, wait_for="#jobDescriptionText", country=country)

Before you trust the HTML, check whether Cloudflare handed you a challenge instead of a job. Because a challenge can arrive with a 200 status, raise_for_status() will not catch it. Scan the body for the telltale markers:

def is_blocked(html: str) -> bool:
    """Cloudflare/Indeed can return a challenge with HTTP 200, so the status lies."""
    markers = (
        "Just a moment...",
        "Attention Required! | Cloudflare",
        "cf-browser-verification",
        "challenge-platform",
        "Enable JavaScript and cookies to continue",
        "Additional Verification Required",
    )
    return any(m in html for m in markers)

Now a single fetch is honest: it either returns a real posting or tells you it was blocked so you can retry.

Parse the core fields

For parsing at scale, use selectolax (a C-backed HTML parser) rather than the pure-Python default. It parses Indeed's large DOM roughly an order of magnitude faster than html.parser, which matters when you process tens of thousands of pages. Install it with pip install selectolax.

The key move is trying a list of selectors per field and preferring data-testid attributes, which survive Indeed's class-name churn better than visual classes:

from selectolax.parser import HTMLParser

def _first_text(tree, selectors):
    for sel in selectors:
        node = tree.css_first(sel)
        if node and node.text(strip=True):
            return node.text(strip=True)
    return None

TITLE = ["h1.jobsearch-JobInfoHeader-title",
         '[data-testid="jobsearch-JobInfoHeader-title"]']
COMPANY = ['[data-testid="inlineHeader-companyName"] a',
           '[data-testid="inlineHeader-companyName"]']
LOCATION = ['[data-testid="inlineHeader-companyLocation"]',
            '[data-testid="jobsearch-JobInfoHeader-companyLocation"]']

def parse_job(html: str, jk: str) -> dict:
    tree = HTMLParser(html)
    desc = tree.css_first("#jobDescriptionText")
    pay = tree.css_first("#salaryInfoAndJobType")
    return {
        "job_id": jk,
        "title": _first_text(tree, TITLE),
        "company": _first_text(tree, COMPANY),
        "location": _first_text(tree, LOCATION),
        "salary_raw": pay.text(strip=True) if pay else None,
        "description": desc.text(strip=True) if desc else None,
        "url": f"https://www.indeed.com/viewjob?jk={jk}",
    }

The #salaryInfoAndJobType block bundles pay and employment type in one string like "$70,000 - $90,000 a year - Full-time", so keep the raw text now and normalize it in the analytics step. The description comes back as text here; if you need the formatted HTML (bullet lists of responsibilities and requirements), read desc.html instead of desc.text().

Let the API parse for you with extract_rules

Maintaining CSS selectors across Indeed's markup drift is the tax on self-parsing. The SparkProxy Scraping API can do the extraction server-side with the extract_rules parameter: pass a map of field names to selectors, and the API returns JSON keyed by your names. This turns the scraping endpoint into a lightweight Indeed jobs API where the response is already structured.

import json
import requests

rules = {
    "title": '[data-testid="jobsearch-JobInfoHeader-title"]',
    "company": '[data-testid="inlineHeader-companyName"]',
    "location": '[data-testid="inlineHeader-companyLocation"]',
    "salary": "#salaryInfoAndJobType",
    "description": "#jobDescriptionText",
}

resp = requests.get(
    "https://scrape.sparkproxy.io/api/v1",
    headers={"X-API-Key": "YOUR_API_KEY"},
    params={
        "url": "https://www.indeed.com/viewjob?jk=a1b2c3d4e5f6a7b8",
        "render_js": "true",
        "premium_proxy": "true",
        "stealth": "true",
        "country_code": "US",
        "extract_rules": json.dumps(rules),
    },
    timeout=90,
)
data = resp.json()   # {"title": "...", "company": "...", "salary": "$70,000 ...", ...}

Check the docs for the exact extract_rules syntax your plan exposes, including how to pull an attribute versus text. The trade is the same one from the last section, moved server-side: rules mean you do not ship a parser, but you still update the selectors when Indeed changes them. For a fixed field set like salary benchmarking, extract_rules is usually the lower-maintenance path.

Search, geo-target, and paginate listings

Search pages are where Indeed scraping earns its keep, because they let you discover postings by keyword and location. The search URL takes a few parameters worth knowing:

  • q: the query ("data engineer", "registered nurse").
  • l: the location ("New York, NY", "Remote", a ZIP).
  • start: the pagination offset, in increments of 10.
  • fromage: max posting age in days (1, 3, 7, 14), which keeps a run to fresh listings.
  • sort=date: newest first, which is what you want for tracking new postings.

Geo-targeting has two layers. Set l= for the location within a market, and set country_code (plus the right domain: indeed.com for the US, uk.indeed.com for the UK, de.indeed.com for Germany) so the exit IP country matches. Mismatching them triggers redirects and region interstitials.

from urllib.parse import urlencode

def search_url(query: str, location: str, start: int = 0,
               fromage: int = 7, sort: str = "date") -> str:
    params = {"q": query, "l": location, "start": start,
              "fromage": fromage, "sort": sort}
    return "https://www.indeed.com/jobs?" + urlencode(params)

def parse_search_cards(html: str) -> list[dict]:
    tree = HTMLParser(html)
    rows = []
    for card in tree.css("div.job_seen_beacon, [data-testid='slider_item']"):
        link = card.css_first("a.jcs-JobTitle, h2.jobTitle a")
        if link is None:
            continue
        jk = link.attributes.get("data-jk")
        if not jk:
            continue
        title = card.css_first("h2.jobTitle span[title], a.jcs-JobTitle")
        company = card.css_first('[data-testid="company-name"]')
        location = card.css_first('[data-testid="text-location"]')
        salary = card.css_first('[data-testid="attribute_snippet_testid"], '
                                '.salary-snippet-container')
        rows.append({
            "job_id": jk,
            "title": title.text(strip=True) if title else None,
            "company": company.text(strip=True) if company else None,
            "location": location.text(strip=True) if location else None,
            "salary_raw": salary.text(strip=True) if salary else None,
        })
    return rows

To walk every page, request &start=N in steps of 10 and stop when a page returns no cards. Dedupe on jk as you go, because Indeed repeats cards across page boundaries:

def scrape_search(query: str, location: str, max_pages: int = 10) -> list[dict]:
    seen, results = set(), []
    for page in range(max_pages):
        url = search_url(query, location, start=page * 10)
        html = fetch(url, wait_for="#mosaic-provider-jobcards")
        if is_blocked(html):
            continue
        cards = parse_search_cards(html)
        if not cards:
            break
        new = [c for c in cards if c["job_id"] not in seen]
        if not new:               # nothing new means we've hit the tail
            break
        for c in new:
            seen.add(c["job_id"])
        results.extend(new)
    return results

One hard limit to design around: Indeed caps how deep organic search pagination goes for a single query, so a broad search like "engineer" in "United States" leaves most postings unreachable. The fix is to narrow. Split by specific title, city, and fromage window, run each tight query to its cap, then dedupe across them by job_id. Twenty narrow queries surface far more of the market than one broad one.

Turn listings into job-market data

Raw cards are not a dataset yet. Two cleanup steps separate a real job market data scraping pipeline from a pile of HTML: salary normalization and posting deduplication. Both are where most tutorials stop, and both quietly corrupt any analysis you run downstream.

Salary normalization. Indeed shows pay in mixed periods ("$28 an hour", "$70,000 - $90,000 a year", "$4,500 a month") and often as an Indeed-estimated range rather than an employer-posted figure. Mixing estimated and posted salaries, or averaging hourly and annual numbers as-is, produces garbage benchmarks. Parse the raw string into an annualized min and max, and flag whether it was estimated:

import re

HOURS_PER_YEAR = 2080          # 40h x 52 weeks
PERIOD_MULTIPLIER = {"hour": HOURS_PER_YEAR, "year": 1,
                     "month": 12, "week": 52, "day": 260}

def normalize_salary(raw: str | None) -> dict:
    if not raw:
        return {"min": None, "max": None, "estimated": None}
    estimated = "estimat" in raw.lower()   # Indeed labels its own guesses
    period = next((p for p in PERIOD_MULTIPLIER if p in raw.lower()), "year")
    nums = [float(n.replace(",", "")) for n in re.findall(r"[\d,]+\.?\d*", raw)]
    nums = [n for n in nums if n > 5]       # drop stray digits like "40h"
    if not nums:
        return {"min": None, "max": None, "estimated": estimated}
    mult = PERIOD_MULTIPLIER[period]
    annual = [round(n * mult) for n in nums]
    return {"min": min(annual), "max": max(annual), "estimated": estimated}

# normalize_salary("$28 an hour")            -> {'min': 58240, 'max': 58240, ...}
# normalize_salary("$70,000 - $90,000 a year") -> {'min': 70000, 'max': 90000, ...}

Posting deduplication. Because staffing agencies re-post the same role under different jk values, counting by jk overstates demand. Dedupe on a content fingerprint of the normalized title, company, and location so each real opening counts once:

from collections import Counter
from statistics import median

def fingerprint(row: dict) -> str:
    parts = [(row.get(k) or "").strip().lower() for k in ("title", "company", "location")]
    return "|".join(parts)

def analyze(rows: list[dict]) -> dict:
    unique = {fingerprint(r): r for r in rows}.values()   # one per real opening
    by_location = Counter(r["location"] for r in unique if r.get("location"))
    posted = [normalize_salary(r.get("salary_raw")) for r in unique]
    mids = [(s["min"] + s["max"]) / 2 for s in posted
            if s["min"] and not s["estimated"]]            # employer-posted only
    return {
        "total_cards": len(rows),
        "unique_openings": len(list(unique)),
        "top_locations": by_location.most_common(5),
        "median_posted_salary": round(median(mids)) if mids else None,
    }

Filtering to employer-posted salaries before you take a median is the detail that makes the number trustworthy. From here, the same records support hiring-velocity tracking (count new job_ids per day with fromage=1), skill-demand analysis (regex the description text for tools and certifications), and remote-versus-onsite splits. The proxy patterns behind sustained collection like this are covered in Using Proxies for Market Research and Data Collection.

Scale without getting blocked

At volume, three things keep the pipeline healthy: retries on soft blocks, backoff so you do not spike a single IP, and modest concurrency. With a scraping API the provider rotates the exit IP for you, so your ceiling is your plan's rate limit rather than the number of proxies you own. Keep worker counts sane (5 to 15 is plenty) and let retries absorb the occasional Cloudflare challenge.

import time
import random
from concurrent.futures import ThreadPoolExecutor, as_completed

def fetch_job_with_retry(jk: str, attempts: int = 3) -> str | None:
    for i in range(attempts):
        html = fetch_job(jk)
        if not is_blocked(html) and "jobDescriptionText" in html:
            return html
        time.sleep(2 ** i + random.random())   # exponential backoff + jitter
    return None

def scrape_jobs(job_ids: list[str], workers: int = 8) -> list[dict]:
    out = []
    with ThreadPoolExecutor(max_workers=workers) as pool:
        futures = {pool.submit(fetch_job_with_retry, jk): jk for jk in job_ids}
        for fut in as_completed(futures):
            html = fut.result()
            if html is None:
                continue
            out.append(parse_job(html, futures[fut]))
    return out

The jitter matters more than it looks. random.random() staggers retries so a batch of failures does not retry in lockstep and re-trigger the same block. Persist results as you go instead of holding everything in memory, so a crash at posting 40,000 does not cost you the first 39,999. A flat CSV is enough to start:

import csv

def save_csv(rows: list[dict], path: str = "indeed_jobs.csv") -> None:
    if not rows:
        return
    with open(path, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
        writer.writeheader()
        writer.writerows(rows)

For a running job-market tracker, append a scraped_at timestamp and the query/location you used to each row, and write to a database keyed on (job_id, scraped_at). That gives you a clean time series where posting counts and salary trends are comparable run to run. The general proxy patterns behind high-volume collection are in Using Datacenter Proxies for Web Scraping.

Frequently asked questions

FAQ

Scraping publicly accessible pages (no login) generally does not violate the US Computer Fraud and Abuse Act under hiQ v. LinkedIn (9th Cir. 2022), but it can still breach Indeed's Terms of Service, which prohibit automated collection, and Indeed's robots.txt disallows crawling /jobs and /viewjob. For commercial use, prefer Indeed's official APIs or a licensed feed; for personal research, stick to public listing data, avoid personal data, and get legal advice before launch.

Indeed sits behind Cloudflare, which runs a JavaScript and Turnstile challenge before serving the real page, so a plain HTTP client only gets the "Just a moment..." interstitial. Use a real browser render with rotating residential IPs: with the SparkProxy Scraping API set render_js=true, premium_proxy=true, and stealth=true, then scan the body for challenge markers and retry with backoff when you detect one.

The job ID is Indeed's jk value, a hexadecimal key that uniquely identifies a posting. You find it as the data-jk attribute on the job title link in search results, and as the jk query parameter in a detail URL like /viewjob?jk=. Use it as your primary key, but dedupe real openings on title, company, and location, because agencies re-post the same role under different jk values.

Pull the raw pay string from #salaryInfoAndJobType on the detail page or the salary snippet on the card, then normalize it. Indeed mixes hourly, monthly, and yearly figures and often shows an Indeed-estimated range instead of an employer-posted one. Convert everything to an annual min and max, and filter to employer-posted salaries before averaging so estimates do not skew your benchmark.

Indeed caps how deep organic pagination goes for a single query using the start offset, so one broad search leaves most postings unreachable. Narrow each query by specific job title, city, and a fromage age window, run each tight query to its page cap, then dedupe across queries by job_id. Many narrow searches cover far more of the market than one broad one.

Yes. Pass the extract_rules parameter to the SparkProxy Scraping API with a map of field names to CSS selectors, and the response returns JSON keyed by your names. That gives you a lightweight Indeed jobs API built on the scraping endpoint, with parsing handled server-side, though you still update the selectors when Indeed changes its markup.

Limited-time · 50% off

Get 50% off your first purchase

Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.

Offer ends soon — claim it before it's gone

Claim Discount

About the Author

SparkProxy Technical Team. The SparkProxy engineering team builds and maintains global datacenter and residential proxy infrastructure plus a managed Scraping API. This guide reflects patterns tested against indeed.com in 2026, using Python 3.11+, requests 2.32+, and selectolax 0.3+. Selectors are current as of mid-2026; Indeed obfuscates and rotates its markup often, so treat them as a starting point and match multiple candidates. This is engineering guidance, not legal advice.

Citations: hiQ Labs v. LinkedIn, 9th Cir. 2022 · SparkProxy Scraping API docs

Keep reading

Related articles

How to Scrape Airbnb Listings and Prices

How to Scrape Airbnb Listings and Prices

Learn how to scrape Airbnb listings and prices: extract fields from Airbnb's embedded JSON, handle date-based pricing, map pagination, and anti-bot defenses.

SparkProxy·Guides
How to Scrape GraphQL APIs

How to Scrape GraphQL APIs

Learn to scrape GraphQL API data: find the /graphql endpoint, read the query and variables in DevTools, then replay your own queries with cursor pagination.

SparkProxy·Guides
How to Bypass reCAPTCHA When Web Scraping

How to Bypass reCAPTCHA When Web Scraping

How to bypass reCAPTCHA when web scraping the ethical way: how v2 and v3 scoring work, how to raise your reCAPTCHA score, and solvers as a last resort.

SparkProxy·Guides