# Indeed Job Scraper - Salaries, Skills & Company Data (`harvestlab/indeed-scraper`) Actor

Indeed job scraper for jobs, salaries, skills, company ratings, benefits, and remote-work data across 8 countries. Build role-location salary grids, exports, webhooks, and optional compensation briefs.

- **URL**: https://apify.com/harvestlab/indeed-scraper.md
- **Developed by:** [harvestlab](https://apify.com/harvestlab) (community)
- **Categories:** Jobs, AI
- **Features:** Limited permissions
- **Stats:** 31 total users, 4 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.80 / 1,000 job scrapeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Indeed Job Scraper - Salaries, Skills & Company Data

Use this Indeed job scraper to collect job listings, salary ranges, skills, company ratings, benefits, remote-work signals, and hiring-market data across 8 countries. Search one role and location, or compare multiple roles across multiple cities to build salary and skill-demand matrices.

**Best first run**

```json
{
  "searchQuery": "data analyst",
  "location": "Remote",
  "country": "us",
  "maxJobs": 3,
  "datePosted": "1",
  "enableAiAnalysis": false
}
```

Want a ready-made workflow? Open [Track remote AI jobs on Indeed](https://apify.com/harvestlab/indeed-scraper/examples/track-remote-ai-jobs-on-indeed) to start with a fresh US remote-AI search and a job-focused output table.

Use this actor when you need an Indeed job scraper, Indeed salary scraper, company hiring dataset, compensation benchmark, skill-demand report, or recruiter market-intelligence workflow. Start with a small fresh remote single-query run, then use city searches, compare mode, or AI analysis after basic extraction works for your target country.

> **No cookies, no OAuth, no API keys (for the scraping)** - runs on public data only. Hosted AI providers need your own API key when enabled; self-hosted Ollama needs no key. Provider token charges are separate from Actor AI events.

Build live Indeed salary matrices and skill-demand reports for **$0.003/listing at the base rate**, or $0.0024 on Starter, $0.0021 on Scale and $0.0018 on Business. These discounts follow your Apify plan, not the number of jobs in a run. Unless a plan is named, prices and examples below use the Free/no-discount base rates; see [Cost and Pricing](#cost-and-pricing). Total event charges depend on the jobs returned, reports and exports requested. The actor parses salary ranges, detects 70+ skills, and provides a **multi-query x multi-location compare mode** across 8 countries.

Whether you are a compensation team building multi-geo salary bands, a recruiter benchmarking 3 competing roles across 5 metros, or a career-switcher deciding between role pivots, this actor delivers structured Indeed data and AI-driven market intelligence without requiring you to stitch multiple scrapes together.

### What it does

Indeed Scraper pulls structured job listings from Indeed across 8 countries (US, UK, Canada, Australia, Germany, France, Netherlands, India) and turns raw postings into decision-ready intelligence.

Every job is parsed into clean fields: normalized salary ranges (hourly/yearly/K-notation all resolved to `min`/`max`/`period`), 70+ detected technical and professional skills extracted from the job description, employer rating, benefits list, and job type. At $0.003 per deduplicated listing, it is priced for frequent benchmarking runs while returning structured, analysis-ready fields.

The standout feature is **multi-query compare mode**. Provide an array of job titles and an array of locations and the actor runs the full NxM grid - up to 16 cells - then produces a single cross-role comparison report: a median salary matrix, geographic pay premium ranking, hottest skills across all roles, emerging skill gaps between titles, and per-audience recommendations. It can also export a CSV salary matrix and a Markdown executive briefing, ready to drop into a Slack workflow or weekly briefing.

For single-query runs, AI market analysis produces salary percentile bands (25th/50th/75th/90th), top-skill frequency ranking, company hiring-volume leaders, and remote/on-site ratio - all from a single `$0.05` event charge.

Compensation teams, recruiters, HR analysts, career researchers, and workforce development organizations use it to replace hours of manual Indeed browsing with a scheduled, structured data pipeline.

### Indeed Job Scraper

Scrape Indeed jobs by title, keyword, location, country, job type, experience level, salary floor, posting date, and remote/hybrid/on-site filter. Each job row includes title, company, rating, location, salary fields, benefits, skills, job URL, posting date, and search context.

### Indeed Salary Scraper

Extract employer-posted salary ranges and normalize them into structured `salary_min`, `salary_max`, `salary_period`, and `currency` fields. Use compare mode to benchmark compensation across roles, cities, countries, or remote-work markets.

### Indeed Company Reviews And Ratings

Indeed job cards can include employer ratings from Indeed's company review system. Use those ratings alongside salary, benefits, skills, and remote-work fields to prioritize employers, screen roles, or enrich talent-market dashboards.

### Features

- **8-country support** -- search Indeed across US, UK, Canada, Australia, Germany, France, Netherlands, and India using localized Indeed domains
- **Multi-query compare mode** -- supply `queries[]` + `locations[]` and the actor runs the full N x M grid (capped at 16 cells), then produces a single cross-query / cross-location comparison report: median salary matrix, geo premium, hottest skills overall, emerging-skill gaps between roles, and per-audience recommendations
- **Advanced job filters** -- filter by date posted, job type (full-time, part-time, contract, internship), experience level (entry, mid, senior), salary minimum, and remote/hybrid/on-site
- **Automatic salary parsing** -- extracts and normalizes salary ranges from free-text salary strings, handling hourly/monthly/yearly salaries, EU/US thousands separators, K notation, and range formats into structured min/max/period fields
- **70+ skill detection** -- scans title + job descriptions to identify mentioned technical and professional skills, building a structured skills profile per job
- **Benefits extraction** -- captures listed benefits like health insurance, 401(k), remote work, PTO, and stock options
- **Company ratings** -- extracts employer rating scores from Indeed's company review system
- **AI market intelligence (single-query)** -- optional AI-powered report with salary benchmarking (25th/50th/75th/90th percentiles), top skills by frequency, company hiring volume, remote vs on-site ratios, and actionable recommendations
- **Compare-mode export artifacts** -- render a CSV salary-matrix spreadsheet and/or a Markdown executive briefing, with a webhook for Slack/Notion delivery
- **Multiple AI providers** -- choose OpenRouter (recommended - 300+ models), Anthropic (Claude), Google AI (Gemini), OpenAI (GPT), or Ollama (self-hosted) for AI analysis
- **Pay-per-event pricing** -- only pay for what you scrape, no monthly fees or commitment

### Use Cases

- **Recruiters and staffing agencies (talent intelligence)** -- benchmark competing offers across 3-8 metros in one run. The compare-mode salary matrix shows where the market is paying a premium for the role you're filling and which companies are hiring aggressively, so you can sharpen your pitch and pricing in a single weekly Slack briefing.
- **HR and compensation teams (compensation benchmarking, alternative to Levels.fyi / Payscale)** -- build data-driven compensation bands without a five-figure SaaS subscription. Levels.fyi and Payscale pull from self-reported employee data with a quarterly lag; this actor pulls live employer-posted salary ranges, normalizes hourly/yearly/K-notation, and emits 25/50/75/90 percentile bands per role and geography for ~$1 a run.
- **Job seekers (geo arbitrage and role pivots)** -- run the same query across 4-6 cities + Remote and let the geo-premium ranking show where your role pays 20-40% more for the same skill stack. Pair role pivot research (`["data engineer", "ML engineer", "AI engineer"]`) with the emerging-skill-gap report to see which adjacent role rewards your existing skills best.
- **VCs and PE firms (hiring signals as portfolio diligence)** -- monitor portfolio companies' hiring volume, role mix, and salary bands as a leading indicator of growth, pivots, or burn. Schedule weekly compare-mode runs across portfolio cohorts and feed the Markdown briefing straight into your investment-team Slack - cheaper and faster than LinkedIn Talent Insights ($1k+/month enterprise tier).
- **Market researchers and equity analysts** -- track hiring trends as a leading economic indicator. Run weekly `["software engineer", "sales", "operations"]` comparisons across the FAANG cohort or any sector and track shifts in volume, salary growth, and remote ratio in a longitudinal dataset.
- **Workforce-development organizations and bootcamps** -- inform curriculum design with current skill demand. The 70+ skill detection library shows which programming languages, frameworks, and AI/ML tools are accelerating in your local market - informing course updates without buying a Burning Glass / Lightcast license.
- **Talent acquisition leaders (alternative to LinkedIn Talent Insights / Glassdoor for Employers)** -- LinkedIn Talent Insights starts at ~$1k/seat/month; Glassdoor for Employers bundles reviews with limited salary intelligence. This actor delivers structured cross-role/cross-geo salary + skill data on demand at $0.003/job, ready for direct ingestion into your BI stack or feeding Slack/Notion briefings via the included webhook.
- **Salary researchers, academic economists, and consultants** -- aggregate Indeed postings across roles, locations, experience levels, and time. Build longitudinal compensation databases for wage research, remote-work adoption studies, or client-facing market intelligence reports - at a fraction of the cost of API access from Indeed Hiring Lab or Burning Glass.

### Input

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `searchQuery` | string | -- (required in single-query mode) | Job title or keywords (e.g., "software engineer", "marketing manager"). Hidden aliases: `query`, `q`, `search`, `keyword`, `searchTerm`, `jobTitle`. |
| `queries` | string\[] | -- | **Compare mode**: list of job titles to run in parallel. Requires `locations` also set. |
| `locations` | string\[] | -- | **Compare mode**: list of locations to run in parallel. Requires `queries` also set. |
| `location` | string | `Remote` | City, state, zip code, or "remote" (single-query mode only). Remote and fresh-date searches are the safest first run. |
| `country` | select | `us` | Indeed country domain: `us`, `uk`, `ca`, `au`, `de`, `fr`, `nl`, `in` |
| `maxJobs` | integer | 3 | Maximum jobs to scrape per cell (1-100). In compare mode this applies per (query, location) cell. Start small, then scale after your target query is returning data. Hidden aliases: `maxItems`, `maxResults`. |
| `datePosted` | select | `1` | Posting recency filter: `1` (24h), `3` (3 days), `7` (7 days), `14` (14 days), `any` |
| `jobType` | select | `any` | Job type: `any`, `fulltime`, `parttime`, `contract`, `internship`, `temporary` |
| `experienceLevel` | select | `any` | Experience: `any`, `entry`, `mid`, `senior` |
| `salaryMin` | integer | -- | Minimum salary filter in local currency |
| `remoteFilter` | select | `any` | Work arrangement: `any`, `remote`, `hybrid`, `in_person` |
| `sortBy` | select | `relevance` | Sort by: `relevance` or `date` |
| `fetchDetails` | boolean | false | Backfill salary from job detail pages when the list card is missing it (common on sponsored/pagead cards like nursing roles). Adds ~1 extra request per missing-salary job. Off by default to keep runs fast. Hidden alias: `includeDetails`. |
| `exportFormat` | select | `none` | **Compare mode only**: render a briefing artifact. Options: `none`, `csv`, `markdown`, `both`. Charged $0.01 per artifact. |
| `artifactWebhookUrl` | string | -- | **Compare mode only**: optional HTTPS webhook to POST artifact URLs after generation. Wire to Slack / Zapier / Make for autonomous weekly compensation briefings. Non-fatal on failure. Hidden aliases: `webhookUrl`, `slackWebhookUrl`. |
| `enableAiAnalysis` | boolean | false | Generate AI market intelligence report (single-query) or comparison report (compare mode) |
| `llmProvider` | select | `openrouter` | AI provider: `openrouter`, `anthropic`, `google`, `openai`, or `ollama` |
| `llmModel` | string | -- | Override default AI model (leave empty for recommended default) |
| `openrouterApiKey` | string | -- | OpenRouter API key - set `OPENROUTER_API_KEY` env var OR `openrouterApiKey` input. Get one at https://openrouter.ai/keys. |
| `anthropicApiKey` | string | -- | Anthropic API key - set `ANTHROPIC_API_KEY` env var OR `anthropicApiKey` input. Get one at https://console.anthropic.com/settings/keys. |
| `googleApiKey` | string | -- | Google AI API key - set `GOOGLE_API_KEY` env var OR `googleApiKey` input. Get one at https://aistudio.google.com/app/apikey. |
| `openaiApiKey` | string | -- | OpenAI API key - set `OPENAI_API_KEY` env var OR `openaiApiKey` input. Get one at https://platform.openai.com/api-keys. |
| `ollamaBaseUrl` | string | `http://localhost:11434` | Ollama base URL for self-hosted AI. Set `ollamaBaseUrl` input or run Ollama locally. Install at https://ollama.com/download. |
| `proxyConfiguration` | proxy | RESIDENTIAL | Proxy settings. Indeed aggressively blocks datacenter IPs - RESIDENTIAL is strongly recommended. When Apify Proxy is enabled and no country is set, the actor auto-selects the exit country for the chosen Indeed domain. |

#### Full Example Input

```json
{
    "searchQuery": "data analyst",
    "location": "Remote",
    "country": "us",
    "maxJobs": 3,
    "datePosted": "1",
    "jobType": "any",
    "experienceLevel": "any",
    "remoteFilter": "any",
    "sortBy": "relevance",
    "enableAiAnalysis": true,
    "llmProvider": "openrouter",
    "openrouterApiKey": "sk-or-..."
}
```

### Output

Each job is pushed as a separate dataset item:

```json
{
    "job_id": "abc123def456",
    "title": "Senior Software Engineer",
    "company_name": "TechCorp Inc.",
    "company_rating": 4.2,
    "location": "San Francisco, CA",
    "is_remote": false,
    "salary_text": "$150,000 - $200,000 a year",
    "salary_min": 150000,
    "salary_max": 200000,
    "salary_period": "yearly",
    "salary_currency": "USD",
    "currency": "USD",
    "job_type": "full-time",
    "description_snippet": "We are looking for a Senior Software Engineer to join...",
    "posted_date": "2026-04-08",
    "job_url": "https://www.indeed.com/viewjob?jk=abc123def456",
    "url": "https://www.indeed.com/viewjob?jk=abc123def456",
    "benefits": ["Health insurance", "401(k)"],
    "skills_mentioned": ["Python", "AWS", "Docker", "Kubernetes", "React"],
    "search_query": "software engineer",
    "search_location": "San Francisco, CA",
    "search_country": "us",
    "scraped_at": "2026-04-10T12:00:00+00:00"
}
```

#### Output field reference

| Field | Type | Description |
|-------|------|-------------|
| `job_id` | string | 16-char Indeed job key (e.g. `d55eb1a8c9e4bbe8`) |
| `title` | string | Job title as posted |
| `company_name` | string | Employer name |
| `company_rating` | float | Indeed employer rating (1.0-5.0) when available |
| `location` | string | City, state / region as posted |
| `is_remote` | boolean | True if the listing is tagged remote |
| `salary_text` | string | Raw salary string from the listing |
| `salary_min` / `salary_max` | number | Parsed numeric salary range |
| `salary_period` | string | `hourly`, `daily`, `weekly`, `monthly`, or `yearly` |
| `salary_currency` | string | ISO-ish currency code (USD, GBP, EUR, etc.) - legacy field |
| `currency` | string | Portfolio-standard ISO 4217 currency code - prefer this |
| `job_type` | string | `full-time`, `part-time`, `contract`, `internship`, `temporary` |
| `description_snippet` | string | First ~500 chars of the job description |
| `posted_date` | string | ISO 8601 date derived from Indeed's "X days ago" label |
| `job_url` | string | Direct `viewjob?jk=...` link - legacy field |
| `url` | string | Portfolio-standard URL field - prefer this |
| `benefits` | string\[] | Extracted perk labels (health, 401k, RSU, etc.) |
| `skills_mentioned` | string\[] | 70+ tech + business skills detected in title + description |
| `search_query` / `search_location` / `search_country` | string | Echo of the original request |
| `scraped_at` | string | ISO 8601 UTC timestamp |

**Field migration note**: `salary_currency` and `job_url` are retained for backward compatibility, but both dual-emit the portfolio-standard `currency` and `url` alongside. New integrations should read `currency` and `url`; legacy names will be dropped in a future release.

#### AI Market Intelligence Output

When `enableAiAnalysis` is enabled, an additional dataset item contains a comprehensive market report including:

- **Salary benchmarking** -- 25th, 50th, 75th, and 90th percentile salary ranges for the searched role
- **Top skills by frequency** -- ranked list of the most mentioned technical and professional skills
- **Company hiring volume** -- which companies are posting the most jobs and at what salary levels
- **Remote vs on-site ratios** -- breakdown of work arrangement preferences in the market
- **Top-paying listings** -- highest compensation packages with company and requirements details
- **Market temperature** -- hiring velocity, competition level, and demand indicators
- **Actionable recommendations** -- tailored advice for job seekers, recruiters, and hiring managers

#### Cost and Pricing

This actor uses pay-per-event pricing with no separate Actor rental fee. Starter (Bronze), Scale (Silver) and Business (Gold) receive 20%, 30% and 40% off all events below. Platinum and Diamond use the Business/Gold amounts. Discounts depend on your Apify subscription plan, not a per-run quantity threshold. The [live Pricing tab](https://apify.com/harvestlab/indeed-scraper/pricing) shows your applicable prices.

All amounts are USD per event. Optional AI-provider charges are separate from these Actor events.

| Event | Free / base | Starter / Bronze | Scale / Silver | Business / Gold | When it applies |
| --- | ---: | ---: | ---: | ---: | --- |
| `job-scraped` | $0.003 | $0.0024 | $0.0021 | $0.0018 | Each deduplicated job listing extracted |
| `ai-analysis-completed` | $0.05 | $0.04 | $0.035 | $0.03 | A single-query AI market intelligence report is generated |
| `market-comparison-report` | $0.10 | $0.08 | $0.07 | $0.06 | A multi-query compare-mode report is generated |
| `export-csv-generated` | $0.01 | $0.008 | $0.007 | $0.006 | Each compare-mode CSV salary-matrix artifact |
| `export-markdown-generated` | $0.01 | $0.008 | $0.007 | $0.006 | Each compare-mode Markdown briefing artifact |

**vs. commercial alternatives**: Indeed's Employer API requires partner approval and starts at enterprise pricing ($5k+/mo); Adzuna API charges $0.01-$0.05/job; Reed.co.uk charges £500+/mo for bulk job data; Brilliant.jobs data feeds start at $499/mo. This actor uses pay-per-event - $0.003/job listing with no subscription and no partner approval needed.

**Base-rate event subtotal examples:** Multiply these Actor-event subtotals by 0.8 on Starter, 0.7 on Scale or 0.6 on Business. For example, 100 jobs without AI or exports cost $0.30 at base rates, $0.24 on Starter, $0.21 on Scale or $0.18 on Business. These are event subtotals, not all-in quotes; optional external-provider charges are separate.

| Scenario | Jobs | AI Event | Export | Base event subtotal |
|----------|------|----------|--------|-----------:|
| Quick salary check | 10 | none | none | $0.03 |
| Role market analysis (single query) | 25 | $0.05 ai-analysis | none | $0.125 |
| Full market intelligence (single query) | 50 | $0.05 ai-analysis | none | $0.20 |
| Large-scale data collection | 100 | $0.05 ai-analysis | none | $0.35 |
| Compare 3 roles x 4 cities at 25/cell | 300 | $0.10 comparison | none | $1.00 |
| Weekly Slack briefing: 3 roles x 4 cities | 300 | $0.10 comparison | CSV+MD ($0.02) | $1.02 |
| Daily Slack tracker: 2 roles x 3 cities | 150 | $0.10 comparison | CSV+MD ($0.02) | $0.57 |

#### How we compare to other Indeed scrapers on Apify

| Scraper | Price | Model | AI Analysis |
|---------|-------|-------|-------------|
| **This actor** | **Base: $3/1K jobs + $0.05/AI report; plan discounts above** | Pay-per-event, no Actor rental fee | Yes (5 providers) |
| `misceres/indeed-scraper` | $3/1K | Pay-per-result | No |
| `borderline/indeed-scraper` | $5/1K | Pay-per-result | No |
| `memo23/apify-indeed-cheerio` | $29/month + usage | Rental + usage | No |
| `curious_coder/indeed-scraper` | $20/month + usage | Rental + usage | No |

### Quick Start

The simplest way to get started -- search for a single job title:

```json
{
    "searchQuery": "data analyst",
    "location": "Remote",
    "maxJobs": 3,
    "datePosted": "1"
}
```

This scrapes a small set of fresh remote data analyst job listings sorted by relevance.

#### Weekly Comp-Briefing Template (one-click clone)

Saves a recurring task that runs every Monday 8am, comparing 3 roles x 3 locations, exports to CSV + Markdown, posts the briefing to your Slack via webhook.

```json
{
  "queries": ["data engineer", "ML engineer", "AI engineer"],
  "locations": ["New York, NY", "San Francisco, CA", "Remote"],
  "country": "us",
  "datePosted": "7",
  "exportFormat": "both",
  "enableAiAnalysis": true,
  "llmProvider": "openrouter",
  "openrouterApiKey": "sk-or-...",
  "artifactWebhookUrl": "https://hooks.slack.com/services/YOUR/WEBHOOK/HERE",
  "maxJobs": 50
}
```

1. Open the actor in Apify Console.
2. Click "Save & Build".
3. Paste the JSON above into Input.
4. Set Schedule -> Cron `0 8 * * MON`.
5. Replace the webhook URL with your Slack incoming webhook.

**Base-rate event subtotal:** The 3-role x 3-location weekly template above can return up to 450 jobs: 450 x $0.003 + one $0.10 compare report + $0.02 for CSV/Markdown exports = $1.47. The same events cost $1.176 on Starter, $1.029 on Scale or $0.882 on Business. These figures exclude external AI-provider charges and are not all-in run quotes.

#### Multi-Query Compare Mode (v1.6+)

Supply `queries` and `locations` as arrays to run the full N x M grid in one job and get a single cross-role / cross-location AI comparison report:

```json
{
    "queries": ["data scientist", "ML engineer", "AI researcher"],
    "locations": ["New York, NY", "San Francisco, CA", "Austin, TX", "Remote"],
    "country": "us",
    "maxJobs": 25,
    "enableAiAnalysis": true,
    "llmProvider": "openrouter",
    "openrouterApiKey": "sk-or-..."
}
```

The actor runs 3 x 4 = 12 parallel searches (max 8 concurrent), writes every job to the dataset tagged with its `search_query` and `search_location`, and pushes a `market_comparison_report` item containing the median-salary matrix, highest-paying combinations, geo premium analysis, hottest skills overall, and emerging-skill gaps between roles. Grid is capped at 16 cells to protect proxy budget.

Compare mode charges a single `$0.10` `market-comparison-report` event in place of the `$0.05` `ai-analysis-completed` event.

#### Compare-Mode Export Artifacts (v1.7+)

Autonomous weekly compensation briefings. Set `exportFormat` to `"csv"`, `"markdown"`, or `"both"` and the actor renders a ready-to-share artifact on top of the raw dataset:

- `"csv"` -- RFC-4180 salary-matrix spreadsheet. Loads cleanly into Google Sheets, Tableau, Power BI, Excel.
- `"markdown"` -- README-style executive brief designed for direct paste into Slack / Notion / email.
- `"both"` -- emit both. Charged $0.01 per artifact.

Each artifact is available from the run storage as `compare-{run_id}.csv` / `.md`, and the public URL is included as an `export_artifact` dataset item. CSV schema 1.1 keeps one row per query/location cell and adds `salary_currency_status` plus JSON `salary_currency_groups`. Each observed currency retains separate annualized counts, quantiles and original salary observations. Mixed or unknown units have no pooled cell median; no exchange-rate conversion or cross-currency pay ranking is performed. Unknown-unit amounts remain in the groups, while their aggregate quantiles stay null.

Pair with the optional `artifactWebhookUrl` input and Apify Scheduler for an autonomous weekly comp-briefing pipeline:

```json
{
    "queries": ["data scientist", "ML engineer"],
    "locations": ["New York, NY", "San Francisco, CA", "Remote"],
    "country": "us",
    "maxJobs": 25,
    "enableAiAnalysis": true,
    "llmProvider": "openrouter",
    "openrouterApiKey": "sk-or-...",
    "exportFormat": "both",
    "artifactWebhookUrl": "https://hooks.slack.com/services/T00/B00/XXXX"
}
```

Every Monday at 8 AM, the actor scrapes 2 roles x 3 cities, writes a Markdown brief + CSV matrix to key-value storage, and POSTs the URLs to your Slack / Zapier / Make webhook. At base rates, the event subtotal is $0.57 for 25 jobs per cell or $1.02 for 50 jobs per cell, including one comparison report and both export artifacts. Apply your plan discount from the pricing table; external AI-provider charges are separate.

#### More Examples

**Basic job search** - scrape software engineer jobs in San Francisco:

```json
{
    "searchQuery": "software engineer",
    "location": "San Francisco, CA",
    "maxJobs": 25,
    "country": "us"
}
```

**Multi-query market research** - compare salaries across roles in one city:

```json
{
    "queries": ["data scientist", "machine learning engineer", "AI researcher"],
    "locations": ["New York, NY"],
    "maxJobs": 10,
    "country": "us"
}
```

**Remote jobs with AI analysis** - find remote Python developer jobs and get market intelligence:

```json
{
    "searchQuery": "Python developer remote",
    "maxJobs": 50,
    "enableAiAnalysis": true,
    "llmProvider": "openrouter",
    "openrouterApiKey": "YOUR_KEY"
}
```

#### MCP Quickstart - call this actor from Claude / Cursor / ChatGPT

Open Apify's hosted MCP server with this actor selected:

```text
https://mcp.apify.com?tools=harvestlab/indeed-scraper
```

Then prompt the agent:

> "Use the harvestlab/indeed-scraper actor on Apify to compare salaries for 'machine learning engineer' between New York, San Francisco, and Remote with AI matrix analysis and CSV export. Push the results back as JSON."

Through Apify MCP, the agent will discover the actor schema, generate the right input, run it, and pipe the typed output back into your conversation.

### Use with AI agents

indeed-scraper's output is agent-ready - every run returns structured jobs plus an optional cross-role / cross-geo salary matrix and AI compensation analysis as JSON, across 8 country domains (US/UK/CA/AU/DE/FR/NL/IN). Wrap this actor as a tool inside a VC-portfolio-diligence agent, talent-acquisition workflow, or salary-benchmarking pipeline and replace a Levels.fyi Comp ($30k+/yr), Payscale ($1.5k+/yr), or LinkedIn Talent Insights ($1k+/mo) seat with a few lines of code - geo-arbitrage rankings included.

The Python examples below use `apify-client` 3.x, `langchain-core` and
`langgraph` (tested with 3.4.0 / 1.7.6 / 1.2.20). Set `APIFY_TOKEN` privately in
your environment. Calling the tool or graph starts a billable Actor run;
constructing them does not. Start with one role and one location.

The default is **AI off**: native CSV supplies the observed, annualized salary
matrix, and missing salaries remain `None`. The parser accepts legacy CSV 1.0
(with group provenance marked `legacy_unverified`) and currency-aware CSV 1.1.
For 1.1, inspect `salary_currency_groups` for each currency's observations;
mixed/unknown cells legitimately have null top-level quantiles. CSV and Markdown each add their
existing $0.01 artifact event, alongside job events and applicable platform usage.
Optional AI requires `enableAiAnalysis=True` and your local
`OPENROUTER_API_KEY`; hosted-provider token charges are separate. Its completed
report is optional interpretation, not a replacement for the observed matrix.

**LangChain - structured salary-comparison tool:**

```python
import csv
import io
import json
import os
from datetime import timedelta
from decimal import Decimal, InvalidOperation
from apify_client import ApifyClient
from langchain_core.tools import tool

client = ApifyClient(os.environ["APIFY_TOKEN"])

def parse_salary_matrix(record: dict, expected: set[tuple[str, str]], country: str,
                        max_jobs: int) -> list[dict]:
    if (not isinstance(record, dict) or not isinstance(record.get("value"), str)
            or record.get("content_type", "").split(";")[0].lower() != "text/csv"
            or len(record["value"]) > 100000):
        raise RuntimeError("Expected the completed run's native salary CSV")
    reader = csv.DictReader(io.StringIO(record["value"]))
    required = {"query", "location", "country", "jobs_count", "salaried_jobs_count",
                "median_yearly", "p25", "p75", "currency", "schema_version"}
    if not required.issubset(reader.fieldnames or []):
        raise ValueError("Incomplete salary CSV columns")
    rows, seen, matrix = list(reader), set(), []
    if len(rows) != len(expected):
        raise ValueError("Incomplete comparison grid")

    def amount(value):
        try:
            number = Decimal(str(value).replace(",", "")) if value is not None and str(value).strip() else None
        except InvalidOperation:
            raise ValueError("Invalid salary CSV amount") from None
        if number is not None and (not number.is_finite() or number < 0):
            raise ValueError("Invalid salary CSV amount")
        return float(number) if number is not None else None

    for row in rows:
        key = (row["query"], row["location"])
        version = row["schema_version"]
        if (key not in expected or key in seen or row["country"] != country.upper()
                or version not in {"1.0", "1.1"}):
            raise ValueError("Unexpected salary CSV cell")
        seen.add(key)
        jobs, salaried = int(row["jobs_count"]), int(row["salaried_jobs_count"])
        if not 0 <= salaried <= jobs <= max_jobs:
            raise ValueError("Invalid salary observation counts")
        values = {field: amount(row[field]) for field in ("median_yearly", "p25", "p75")}
        status, groups = "legacy_unverified", None
        if version == "1.0":
            # Legacy CSV has no currency groups: preserve its fields, infer none.
            if (salaried == 0 and any(v is not None for v in values.values())) or (
                    salaried > 0 and any(v is None for v in values.values())):
                raise ValueError("Salary counts and amounts disagree")
        else:
            if not {"salary_currency_status", "salary_currency_groups"}.issubset(row):
                raise ValueError("Missing salary currency groups")
            groups = json.loads(row["salary_currency_groups"])
            if not isinstance(groups, list):
                raise ValueError("Invalid salary currency groups")
            codes, counted = set(), 0
            for group in groups:
                if not isinstance(group, dict):
                    raise ValueError("Invalid salary currency group")
                code, count = group.get("currency"), group.get("salaried_jobs_count")
                if (not isinstance(code, str) or not code or code in codes
                        or type(count) is not int or not 0 < count <= salaried):
                    raise ValueError("Invalid salary currency group counts")
                codes.add(code)
                counted += count
                quantiles = [amount(group.get(field)) for field in ("median_yearly", "p25", "p75")]
                if (code == "UNKNOWN" and any(v is not None for v in quantiles)) or (
                        code != "UNKNOWN" and any(v is None for v in quantiles)):
                    raise ValueError("Unknown units must not be pooled")
                yearly = group.get("yearly_values")
                observations = group.get("salary_observations")
                if (not isinstance(yearly, list) or len(yearly) != count
                        or any(amount(v) is None for v in yearly)
                        or not isinstance(observations, list) or len(observations) != count):
                    raise ValueError("Missing original salary observations")
                for observation in observations:
                    if (not isinstance(observation, dict)
                            or amount(observation.get("yearly_estimate")) is None
                            or all(amount(observation.get(k)) is None for k in ("salary_min", "salary_max"))):
                        raise ValueError("Invalid original salary observation")
            status = ("not_available" if not groups else "mixed" if len(groups) > 1
                      else "unknown" if "UNKNOWN" in codes else "single")
            currency = (groups[0]["currency"] if status == "single" else "MULTIPLE" if status == "mixed"
                        else "UNKNOWN" if status == "unknown" else "")
            if (counted != salaried or row["salary_currency_status"] != status
                    or row["currency"] != currency
                    or (status == "single" and any(v is None for v in values.values()))
                    or (status != "single" and any(v is not None for v in values.values()))):
                raise ValueError("Salary currency counts and aggregates disagree")
        matrix.append({"query": key[0], "location": key[1], "country": row["country"],
                       "jobs_count": jobs, "salaried_jobs_count": salaried,
                       "currency": row["currency"], "salary_currency_status": status,
                       "salary_currency_groups": groups, **values})
    return matrix

@tool("compare_indeed_salaries")
def compare_indeed_salaries_tool(queries: list[str], locations: list[str], country: str = "us",
                                maxJobs: int = 2, enableAiAnalysis: bool = False) -> dict:
    """Compare observed Indeed salary cells; optionally add a BYOK AI report."""
    queries = list(dict.fromkeys(q.strip() for q in queries if q.strip()))
    locations = list(dict.fromkeys(loc.strip() for loc in locations if loc.strip()))
    country = country.lower()
    if (country not in {"us", "uk", "ca", "au", "de", "fr", "nl", "in"}
            or not queries or not locations or len(queries) * len(locations) > 4
            or not 1 <= maxJobs <= 3):
        raise ValueError("Use 1-4 query/location cells, 1-3 jobs per cell and a supported country")
    inputs = {"queries": queries, "locations": locations, "country": country, "maxJobs": maxJobs,
              "fetchDetails": False, "exportFormat": "both", "enableAiAnalysis": enableAiAnalysis,
              "artifactWebhookUrl": "", "webhookUrl": "", "slackWebhookUrl": "",
              "proxyConfiguration": {"useApifyProxy": True, "apifyProxyGroups": ["RESIDENTIAL"]}}
    if enableAiAnalysis:
        key = os.environ.get("OPENROUTER_API_KEY", "").strip()
        if not key:
            raise ValueError("Set OPENROUTER_API_KEY privately before requesting optional AI")
        inputs.update(llmProvider="openrouter", openrouterApiKey=key)
    run = client.actor("harvestlab/indeed-scraper").call(
        run_input=inputs, memory_mbytes=256, run_timeout=timedelta(seconds=180),
        max_total_charge_usd=Decimal("0.25" if enableAiAnalysis else "0.10"), restart_on_error=False)
    if (run is None or run.status != "SUCCEEDED" or not run.default_dataset_id
            or not run.default_key_value_store_id):
        raise RuntimeError("Run did not complete; inspect its status before consuming output")
    items = list(client.dataset(run.default_dataset_id).iterate_items(limit=100))
    jobs = [item for item in items if item.get("job_id") and not item.get("diagnostic_item")]
    artifacts = [item for item in items if item.get("type") == "export_artifact"]
    if len(artifacts) != 1 or artifacts[0].get("run_id") != run.id:
        raise RuntimeError("No complete comparison export; inspect this run's dataset diagnostics")
    artifact = artifacts[0]
    base = f"https://api.apify.com/v2/key-value-stores/{run.default_key_value_store_id}/records/compare-{run.id}"
    if artifact.get("csv_url") != base + ".csv" or artifact.get("markdown_url") != base + ".md":
        raise RuntimeError("Unexpected comparison artifact identity")
    try:
        record = client.key_value_store(run.default_key_value_store_id).get_record(f"compare-{run.id}.csv")
    except Exception:
        raise RuntimeError("Salary CSV unavailable; retain the completed run for inspection") from None
    expected = {(q, loc) for q in queries for loc in locations}
    matrix = parse_salary_matrix(record, expected, country, maxJobs)
    if (artifact.get("grid_cells") != len(matrix) or artifact.get("total_jobs") != len(jobs)
            or sum(cell["jobs_count"] for cell in matrix) != len(jobs)):
        raise RuntimeError("Comparison dataset and CSV counts disagree")
    analysis = next((item["analysis"] for item in items
                     if item.get("type") == "market_comparison_report" and not item.get("error")
                     and isinstance(item.get("analysis"), dict) and item["analysis"]
                     and not any(k in item["analysis"] for k in ("error", "parse_error", "raw_analysis", "raw_response"))), None)
    return {"run_id": run.id, "jobs": jobs, "salary_matrix": matrix,
            "csv_url": artifact["csv_url"], "markdown_url": artifact["markdown_url"],
            "ai_analysis": analysis,
            "ai_status": "completed" if analysis else ("not_returned" if enableAiAnalysis else "not_requested")}

## result = compare_indeed_salaries_tool.invoke({"queries": ["Python developer"], "locations": ["Remote"]})
```

The limits above bound this example, not the Actor's full schema. The charge
limit covers Actor events; inspect actual platform/provider usage separately.
Runs without a completed native export raise an explicit error. Returned
zero-job cells retain zero counts and `None` salaries; they are not observations
of market salary. Retrying an export does not require starting another scrape. `salary_matrix`
contains the native CSV's per-cell counts, currency, median and quartiles, with
blank salaries represented as `None`. Keep currencies explicit and do not treat
undisclosed salaries as zero or this small sample as a population benchmark.

**LangGraph - observed salary-matrix node:**

```python
from typing import TypedDict
from langgraph.graph import StateGraph, START, END

## Run the LangChain block above first; this node reuses its bounded tool.
class SalaryState(TypedDict, total=False):
    role: str
    locations: list[str]
    country: str
    enableAiAnalysis: bool
    run_id: str
    jobs: list[dict]
    salary_matrix: list[dict]
    ai_analysis: dict | None
    ai_status: str
    geo_premium_analysis: dict | None
    csv_url: str
    markdown_url: str

def salary_benchmark_node(state: SalaryState) -> SalaryState:
    result = compare_indeed_salaries_tool.invoke({
        "queries": [state["role"]], "locations": state["locations"],
        "country": state.get("country", "us"), "maxJobs": 2,
        "enableAiAnalysis": state.get("enableAiAnalysis", False)})
    analysis = result["ai_analysis"] or {}
    return {**state, **result, "geo_premium_analysis": analysis.get("geo_premium_analysis")}

graph = StateGraph(SalaryState)
graph.add_node("salary_benchmark", salary_benchmark_node)
graph.add_edge(START, "salary_benchmark")
graph.add_edge("salary_benchmark", END)
salary_benchmark = graph.compile()
## result = salary_benchmark.invoke({"role": "Python developer", "locations": ["Remote"], "country": "us"})
```

The graph returns the observed `salary_matrix` even when AI is off or no AI
report completes. With explicit BYOK AI, additional fields live in the completed
record's `analysis`, including `geo_premium_analysis`; they are not top-level job
fields. Constructing a graph does not schedule runs, retain prior observations
or send notifications. Save or deliver its results only through your authorized
workflow. Source blocking, sample size and employer salary disclosure still limit
the comparison.

See Apify's [`actor-templates/js-langchain`](https://github.com/apify/actor-templates/tree/master/templates/js-langchain) and [`js-langgraph-agent`](https://github.com/apify/actor-templates/tree/master/templates/js-langgraph-agent) for full reference setups.

### Troubleshooting

**Getting blocked or 0 results returned**
Indeed can block automated access with CAPTCHA and rate limits. Set `proxyConfiguration` with the `RESIDENTIAL` proxy group. Run with a low `maxJobs` (10-20) first to verify the proxy path is working. US proxies work best for indeed.com; match country to your target Indeed domain.

**Salary data missing for most jobs**
Salary is employer-provided and often not disclosed. Indeed reports that only ~30-40% of job postings include salary data. The actor captures salary when present; missing salary results in `null` fields, not an error. Enable `fetchDetails` to backfill salary from job detail pages for sponsored listings that omit salary on the list card.

**Jobs appear duplicated across result pages**
Indeed sometimes serves the same promoted job listing on multiple search pages. The actor deduplicates by `job_id` within a single run. If you run multiple queries, cross-run dedup is your responsibility (use `job_id` as the unique key).

**AI comparison fails for multi-query runs**
Compare mode starts when both `queries` and `locations` are nonempty; one role and one location is a valid 1×1 comparison. Native CSV/Markdown salary matrices work with AI off. A completed AI comparison additionally requires `enableAiAnalysis: true` and a configured hosted-provider key (or your reachable self-hosted Ollama); its fields are nested under `analysis` in a `type: "market_comparison_report"` record.

**CSV/Markdown export not appearing in dataset**
Compare-mode files are stored in the run's key-value store. A successful export adds a dataset reference with `type: "export_artifact"`, `csv_url` and `markdown_url`; inspect the raw default dataset JSON if a selected view hides that record. File keys are `compare-{run_id}.csv` / `.md`. The salary matrix works with AI off; missing exports should be investigated in that exact run's diagnostics rather than inferred from an AI report.

**Indeed bot detection - persistent failures**
Indeed can block requests with CAPTCHA challenges and IP reputation checks. If you experience persistent 403 errors: (1) ensure you are using RESIDENTIAL proxies, not datacenter; (2) retry with `location: "Remote"`, `datePosted: "1"`, and a lower `maxJobs` such as 3-5; (3) try again later as proxy IP pools rotate; (4) try a different country domain. There is no guaranteed workaround, but residential proxies are materially more reliable than datacenter exits.

**Frequently Asked Questions**

*How accurate is the salary data?* The actor extracts salary information directly from Indeed job postings. Salary data is only available for jobs that include salary in their listings -- typically 30-60% of postings depending on the role and market. The automatic parser normalizes all formats (hourly, yearly, K notation, ranges) into consistent structured fields.

*Which countries are supported?* The actor supports 8 Indeed country domains: United States (us), United Kingdom (uk), Canada (ca), Australia (au), Germany (de), France (fr), Netherlands (nl), and India (in). Each country uses the localized Indeed domain for accurate local results.

*How does skill detection work?* The actor scans job titles + description snippets against a library of 70+ technical and professional skills including programming languages, cloud platforms, data/ML tooling, frameworks, infrastructure tools, AI tools, and business systems.

*How does compare mode work?* Set `queries` (e.g. `["data scientist", "ML engineer", "AI researcher"]`) and `locations` (e.g. `["New York, NY", "San Francisco, CA", "Remote"]`) instead of the single `searchQuery` / `location` fields. Arrays take precedence. The actor runs the full N x M grid of searches in parallel (8 concurrent, capped at 16 cells), tags every job with its `search_query` and `search_location`, and - if `enableAiAnalysis` is true - produces a single `market_comparison_report` with a median-salary matrix, highest-paying combinations, geo premium analysis, hottest skills, and emerging skill gap callouts.

*Can I track hiring trends over time?* Yes. Schedule regular runs on Apify (weekly or monthly) for the same search queries and locations. Over time, you build a longitudinal dataset showing changes in job volume, salary ranges, skill demand, and remote work adoption.

***

### Scheduling and webhooks

Set up weekly Indeed runs in Apify Console to keep a live pipeline of fresh job postings for a target role and location. Use Apify webhooks for run-completion delivery, or use `artifactWebhookUrl` in compare mode to POST CSV/Markdown artifact URLs into Slack, n8n, Zapier, Make, Notion, or Airtable. Typical pipeline: AI Engineer San Francisco weekly -> Apify webhook -> Slack alert for postings with salary above USD 180k.

***

### Legal and Compliance

This actor scrapes publicly available data. By using this actor, you agree to the following:

- **Your responsibility**: You are solely responsible for ensuring your use complies with all applicable laws, regulations, and the target website's terms of service. This includes but is not limited to GDPR (EU), CCPA (California), and other data protection laws in your jurisdiction.
- **No legal advice**: This actor does not constitute legal advice. Consult a qualified attorney if you have questions about the legality of your specific use case.
- **Intended use**: This actor is designed for legitimate business purposes such as market research, competitive analysis, and academic research using publicly accessible data.
- **Data handling**: You are responsible for how you store, process, and share any data collected. Ensure you have a lawful basis for processing any personal data under applicable privacy laws.
- **Rate limiting**: This actor implements polite crawling practices including request delays and retry backoff to minimize impact on target servers.
- **No warranty**: This actor is provided "as is" without warranty. Data accuracy depends on the target website's content and structure.
- **Job data**: Indeed's terms of service restrict automated data collection. Job listings are provided for informational and research purposes. Do not use scraped data to create competing job board services.
- **Personal data notice**: Job listings may contain names of hiring managers or company representatives. Under GDPR and similar regulations, even publicly available personal data may be subject to data protection requirements. Ensure you have a lawful basis (such as legitimate interest) for collecting and processing this data. Implement appropriate data retention policies.

#### Limitations

- **Indeed aggressively blocks automated access.** Residential proxies are required and even then, some requests may be blocked. The actor rotates proxy IPs between retries with exponential backoff. If you experience persistent 403 errors, try running again (different proxy IPs may succeed) or try a different country domain.
- Salary data is only available for jobs that include salary information in their listings (not all do).
- Skills detection is based on pattern matching from job description snippets, not full job descriptions -- some skills may be missed.
- AI analysis quality depends on the number of jobs scraped (25+ recommended) and the AI model chosen.
- Indeed's HTML structure may change; the actor uses multiple parsing strategies for resilience but temporary disruptions are possible.
- Maximum 100 jobs per run to maintain reasonable resource usage.
- Job posting recency depends on Indeed's internal date tracking, which may not always be precise.

#### Tips for Reliable Runs

- **Use specific job titles.** "Senior Data Engineer" returns more targeted results than generic terms like "data" or "engineer."
- **Filter by date posted.** Set `datePosted` to `1` or `3` to focus on active, current openings rather than stale listings and reduce blocked broad-result pages.
- **Enable AI analysis for salary benchmarking.** The AI report synthesizes salary data across all scraped jobs into actionable percentile ranges.
- **Scale after a small pass.** Start with 3-5 jobs to confirm the target query is reachable, then increase volume for skill-demand or AI benchmarking runs.
- **Compare across locations.** Run the same search query in multiple cities to understand geographic salary differences.
- **Use residential proxies for large runs.** For scraping 50+ jobs, residential proxies improve reliability.

#### Related Actors

- **[Monitor New Jobs Only](https://apify.com/harvestlab/ats-hiring-signal-monitor/examples/monitor-new-jobs-only)** — To follow selected employers over time, supply their supported ATS board URLs, save a baseline, and export newly observed roles on later runs. The first baseline and unchanged repeats return no job rows; source scan fees still apply. Company names from Indeed are not automatically converted to ATS boards.
- **[Website Contact Extractor](https://apify.com/harvestlab/contact-extractor)** — Extract public contact details from company websites you supply, with optional email checks and tech-stack signals. Available contacts depend on what each website publishes.

# Actor input Schema

## `searchQuery` (type: `string`):

Job title or keywords (e.g. "software engineer", "data scientist", "marketing manager")

## `query` (type: `string`):

CLI alias for searchQuery. Hidden from Console form.

## `q` (type: `string`):

CLI alias for searchQuery. Hidden from Console form.

## `search` (type: `string`):

CLI alias for searchQuery. Hidden from Console form.

## `keyword` (type: `string`):

CLI alias for searchQuery. Hidden from Console form.

## `searchTerm` (type: `string`):

CLI alias for searchQuery. Hidden from Console form.

## `jobTitle` (type: `string`):

CLI alias for searchQuery. Hidden from Console form.

## `queries` (type: `array`):

Optional list of job titles to search in parallel, e.g. \["data scientist", "ML engineer", "AI researcher"]. When provided alongside 'locations', the actor runs NxM searches and generates a single cross-query/cross-location comparison report (charged at $0.10 instead of $0.05). Leave empty to run a single searchQuery.

## `locations` (type: `array`):

Optional list of locations to search in parallel, e.g. \["New York, NY", "San Francisco, CA", "Austin, TX", "Remote"]. Combined with 'queries' to run a full NxM grid. Leave empty to use the single 'location' field.

## `location` (type: `string`):

City, state, or "remote" (e.g. "Remote", "New York, NY", "San Francisco, CA"). Single-query mode only - when Compare Mode 'locations' is set, this field is ignored. Start with Remote or a narrow city/date filter for the best first-run reliability.

## `city` (type: `string`):

CLI alias for location. Hidden from Console form.

## `where` (type: `string`):

CLI alias for location. Hidden from Console form.

## `country` (type: `string`):

Indeed country domain to search (e.g. United States uses indeed.com, United Kingdom uses uk.indeed.com, Germany uses de.indeed.com).

## `maxJobs` (type: `integer`):

Maximum number of job listings to scrape (1-100). Indeed shows ~15 results per page. Start with 3 to test current proxy/source availability before running larger jobs.

## `maxItems` (type: `integer`):

CLI alias for maxJobs. Hidden from Console form.

## `maxResults` (type: `integer`):

CLI alias for maxJobs. Hidden from Console form.

## `datePosted` (type: `string`):

Filter jobs by how recently they were posted. Useful for monitoring new openings daily/weekly.

## `jobType` (type: `string`):

Filter by employment type (e.g. Full-time for salaried W-2/permanent roles, Contract for 1099/freelance work, Internship for student positions).

## `experienceLevel` (type: `string`):

Filter by required experience: Entry level (0-2 years / new grad), Mid level (3-5 years), Senior level (6+ years / lead roles).

## `salaryMin` (type: `integer`):

Minimum annual salary in the local currency for the selected Country (USD for US, GBP for UK, EUR for DE/FR/NL, CAD for Canada, AUD for Australia, INR for India). Example: 80000 for $80k/year in the US. Leave empty for no salary floor.

## `remoteFilter` (type: `string`):

Filter by work arrangement: Remote (fully WFH), Hybrid (mix of office/home), In-person (on-site only).

## `sortBy` (type: `string`):

Result ordering. 'Relevance' (default) ranks by Indeed's match score against your keywords - best for broad keyword searches. 'Date posted' shows newest first - best for daily/weekly monitoring of new openings and avoiding stale listings.

## `fetchDetails` (type: `boolean`):

When enabled, for any job whose list-card didn't render a salary (common for sponsored /pagead ads like nursing roles), fetch the job detail page and try to extract salary from JSON-LD or the page body. Adds ~1 extra request per missing-salary job. Default off to keep runs fast.

## `includeDetails` (type: `boolean`):

Hidden CLI alias for fetchDetails.

## `exportFormat` (type: `string`):

When compare mode is active (queries + locations both set), render a ready-to-share salary matrix artifact in addition to the dataset. 'csv' = RFC-4180 salary-matrix spreadsheet (Google Sheets / Tableau / Excel friendly, one row per cell). 'markdown' = README-style executive brief for direct paste into Slack / Notion / email. 'both' = emit both. Each artifact is stored in the run's key-value store as `compare-{run_id}.csv` / `.md` with a public URL included in the dataset report. Charged $0.01 per artifact.

## `artifactWebhookUrl` (type: `string`):

Optional HTTPS webhook to POST the artifact URLs and run metadata after generation - wire it to Slack Incoming Webhook, Zapier, Make, or your own endpoint for autonomous weekly compensation briefings via Apify Scheduler. Webhook receives a JSON body: `{run_id, queries, locations, country, total_jobs, csv_url, markdown_url, message}`. Leave empty to skip. Failure to POST is non-fatal.

## `webhookUrl` (type: `string`):

Hidden CLI alias for artifactWebhookUrl.

## `slackWebhookUrl` (type: `string`):

Hidden CLI alias for artifactWebhookUrl.

## `enableAiAnalysis` (type: `boolean`):

Generate an AI-powered job market analysis report with salary benchmarking, skill trends, and hiring insights

## `llmProvider` (type: `string`):

Choose the provider for job market analysis. Hosted providers require your own API key; their model and token charges are separate from Actor AI events. Self-hosted Ollama needs no API key.

## `llmModel` (type: `string`):

Specific model to use. Leave empty for the provider default (google/gemini-3.8-flash for OpenRouter, claude-sonnet-5-5 for Anthropic, gemini-3.8-flash for Google AI, gpt-4o-mini for OpenAI, llama3.1 for Ollama).

## `openrouterApiKey` (type: `string`):

OpenRouter API key - set OPENROUTER_API_KEY env var OR openrouterApiKey input. Get one at https://openrouter.ai/keys.

## `anthropicApiKey` (type: `string`):

Anthropic API key - set ANTHROPIC_API_KEY env var OR anthropicApiKey input. Get one at https://console.anthropic.com/settings/keys.

## `googleApiKey` (type: `string`):

Google AI API key - set GOOGLE_API_KEY env var OR googleApiKey input. Get one at https://aistudio.google.com/app/apikey.

## `openaiApiKey` (type: `string`):

OpenAI API key - set OPENAI_API_KEY env var OR openaiApiKey input. Get one at https://platform.openai.com/api-keys.

## `ollamaBaseUrl` (type: `string`):

Ollama base URL for self-hosted AI. Set ollamaBaseUrl input or run Ollama locally. Install at https://ollama.com/download.

## `proxyConfiguration` (type: `object`):

Proxy settings. Indeed aggressively blocks datacenter IPs with 403/CAPTCHA walls - RESIDENTIAL (default) is strongly recommended. Leave Apify Proxy enabled unless you're supplying your own custom proxy list.

## Actor input object example

```json
{
  "searchQuery": "software engineer",
  "location": "Remote",
  "country": "us",
  "maxJobs": 3,
  "datePosted": "1",
  "jobType": "any",
  "experienceLevel": "any",
  "remoteFilter": "any",
  "sortBy": "relevance",
  "fetchDetails": false,
  "exportFormat": "none",
  "enableAiAnalysis": false,
  "llmProvider": "openrouter",
  "ollamaBaseUrl": "http://localhost:11434",
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}
```

# Actor output Schema

## `datasetOutput` (type: `string`):

Dataset containing scraped Indeed job postings, multi-query/multi-location compare-mode market comparison reports, AI analysis records, and CSV/Markdown export references.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "searchQuery": "software engineer",
    "location": "Remote",
    "ollamaBaseUrl": "http://localhost:11434",
    "proxyConfiguration": {
        "useApifyProxy": true,
        "apifyProxyGroups": [
            "RESIDENTIAL"
        ]
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("harvestlab/indeed-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "searchQuery": "software engineer",
    "location": "Remote",
    "ollamaBaseUrl": "http://localhost:11434",
    "proxyConfiguration": {
        "useApifyProxy": True,
        "apifyProxyGroups": ["RESIDENTIAL"],
    },
}

# Run the Actor and wait for it to finish
run = client.actor("harvestlab/indeed-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "searchQuery": "software engineer",
  "location": "Remote",
  "ollamaBaseUrl": "http://localhost:11434",
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}' |
apify call harvestlab/indeed-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,harvestlab/indeed-scraper"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/YOD7LoMbS6TmFieBC/builds/TA8sozarSB71Lupfw/openapi.json
