Website Job Extractor (HTTP)
Pricing
from $4.00 / 1,000 job extracteds
Website Job Extractor (HTTP)
Scrape job listings directly from company websites / career pages / ATS systems. Unlike job portals, letting you identify hiring intent the moment it happens. The freshest signal for B2B targeting. AI-extracted with automatic ATS detection and anti-hallucination validation.
Pricing
from $4.00 / 1,000 job extracteds
Rating
5.0
(1)
Developer
NanoScrape
Maintained by CommunityActor stats
3
Bookmarked
77
Total users
5
Monthly active users
4 days ago
Last modified
Categories
Share
Website Job Extractor
Extract structured job listings from any company career page using AI. Give it a website URL -- it finds career pages, detects ATS systems, handles pagination, and returns clean, structured job data.
Why this actor?
- Just provide a website URL -- the actor auto-discovers career pages from homepage navigation, subdomains (jobs.domain.ch, karriere.domain.ch), and embedded ATS portals
- 19 ATS systems detected -- automatically follows links to Personio, Greenhouse, Softgarden, Lever, BambooHR, and 14 more
- Anti-hallucination pipeline -- detects "no open positions" signals before calling the LLM, validates every extracted job, and assigns confidence scores
- Works worldwide -- multilingual career page discovery and extraction in 7 languages (DE, EN, FR, ES, IT, PT, NL)
Use with AI Agents (MCP)
Connect this actor to any MCP-compatible AI client — Claude Desktop, Claude.ai, Cursor, VS Code, LangChain, LlamaIndex, or custom agents.
Apify MCP server URL:
https://mcp.apify.com?tools=santamaria-automations/website-job-extractor
Example prompt once connected:
"Use
website-job-extractorto process data with website job extractor. Return results as a table."
Clients that support dynamic tool discovery (Claude.ai, VS Code) will receive the full input schema automatically via add-actor.
How it works
- Discover -- given a website URL, scans homepage navigation and probes common career subdomains to find career pages
- Detect -- identifies 19 ATS systems via URL patterns, iframes, links, and HTML signatures, then follows external portal links
- Gate -- checks for job-related keywords (m/w/d, Full-time, apply now) and screens for "no open positions" signals before invoking the LLM
- Extract -- sends cleaned HTML to an LLM (Gemini Flash, Groq, or OpenRouter) for structured extraction with anti-hallucination rules
- Validate -- post-extraction validation with confidence scoring, deduplication across pages, and optional keyword-based relevance filtering
- Paginate -- detects and follows next-page links up to 5 levels deep, staying within the per-company page budget
The actor runs HTTP-only (CheerioCrawler) with no browser overhead. The actor runs in 256 MB: oversized pages are read with a hard size cap and parsed once, rows are streamed to the dataset as they are found, and a heap watchdog flushes state and lowers concurrency before memory runs out.
Reactive browser fallback
Some company career pages are built as single-page applications (React, Vue, Angular) and return empty HTML to a plain HTTP request. The actor detects these JS-rendering signals and, when enableBrowserFallback: true is set, automatically spawns a browser sub-run to handle those companies.
How it works:
- The HTTP run processes all companies. Companies whose pages show JS-rendering signals and return 0 jobs are collected.
- A browser sub-run (
website-job-extractor-browser) is started fire-and-forget. The HTTP run does not wait for it. - A
browser_fallback_spawnedsentinel row is pushed to the HTTP dataset immediately. It contains the sub-run ID, dataset URL, and console link.
Example sentinel row:
{"_type": "browser_fallback_spawned","browser_run_id": "abc123","browser_dataset_id": "xyz789","browser_dataset_items_url": "https://api.apify.com/v2/datasets/xyz789/items","browser_run_console_url": "https://console.apify.com/actors/qYeRSaxRS20eprNTx/runs/abc123","companies_forwarded": ["amazon-us"],"note": "Real jobs for JS-rendered pages are being extracted in the browser sub-run above...","timestamp": "2026-09-18T10:00:00Z"}
Fetching browser run results:
$curl "https://api.apify.com/v2/datasets/{browser_dataset_id}/items?token=YOUR_TOKEN"
Both the dataset URL and the console URL are embedded in the sentinel row so you do not need to track them separately.
Enabling browser fallback:
{"companies": [...],"llmProvider": "openrouter","openrouterApiKey": "YOUR_KEY","enableBrowserFallback": true}
Deprecated field:
enablePlaywrightFallbackis accepted as an alias until 2027-09-18. Switch toenableBrowserFallbackwhen convenient.
Input shape: website_url vs career_urls
These two fields serve different purposes:
| Field | What it means | When to use it |
|---|---|---|
website_url | Company homepage. The actor runs discovery from here: probes /jobs, /careers, reads navigation links. | When you only have the company's main website and want the actor to find the career page for you. |
career_urls | Specific job listing URLs, SERP pages, or detail pages. The actor extracts directly from these without discovery. | When you already know the career page URL, or the URL is already a search results page (e.g., amazon.jobs/en/search?base_query=...). |
If your input URL is already a SERP or job listing, pass it via career_urls, not website_url. Homepage discovery on a SERP page extracts nav links, which are all rejected as junk category pages.
As of v2.2.8, the actor performs best-effort SERP detection: if website_url looks like a search results page (contains ?base_query=, ?q=, /search?, etc.) and career_urls is empty, it treats website_url as a direct extraction target automatically. Explicit is still better.
Input configuration
Minimal example (managed mode -- no API key needed)
{"companies": [{"company_id": "example-ag","company_name": "Example AG","website_url": "https://example.ch"}]}
Minimal example (BYOK mode -- your own key)
{"companies": [{"company_id": "example-ag","company_name": "Example AG","website_url": "https://example.ch"}],"llmProvider": "gemini","geminiApiKey": "YOUR_GEMINI_API_KEY"}
With keyword filtering
Only extract jobs matching specific keywords. Each job receives a relevance score of high, medium, or low.
{"companies": [{"company_id": "example-ag","company_name": "Example AG","website_url": "https://example.ch"}],"jobKeywords": ["Software Engineer", "DevOps", "Backend"],"llmProvider": "gemini","geminiApiKey": "YOUR_GEMINI_API_KEY"}
Filters and full job lists (company career pages)
keywords, location, postedWithinDays and sortBy filter the company's own job list. They are applied before the maxResultsPerCompany cap and before charging, so a cap of 5 returns the first 5 jobs that match, and rows that do not match are never pushed or billed. jobKeywords is accepted as an alias of keywords.
{"companies": [{ "company_id": "nhs-1", "company_name": "Example Hospital", "website_url": "https://careers.example-hospital.co.uk" }],"keywords": ["nurse"],"location": "London","postedWithinDays": 30,"maxResultsPerCompany": 5}
- keywords: the job title must contain at least one keyword (case and accent insensitive).
- location: city, region or country. Country names and ISO codes match both ways (
GermanyandDE,United KingdomandGB). Remote jobs match unless they name a different country. A job whose location cannot be read is not kept. - postedWithinDays: jobs without a posting date are kept.
- maxResultsPerCompany = 0 means all jobs. Hard bound: 10,000 jobs per company, bounded by the run's time limit (not a per-company time box) and
maxPagesPerCompanyfor crawled pages. - sortBy: newest first where the career site exposes a date sort (Workday lists newest first by posting day; rows of the same day come in site order). Rows are streamed as they are read and never buffered to be re-sorted, so a site without a date sort returns its own order.
- End reasons:
job_extraction_done.reasonisokwhen rows were pushed,filtered_out_by_filtersorno_location_match_on_sitewhen listings were read but the filters removed all of them, andjs_rendered_no_browseronly when no listings were seen at all. - Streaming and billing: ATS rows are pushed and charged one by one as they are read, and the actor never pushes a row it cannot charge. If the run is aborted, migrated, hits its time limit or reaches its maximum charge, every row already delivered was charged, and the company's
job_extraction_donerow says why (reason:aborted,timeout_wind_downorcharge_limit_reached) withjobs_pushedequal to the rows delivered. - includeJobDetails = false (alias: includeDescription = false) pushes ATS rows straight from the listing pages with no per-job detail requests. It is the fast and cheap mode for all jobs (
maxResultsPerCompany0) on very large career sites, at the cost of the full description (title, location, link, posted date and requisition id are kept). With details on, a site that throttles detail requests is read at the pace it allows. - sortBy:
newest(default) uses the ATS's own newest-first sort where it has one; otherwise rows arrive in the site's order.
Where the filter runs:
| Source | Keywords | Location | Notes |
|---|---|---|---|
| Workday | server (search text) | server (location facets) when the tenant has a matching facet, otherwise local | stops paging once the cap is filled |
| Oracle Recruiting | server | server (location facets) | newest-first sort |
| Eightfold | server | server hint, verified locally | |
| Phenom | server | local | newest-first sort |
| SuccessFactors | server | server hint, verified locally | |
| iCIMS | server | local | |
| Greenhouse, Jibe, Taleo, Avature, Radancy | local, page by page | local, page by page | stops paging at the cap |
| Sitemap + JSON-LD | URL slug prefilter before any page is fetched, then local | local | up to 1,000 detail pages per company |
| LLM extraction | local, after extraction | local, after extraction |
A light local check always runs after server-side filtering, but it never drives paging. Dates and locations come from the list row where the ATS provides them.
Each job_extraction_done row reports jobs_seen, jobs_after_filters, jobs_pushed, jobs_total_reported_by_site (null when the site does not expose a total), filter_mode (server, local, none), pages_fetched and, when filters leave a company with 0 rows, a filter_note that says why. jobs_seen counts distinct jobs looked at across all methods after de-duplication by URL and requisition id (the same requisition under two career-site paths counts once), and can be lower than jobs_total_reported_by_site when the cap stopped paging early.
With known career URLs
If you already know the career page URLs, provide them directly to skip discovery.
{"companies": [{"company_id": "example-ag","company_name": "Example AG","career_urls": ["https://example.ch/karriere","https://example.personio.de/"]}],"llmProvider": "gemini","geminiApiKey": "YOUR_GEMINI_API_KEY"}
Full parameter reference
| Parameter | Type | Default | Description |
|---|---|---|---|
companies | array | required | Companies to extract jobs from. Each item needs company_id and company_name, plus at least one of website_url, career_urls, or career_page_url. |
managedLlmBudgetUsd | number | 10 (paying) / 0.30 (free) | USD cap for managed LLM spend per run. Free-tier users are hard-capped at $0.30. Paying users default to $10.00 if left blank. Maximum $50.00. Has no effect in BYOK mode. |
llmProvider | string | "gemini" | Primary AI provider for BYOK mode: gemini, groq, or openrouter. Ignored in managed mode. |
fallbackProvider | string | -- | Second-level fallback provider (used if primary fails) |
fallback2Provider | string | -- | Third-level fallback provider |
geminiApiKey | string | -- | OPTIONAL. API key for Google Gemini. Get one free at aistudio.google.com/apikey. Leave blank for managed mode. |
groqApiKey | string | -- | OPTIONAL. API key for Groq. Leave blank for managed mode. |
openrouterApiKey | string | -- | OPTIONAL. API key for OpenRouter. Leave blank for managed mode. |
outputLanguage | string | "auto" | Output language: en, de, fr, it, es, pt, nl, or auto (matches website language) |
maxResultsPerCompany | integer | 200 | Maximum jobs pushed per company, counted after filters. 0 = all jobs (hard bound 10,000). Actor keeps paginating until this count is reached, no more pages exist, or the page cap fires. |
includeJobDetails | boolean | not set | true = read every job's detail page (ATS descriptions plus employer-site detail pages with structured fields, see "Job details"). false = no detail reads at all (fast mode for all jobs on huge sites). Not set = ATS descriptions on, employer-site detail pages off. Replaces the hidden aliases includeDescription and includeFullPostingText, which still work while this is not set. |
maxFullTextChars | integer | 12000 | Cap on characters of posting text stored per job and sent to the LLM (500 to 50000). Only with includeJobDetails. |
fullTextLlmFill | boolean | true | false = read the detail pages but make no LLM call to fill missing fields (detail_llm_calls stays 0). Only with includeJobDetails. Forwarded to the browser fallback. Local runs: env WJE_FULLTEXT_LLM_FILL=0. |
keywords | string[] | -- | Keep only jobs whose title contains one of these. Applied before the cap and before charging. |
location | string | -- | Keep only jobs in this city, region or country (names and ISO codes match both ways). |
postedWithinDays | integer | -- | Keep only jobs posted within the last N days (undated jobs are kept). |
sortBy | string | newest | newest or relevance. |
maxPagesPerCompany | integer | 20 | Hard cap on pages fetched per company (career page, pagination, ATS pages). Independent of maxResultsPerCompany. A company also stops after 3 consecutive pages with no jobs. |
maxConcurrency | integer | 3 | Parallel HTTP requests (range: 1-10) |
jobKeywords | string[] | -- | Alias of keywords. When set, LLM-extracted jobs also get a relevance score. |
webhookUrl | string | -- | URL to POST results to when extraction completes |
Company input fields
Each company object supports these fields:
| Field | Required | Description |
|---|---|---|
company_id | Yes | Your internal identifier (UUID or any string) |
company_name | Yes | Company name (used as context for AI extraction) |
website_url | No | Company homepage -- the actor discovers career pages from here |
career_urls | No | Array of known career page URLs |
career_page_url | No | Single career page URL (treated as highest priority) |
website_domain | No | Domain for subdomain probing (e.g., example.ch triggers checks on jobs.example.ch, karriere.example.ch) |
At least one of website_url, career_urls, or career_page_url should be provided.
Output format
Each extracted job is pushed as a separate item to the dataset.
{"company_id": "example-ag","company_name": "Example AG","title": "Senior Software Engineer (m/w/d)","description": "Entwicklung von Web-Applikationen mit React und Node.js...","location": "Zurich","employment_type": "Full-time","experience_level": "Senior","salary_range": "100'000-130'000 CHF","department": "Engineering","requirements": ["5+ years experience", "React", "Node.js", "TypeScript"],"benefits": ["Remote work", "Training budget", "Flexible hours"],"source_url": "https://example.ch/karriere","job_url": "https://example.ch/karriere/senior-software-engineer-zurich","job_id": "senior-software-engineer-zurich","application_url": "https://example.ch/apply/senior-swe","posted_at": "2026-03-01","workplace_type": "hybrid","confidence": 0.95,"relevance": "high","ats_system": "personio","ats_url": "https://example-ag.jobs.personio.de/","career_page_url": "https://example.ch/karriere","extracted_at": "2026-03-08T10:30:00Z"}
Output fields
| Field | Type | Description |
|---|---|---|
company_id | string | The company ID from your input |
company_name | string | The company name from your input |
company_name_detected | string or null | Brand name the company's own pages or job system report (for example Migros, Johnson & Johnson), for inputs that only give a domain. null when unsure, never derived from the domain |
company_name_source | string or null | Where company_name_detected came from: json_ld_organization, ats_board, og_site_name or title |
title | string | Job title |
description | string | Job description (may be summarized for long listings) |
location | string | Work location |
employment_type | string | Full-time, Part-time, Contract, Internship, etc. |
experience_level | string | Junior, Mid, Senior, Lead, etc. |
salary_range | string | Salary range if listed on the page |
department | string | Department or team |
requirements | string[] | Required skills and qualifications |
benefits | string[] | Listed benefits and perks |
source_url | string | URL of the career page where the job was found |
description_md | string | Posting text as markdown from the employer-site detail page. Only with includeJobDetails; null otherwise or when the page/PDF could not be read |
posting_format | string | html or pdf: what description_md was read from. null when description_md is null |
full_text_url | string | The employer-site page or PDF the detail data came from; set only when it gave text or fields (the detail charge marker) |
expires_at | string | Closing date of the posting (ISO date): JSON-LD validThrough, else the application deadline stated in the page text. Only with includeJobDetails, employer-site rows |
application_deadline | string | Application deadline stated in the posting text (ISO date). Only with includeJobDetails |
salary_text | string | Salary exactly as stated in the posting; null when no amount is written |
salary_min, salary_max | number | Salary bounds as numbers (equal for a single value, null when unknown, never 0). Only an amount written in the posting counts: a currency without an amount, "competitive", revenue figures and percentages give null. Reads JSON-LD baseSalary and explicit currency + amount statements such as CHF 90'000 - 110'000 pro Jahr or EUR 4.200 monatlich; nothing is estimated |
salary_currency | string | ISO 4217 code of the salary, for example CHF |
salary_period | string | annual, monthly, hourly, daily or weekly; null when the posting gives no period |
salary_source | string | stated when the salary numbers come from the posting, otherwise null |
workload_min, workload_max | number | Workload percent, Swiss style: 80-100% gives 80 and 100, a single 60% gives 60 and 60. From JSON-LD workHours, the job title, a "Pensum" statement or the posting text |
contact_name, contact_email, contact_phone | string | Contact person, email and phone as written in the posting. An LLM value is kept only when the posting text contains it |
remote_option | string | remote or hybrid when the posting says so (JSON-LD TELECOMMUTE, or home-office wording), else null |
languages | string[] | Languages the posting asks for, as English names (German, English, French, Italian) |
hiring_organization | string | Employing entity named in the JSON-LD hiringOrganization |
reference_number | string | The employer's own reference number (JSON-LD identifier) |
apply_url | string | application_url when the row has one, else the employer-site detail page |
is_easy_apply | boolean | JSON-LD directApply |
skills | string[] | Short skill list from the JSON-LD skills field |
postal_code, street_address | string | Address parts of the job location (JSON-LD, only when the posting names exactly one place) |
working_hours | string | Working hours text (JSON-LD workHours) |
job_url | string | URL of the specific job detail page (the page you read, not the apply page) |
job_url_is_synthetic | boolean | true when the job has no link of its own (some career pages list jobs inline as text). job_url is then the page URL plus #job-<title-slug>, for example https://example.ch/karriere#job-pflegefachfrau-80-100. It is unique per job and stable across reruns, but it is not a separate page. Such a job is kept only when its title appears in the page text. false for every real link |
job_id | string | Stable job identifier extracted from the job URL path (e.g., "12345" from /jobs/12345) |
application_url | string | Direct application link (if available) |
posted_at | string | Posting date (if listed) |
workplace_type | string | remote, hybrid, on-site |
confidence | number | Extraction confidence score (0.0-1.0) |
relevance | string | Keyword relevance: high, medium, or low (only when jobKeywords is set) |
ats_system | string | Job system (named as Career Site Jobs names it, e.g. successfactors_rmk, talentbrew) that renders the job list and detail pages we read from (e.g., personio, greenhouse, workday, phenom). Consistent with ats_url and ats_tenant |
ats_supported_by_csj | boolean | true when Career Site Jobs lists ats_system as a platform value, false when the detected system is one it does not read (e.g. rexx, ostendis, onlyfy, softgarden, publicjobs, the classic SuccessFactors portal) or when no system was detected (ats_system is null). Always a boolean, on job rows and on job_extraction_done rows |
application_ats | string | Known ATS detected from the application_url host, i.e. where applications land (e.g., workday for a Phenom career site that applies through Workday). null when unknown or equal to ats_system |
ats_tenant | string | Tenant of the platform the row was read from (describes the source we read, not the apply system) |
ats_url | string | URL of the ATS or career-site listing page the jobs were read from |
ats_urls | string[] | On job_extraction_done rows: every job-list address of the company's job system that was found (several sites in one system are all listed; ats_url is the primary). null when no job system was found |
ats_handover_supported | boolean | On job_extraction_done rows: true when Career Site Jobs can read ats_system as its platform (input {"platform": ats_system, "company": ats_url}), false when it cannot (e.g. icims_jibe, goldman_sachs), null when no job system was found |
manifest_version | string | On job_extraction_done rows: version of the ATS manifest built into this actor, <manifest version>+<10 hex chars>; the suffix changes whenever a job system's Career Site Jobs support flips |
career_page_url | string | Career page URL where job discovery started |
detail_fetched, detail_pdf, detail_failed, detail_llm_calls, detail_charged | integer | On job_extraction_done rows with includeJobDetails: employer-site detail pages that gave text or fields (PDFs included), how many were PDFs, pages that gave nothing, LLM fill calls (at most one per job), rows charged the job-detail-result event (job-detail-result-managed in managed mode) |
extracted_at | string | ISO timestamp of extraction |
Every job row contains every field above. Fields without a value are null; keys are never omitted.
Job details (includeJobDetails)
includeJobDetails is the one switch for detail pages:
- Not set (default): exactly the behaviour before this option existed. ATS career sites keep reading each job's detail page or API for the description, employer-site detail pages are not opened.
true: every job's detail page is read. ATS descriptions as before (included in the normal job price), plus for jobs on the employer's own site (or on a host Career Site Jobs does not read) the detail page: the posting text becomes markdown indescription_mdand the structured fields below are filled.false: no detail reads at all. Rows come straight from the listing pages (fast and cheap for all jobs on huge career sites).
The older inputs includeDescription (ATS part) and includeFullPostingText (employer-site part) still work as hidden aliases while includeJobDetails is not set; when it is set, it wins.
How the fields are filled, in this order, each step only filling what is still empty (a value from the listing or ATS is never overwritten):
- JSON-LD
JobPosting: employment type,posted_at,expires_at, salary (min, max, currency, period), location parts,hiring_organization,reference_number,is_easy_apply,working_hours, shortskillsand qualifications, remote (TELECOMMUTE). - The posting text:
workload_min/workload_max(for example80-100%,Pensum 60%),department, contact person, email and phone,application_deadline,remote_option,languages. PDF postings (the job link itself, or a linked "Stelleninserat" PDF on a thin detail page, max 5 MB) are read the same way; scanned PDFs without a text layer give nothing. - One LLM call per job at most, asking only for the fields still missing, on at most
maxFullTextCharscharacters. Every value must be written in the posting text (emails, phones, names, percentages, dates, languages are checked against it); anything else is dropped and the field staysnull.
maxFullTextChars(default 12000, 500 to 50000) caps the stored text per job and the text sent to the LLM.fullTextLlmFill(defaulttrue): setfalseto skip the LLM call entirely; JSON-LD and the text pass still run anddetail_llm_callsstays 0. Local runs: envWJE_FULLTEXT_LLM_FILL=0.- Rows read from an ATS API are not touched here. A job whose page cannot be read within the company time budget is still delivered, with the detail fields
null. - Each company's
job_extraction_donerow reportsdetail_fetched,detail_pdf,detail_failed,detail_llm_callsanddetail_charged. - Pricing: one
job-detail-resultevent ($0.002;job-detail-result-managed, $0.006, when no API key is supplied) per delivered job whose employer-site detail page gave text or structured fields (charged right after the job's own event, once per unique job). ATS descriptions stay in the normal job price. If the event is not part of the run's pricing it is skipped and logged once. - Detail pages are read over plain HTTP with the same automatic proxy handling as the pages, so reading them adds a few seconds per job.
Sentinel rows
The dataset may also contain sentinel rows with a _type field. These are not job listings but machine-readable events:
_type | When it appears | Key extra fields |
|---|---|---|
job_extraction_done | When all pages for a company are processed | job_count, jobs_extracted, pages_fetched, pagination_source (css_selector, url_pattern, or none), jobs_seen, jobs_after_filters, jobs_pushed, jobs_total_reported_by_site, filter_mode, filter_note |
browser_fallback_spawned | When enableBrowserFallback: true and JS-rendered companies are forwarded to the browser actor | browser_run_id, browser_dataset_id, companies_forwarded |
llm_extraction_failed | When every LLM call failed (all providers exhausted) | error_category, provider, model, attempts, user_message |
managed_llm_run_capped | When the managed LLM budget cap is reached mid-run (managed mode only) | error_category (free_tier_budget_reached or paying_tier_budget_reached), tier, spent_usd, cap_usd, user_message |
All sentinel rows include the four required dataset fields: company_id, company_name, source_url, extracted_at.
Example managed_llm_run_capped sentinel:
{"_type": "managed_llm_run_capped","company_id": "example-ag","company_name": "Example AG","source_url": "https://example.ch/karriere","extracted_at": "2026-09-18T10:00:00Z","error_category": "free_tier_budget_reached","tier": "free","spent_usd": 0.3001,"cap_usd": 0.30,"user_message": "Free-tier managed LLM budget cap of $0.30 reached..."}
Related Actors
Find businesses to enrich
- Kleinanzeigen.de Scraper — Germany
- Willhaben.at Scraper — Austria
- Tutti.ch Scraper — Switzerland
- Subito.it Scraper — Italy
- Marktplaats.nl Scraper — Netherlands
Real Estate
E-Commerce
Enrichment
Supported ATS systems
The actor detects and follows links to these Applicant Tracking Systems:
| ATS System | Domain |
|---|---|
| Personio | personio.de, personio.com |
| Softgarden | softgarden.io |
| Greenhouse | greenhouse.io |
| Lever | lever.co |
| BambooHR | bamboohr.com |
| JOIN | join.com |
| Recruitee | recruitee.com |
| d.vinci | dvinci-hr.com |
| Workwise | workwise.io |
| Factorial | factorial.ch, factorial.co |
| CVManager | cvmanager.ch |
| Onlyfy (by XING) | onlyfy.jobs |
| Jobbase | jobbase.io |
| Teamtailor | teamtailor.com |
| SmartRecruiters | smartrecruiters.com |
| Talentsoft | talent-soft.com |
| rexx systems | rexx-systems.com |
| Concludis | concludis.de |
| Coveto | coveto.de |
When an ATS portal is detected via iframe, link, or HTML pattern, the actor automatically navigates to the portal page and extracts jobs from there.
Skip LLM for known ATS platforms
Enable Skip LLM for Known ATS Platforms (skipAtsExtraction: true) to bypass LLM extraction when a supported ATS is detected. Instead of calling the LLM, the actor outputs just the ats_system and ats_url fields. Feed those URLs into the Career Site Jobs Scraper for faster, cheaper, structured extraction.
Why use this?
- No hallucinations -- the Career Site Jobs Scraper reads structured API data, not LLM-interpreted HTML
- Cheaper -- $1 per 1,000 jobs vs LLM token costs
- More fields -- department, employment type, remote flag, description from the ATS API directly
- 16 platforms supported -- Lever, Greenhouse, Workable, Ashby, Teamtailor, Personio, SmartRecruiters, BambooHR, Workday, iCIMS, Recruitee, JOIN, Pinpoint, Rippling, JazzHR, Comeet
Recommended workflow for bulk job extraction:
- Run this actor with
skipAtsExtraction: trueon company websites - Collect the
ats_urlvalues from the output for detected ATS platforms - Feed those URLs into the Career Site Jobs Scraper
- Companies without a detected ATS are still extracted by the LLM as usual
onlyfy handover
A company whose job list lives on its own onlyfy tenant (<tenant>.onlyfy.jobs) is not read with the LLM. The run ends it as ats_handover_available with one summary row (ats_system: onlyfy, ats_url = the tenant root), no job rows and no job charge. Pass ats_url to the Career Site Jobs Scraper with platform onlyfy. The tenant must be visible on the company's own pages, posting pages or first-party API; it is never guessed from the company name.
A tenant that is a shared group board (one tenant listing several employers' jobs) is only handed over when you set handoverSharedBoards: true. The summary row then also carries ats_employer, the company name: pass it as queries[].employer to the Career Site Jobs Scraper so only that employer's jobs are returned. Without the option, such companies keep being read from their own site.
LLM providers
The actor uses an LLM to parse HTML into structured job data. It supports three providers with automatic fallback -- just provide your API keys and the system builds the longest possible fallback chain.
| Provider | Free Tier | Speed | Default Model | Best For |
|---|---|---|---|---|
| Gemini (recommended) | 1M tokens/min | Fast | gemini-3.6-flash | Most users — generous free tier |
| Groq | 30 requests/min | Very fast | openai/gpt-oss-120b | High-speed extraction |
| OpenRouter | Some free models | Varies | deepseek/deepseek-chat-v3-0324 | Fallback / alternative models |
Model defaults may change without notice as providers retire models. Set
llmModelin your input to pin to a specific version.
Overriding the model
The llmModel input is free-text — set it to any current model identifier your provider supports. Recommended alternatives:
- Gemini:
gemini-3.5-flash-lite(cheapest, lower quality),gemini-3.1-pro-preview(highest quality, priciest). Full list at https://ai.google.dev/gemini-api/docs/models - Groq:
llama-3.1-8b-instant(fastest, lighter quality). List at https://console.groq.com/docs/models - OpenRouter: 300+ models. Browse at https://openrouter.ai/models
You do not need to wait for us to rebuild the actor when Google/Groq/OpenRouter releases a new model — just set llmModel to the new identifier.
Auto-discovery fallback chain
Simply provide API keys for the providers you want to use. The actor automatically detects which providers have valid keys and builds a fallback chain:
{"geminiApiKey": "YOUR_GEMINI_KEY","llmApiKey": "YOUR_GROQ_KEY","openrouterApiKey": "YOUR_OPENROUTER_KEY"}
With this setup, the actor automatically uses: Gemini → Groq → OpenRouter. If Gemini's daily quota runs out, it instantly falls back to Groq. If Groq is rate-limited, it falls back to OpenRouter. No manual fallback configuration needed.
Error-aware fallback: The system classifies errors (quota exhaustion vs. transient rate limits vs. auth errors) and provides clear console messages explaining what happened and why it switched providers.
You can still explicitly set llmProvider, fallbackProvider, and fallback2Provider if you want to override the automatic chain order.
Get a free Gemini API key at aistudio.google.com/apikey.
LLM API keys -- BYOK vs Managed mode
This actor works in two modes:
Managed mode (default -- no key needed)
Leave all API key fields blank. The actor uses a shared OpenRouter account with deepseek/deepseek-chat-v3-0324 as the extraction model. No sign-up required.
Budget caps by tier:
| Tier | Default cap | Maximum cap |
|---|---|---|
| Free Apify account | $0.30 per run (hard ceiling) | $0.30 (cannot raise) |
| Paying Apify account | $10.00 per run | $50.00 (via managedLlmBudgetUsd) |
When the cap is reached mid-run, a managed_llm_run_capped sentinel row is pushed to the dataset and the actor exits cleanly with a message telling you how to continue. No crash.
Managed mode PPE events (charged per extracted job in managed mode):
| Event | Price |
|---|---|
| Company processed | $0.01 |
| Job extracted (managed) | $0.012 |
The 3x premium on managed jobs vs BYOK jobs ($0.012 vs $0.004) covers the deepseek-v3 LLM cost ($0.002-0.003 per company extraction) plus a margin.
BYOK mode (bring your own key)
Supply any of geminiApiKey, groqApiKey, or openrouterApiKey. The actor uses your quota, your rate limits, and the standard PPE event prices:
| Event | Price |
|---|---|
| Company processed | $0.01 |
| Job extracted (BYOK) | $0.004 |
Managed mode input
{"companies": [{"company_id": "stripe","company_name": "Stripe","career_urls": ["https://stripe.com/jobs/search"]}]}
No API key fields required. The actor runs immediately.
Raising the managed budget (paying users only)
{"companies": [...],"managedLlmBudgetUsd": 25}
Free-tier users cannot raise the cap above $0.30 regardless of this input.
Pricing
This actor uses pay-per-result pricing on the Apify platform:
| Event | Price | When |
|---|---|---|
| Company processed | $0.01 per company | Both modes |
| Job extracted (BYOK) | $0.004 per job | When you supply an API key |
| Job extracted (Managed) | $0.012 per job | When no API key is supplied |
Job with employer-site details (job-detail-result) | $0.002 per detail page | Only with includeJobDetails: true, for jobs whose employer-site detail page gave text or fields. Own key or no LLM |
Job with employer-site details, managed (job-detail-result-managed) | $0.006 per detail page | Same, when no API key is supplied (managed mode) |
Standard Apify platform costs (compute and proxy) apply on top.
The detail event is charged once per unique delivered job whose employer-site detail page gave text or structured fields, right after that job's own event. Jobs whose page could not be read (failed fetch, timeout) and ATS adapter descriptions are not charged, and the optional LLM fill (fullTextLlmFill) is included in the price. Without includeJobDetails: true the event never fires.
You pay the company fee whenever the career site was read successfully, even if it has no open jobs right now. Sites that cannot be read are not charged.
Example (managed mode): Extracting jobs from 10 companies with 50 total open positions = $0.10 (companies) + $0.60 (jobs at $0.012) = $0.70 plus platform fees.
Example (BYOK mode): Same 10 companies, 50 jobs = $0.10 + $0.20 = $0.30 plus platform fees and your LLM key cost.
Troubleshooting: LLM key errors
If a run fails with LLM provider ... rejected the supplied API key or LLM model ... is unavailable to your ... key, one of three things is happening.
1. Your key has HTTP-referer or IP restrictions enabled. Common on Google Gemini keys created via Google AI Studio. Google returns API_KEY_INVALID when the caller's IP or referer isn't on the allow-list. This actor runs on Apify's AWS us-east-1 IPs, which won't match a locked-down key. Fix: open Google AI Studio, go to API Keys, remove restrictions on the key, or add apify.com as an allowed referer. Then re-run.
2. Your key has hit its provider quota. Free tiers on Gemini and Groq have daily and per-minute caps. Google returns RESOURCE_EXHAUSTED, Groq returns HTTP 429. The actor surfaces the exact error in the log. Fix: wait for the quota to reset, or upgrade to a paid tier at the provider.
3. The model you set in llmModel was retired. Providers deprecate models faster than their public docs update. Gemini 2.5 Flash was quietly restricted to existing users only. Groq retired Llama 3.3 70B Versatile and Llama 3.1 8B Instant while still listing them as available on the model catalog page. Fix: set llmModel to a currently-available model. The actor's defaults are updated whenever we discover a retirement, but explicit llmModel overrides the default.
Workaround for all three: use OpenRouter. Set llmProvider: "openrouter" and llmApiKey: <your OpenRouter key>. OpenRouter accepts calls from any IP, aggregates cost across providers on one credit balance, and lets you switch model providers by changing the llmModel string (for example google/gemini-3.6-flash, openai/gpt-oss-120b). One key, no per-provider restrictions.
Use cases
- Recruitment analytics -- monitor hiring activity across companies in your market
- Competitive intelligence -- track what roles competitors are hiring for
- Lead generation -- identify companies that are actively hiring (buying signal)
- Job aggregation -- build a niche job board by extracting listings from company career pages
- Market research -- analyze hiring trends by region, industry, or role type
Combine with Google Maps Scraper
Chain this actor with the Google Maps Scraper for a complete pipeline:
Google Maps Scraper --> find companies with websites --> Website Job Extractor --> structured job listings
The Google Maps Scraper has a built-in enableJobExtraction option that automatically chains results to this actor.
JavaScript-rendered pages
This actor is HTTP-only and does not run a browser. Career pages built with JavaScript frameworks (React, Vue, Angular) may render no content in their initial HTML. When this happens, the actor automatically detects JS-rendering signals and flags affected companies in the output.
How detection works
After fetching each page, the actor inspects the raw HTML for framework markers, empty root elements, and noscript warnings. If JS-rendering indicators are found and 0 jobs are extracted for that company, a sentinel item is pushed to the dataset.
Detection signals
| Signal | Meaning |
|---|---|
react_root_empty | <div id="root"> or <div id="__next"> with near-empty content |
react_markers | data-reactroot, __NEXT_DATA__, or _next/static scripts |
vue_markers | data-v- attributes or Vue.js script references |
angular_markers | ng-version or ng-app attributes |
low_text_ratio | Less than 200 chars of text in 5000+ chars of HTML |
low_text_ratio_large | Less than 5% of a 50KB+ page is visible text |
heavy_script_ratio | Script bytes are more than 4x the visible text (20KB+ of scripts) |
spa_root_empty | #root, #app, #__next, #__nuxt or <app-root> container with almost no text |
career_widget:<name> | Script from a known client-side career widget host (Phenom, Eightfold, Radancy/TalentBrew, Oracle HCM, embedded Greenhouse/Lever/Personio widgets, and others) |
no_job_candidates_on_career_page | URL or title says careers/jobs, but the page has no job postings or job-like links and no "no open positions" message |
noscript_warning | <noscript> block asking user to enable JavaScript |
With enableBrowserFallback on, any of these signals on a company that ended with 0 jobs starts the browser sub-run.
Sentinel output example
When a company's career page requires JS rendering, the dataset will contain:
{"company_id": "example-ag","company_name": "Example AG","title": null,"source_url": "https://example.com/karriere","js_rendering_suspected": true,"js_indicators": ["react_root_empty", "react_markers", "noscript_warning"],"extracted_at": "2026-03-09T10:00:00Z"}
Two-stage pipeline with browser fallback
Use the sentinel to build a two-stage pipeline with the Website Job Extractor (Browser):
Website Job Extractor (HTTP, fast, 128MB)├─ Normal jobs → use directly└─ Sentinel (js_rendering_suspected) → Website Job Extractor (Browser, 1-4GB)
Why separate actors? The HTTP actor uses a minimal Docker image (~128MB) with no browser. The browser actor needs Chrome (~1024MB) and costs ~2x more per result. Keeping them separate lets you run the cheap HTTP pass on all companies and only pay for browser rendering on the ~28% that need it.
Automatic chaining: Set enablePlaywrightFallback: true and the HTTP actor automatically triggers the browser actor for JS-flagged companies. The browser run ID is saved in the key-value store as BROWSER_FALLBACK_RUN_ID.
Filter sentinels in code
const dataset = await Actor.openDataset();const { items } = await dataset.getData();const jobs = items.filter(item => item.title !== null);const sentinels = items.filter(item => item.js_rendering_suspected === true);// Re-run sentinels with the browser actorfor (const sentinel of sentinels) {console.log(`Needs browser: ${sentinel.company_name} (${sentinel.js_indicators.join(', ')})`);}
End-to-end pipeline
This actor is part of a 5-actor enrichment suite that works together:
Google Maps Scraper ──→ find companies with websites│├──→ Website Job Extractor (HTTP) ──→ structured job listings│ └── JS-flagged companies ──→ Website Job Extractor (Browser)│└──→ Website Contact Extractor (HTTP) ──→ team contacts└── JS-flagged companies ──→ Website Contact Extractor (Browser)
| Actor | Purpose | Memory | Link |
|---|---|---|---|
| Google Maps Scraper | Find companies by location | ~80MB | View |
| Website Job Extractor | Extract jobs (HTTP) | ~128MB | This actor |
| Website Job Extractor (Browser) | Extract jobs from JS pages | ~1-4GB | View |
| Website Contact Extractor | Extract contacts (HTTP) | ~256MB | View |
| Website Contact Extractor (Browser) | Extract contacts from JS pages | ~1-4GB | View |
Limitations
- HTTP-only: This actor does not run a browser. JS-rendered career pages are automatically detected and flagged (see JavaScript-rendered pages above). Use the Browser variant for these companies. ATS portals that serve server-rendered HTML (Personio, Greenhouse, Softgarden, BambooHR, Factorial, and most others) work reliably.
- DACH coverage is highest: Approximately 72% of DACH (Germany, Austria, Switzerland) company career pages work with HTTP-only extraction. International coverage is around 40-50%, depending on how many companies use JS-rendered career pages.
- LLM API key required: You need to provide at least one LLM API key. Gemini offers a generous free tier. Provide multiple keys for automatic fallback.
- Pagination: The actor detects next-page links via CSS selectors and URL-pattern inference (
?page=N,/page/N/,?offset=N, etc.). Classic HTML sites are handled automatically. SPA-driven career pages (Amazon Jobs, Apple Careers) require the Browser variant which adds button-click pagination. ThemaxResultsPerCompanysetting (default 200) controls when pagination stops; a hard 20-page cap prevents runaway loops. - No login-protected pages: The actor cannot access career pages behind authentication.
Why a company returned 0 jobs (reason field)
Every company gets one job_extraction_done row. When job_count is 0 the row also carries a machine-readable reason and a short human reason_message. When jobs were found both are null.
reason | Meaning |
|---|---|
dns_error | The domain does not resolve (typo or retired domain) |
connection_error | The page could not be fetched (timeout, TLS, connection reset) |
blocked | The site answered 401/403/429 or showed a bot challenge |
http_error:<code> | The site answered with another error status, for example http_error:404 or http_error:503 |
parked_domain | The domain is parked, expired or redirects to a domain seller |
not_a_career_page | The URL is not a careers or jobs page (for example an encyclopedia article) |
js_rendered_no_browser | The job list is rendered by JavaScript and enableBrowserFallback is off |
js_rendered_browser_fallback | The job list is rendered by JavaScript and a browser sub-run was started; see the browser_fallback_spawned row |
parse_error | The page was fetched but the extraction step failed |
no_jobs_found | A careers page was read successfully and lists no open positions |
ats_handover_available | The company's job system is one career-site-jobs-scraper can read, but this run could not read it itself (no built-in reader for that system, or the read failed). Pass ats_system as platform and ats_url as company. No browser retry is needed and no company fee is charged. Refline and Prospective boards are read in the run itself (rows, or no_jobs_found for an empty board) |
Example:
{"_type": "job_extraction_done","company_id": "example-ag","company_name": "Example AG","job_count": 0,"reason": "dns_error","reason_message": "The domain could not be resolved (DNS lookup failed). Check the URL for typos or a retired domain."}