Website Job Extractor (Browser)
Pricing
from $8.00 / 1,000 job extracted (browser)s
Website Job Extractor (Browser)
Extract job listings from JavaScript-rendered career pages (React, Vue, Angular) using AI + Playwright. Companion to the HTTP-only Website Job Extractor. Use it for the ~28% of company sites that need a real browser. Same output format, same quality, same LLM fallback chain.
Pricing
from $8.00 / 1,000 job extracted (browser)s
Rating
0.0
(0)
Developer
NanoScrape
Maintained by CommunityActor stats
0
Bookmarked
43
Total users
6
Monthly active users
4.3 hours
Issues response
10 hours ago
Last modified
Categories
Share
Extract job listings from JavaScript-rendered career pages (React, Vue, Angular SPAs) using AI + Playwright. This is the specialist tool in a three-tier extraction chain — use it as a fallback, not a default.
Which tool should I use? (Read this first)
Running a browser + LLM is inherently more expensive than parsing static HTML. Try these in order:
-
website-job-extractor(HTTP + AI) — try this first. Handles ~70% of company career pages. About 5× cheaper per company than this browser variant. SetenableBrowserFallback: trueon its input and it will automatically hand off JS-only sites to this actor. -
career-site-jobs-scraper— if you know the URL is hosted on a supported ATS platform (Lever, Greenhouse, Workable, Ashby, Teamtailor, Personio, SmartRecruiters, BambooHR, Workday, iCIMS, Recruitee, JOIN, Pinpoint, Rippling, JazzHR, Comeet). Deterministic, HTTP-only, no LLM cost. Cheapest of the three. -
This actor — when the HTTP extractor returns nothing and the URL isn't a known ATS. Renders the page with Playwright, extracts with an LLM. Slower and pricier, but works on anything.
When to use this actor
- Career pages built with React, Vue, Angular, or other JS frameworks
- Pages that return empty/skeleton HTML without JavaScript execution
- Companies flagged by the HTTP actor's JS-rendering detection
- Auto-chained via
enableBrowserFallbackon the HTTP actor (wasenablePlaywrightFallback, deprecated)
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 subdomain patterns, 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 v1.1.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.
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-browser
Example prompt once connected:
"Use
website-job-extractor-browserto process data with website job extractor browser. 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
- Playwright renders the full page (waits for network idle + text content)
- Career page discovery from homepage navigation (same as HTTP actor)
- ATS detection for 19 systems (Personio, Greenhouse, Softgarden, etc.)
- LLM extraction using Gemini Flash / Groq / OpenRouter
- Validation with confidence scoring and deduplication
- Pagination follow-up for multi-page listings
Same extraction pipeline as the HTTP actor — same output format, same quality.
Input
Same input format as the HTTP actor. Typically auto-chained.
Each companies[] item accepts an optional country (ISO 3166-1 alpha-2, case-insensitive, UK is read as GB). Job rows whose country is known and different are dropped before they are delivered and before any charge, and do not count toward maxResultsPerCompany. Rows with an unknown country are kept, and the requested country is never written into a row. With a country set, the company's job_extraction_done row carries dropped_off_country.
Example:
{"companies": [{"company_id": "abc-123","company_name": "TechCorp AG","website_url": "https://techcorp.ch"}],"llmProvider": "gemini","geminiApiKey": "YOUR_KEY"}
Optional: includeJobDetails reads every job's detail page and adds the posting text as markdown in description_md plus structured fields (workload, contact, deadline, salary and more); maxFullTextChars (default 12000) caps the text (see "Job details" below).
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 (the browser actor reads the same ATS APIs before it renders a page):
| 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.
Output
Each job is a dataset item with browser_extraction: true:
{"company_id": "abc-123","company_name": "TechCorp AG","title": "Senior Frontend Developer (m/w/d)","location": "Zürich","employment_type": "Vollzeit","department": "Engineering","job_url": "https://techcorp.ch/jobs/senior-frontend-developer-zurich","job_id": "senior-frontend-developer-zurich","application_url": "https://techcorp.ch/jobs/apply/123","confidence": 0.85,"browser_extraction": true,"extracted_at": "2026-03-09T10:00:00.000Z"}
Memory requirements
- Default and minimum: 1024 MB. At 1024 MB the actor renders 2 pages at a time (the concurrency is capped by available memory), which keeps the browser below the memory-overload threshold. Companies whose jobs are served by a supported applicant tracking system are read over plain HTTP without starting a browser page at all.
- Recommended: 2048 MB if many of your companies are not on a supported applicant tracking system (custom career sites that the browser has to render), or if you raise
maxConcurrencyabove 2. 1024 MB stays the default. - Maximum: 4096 MB
ATS API shortcut (no browser needed)
Before any page is opened, the actor checks the career URLs of each company over plain HTTP. When the jobs live in Workday, Oracle Recruiting, Phenom, Eightfold, iCIMS (incl. Jibe), Taleo, SuccessFactors, Greenhouse or Avature, the job list is read from the ATS directly and the browser is skipped for that company. These rows carry extraction_method: "ats_api"; rows read from a rendered page carry extraction_method: "llm". robots.txt is respected. Each pushed row is billed once, exactly like a browser row.
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.05 |
| Job extracted (managed) | $0.024 |
The 3x premium on managed jobs vs BYOK jobs ($0.024 vs $0.008) covers the deepseek-v3 LLM cost plus a margin. Browser compute overhead means the premium is in absolute terms the same multiplier as HTTP but at a higher base price due to Camoufox/Playwright costs.
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.05 |
| Job extracted (BYOK) | $0.008 |
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.
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
browser-job-detail-resultevent ($0.003;browser-job-detail-result-managed, $0.009, 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.
Job row fields
Every job row contains the same fixed set of keys. Fields without a value are null; keys are never omitted.
| Field | Type | Description |
|---|---|---|
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 stays your input value |
job_url | string | URL of the job posting. When a career page lists jobs inline as text with no link of their own, it is the page URL plus #job-<title-slug> (see job_url_is_synthetic) |
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 |
company_name_source | string or null | Where company_name_detected came from: json_ld_organization, ats_board, og_site_name or title |
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) |
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 browser-job-detail-result event (browser-job-detail-result-managed in managed mode) |
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 |
Sentinel rows
The dataset may also contain sentinel rows with a _type field. These are not job listings but machine-readable events:
_type value | When emitted | 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, button_click, or none) |
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.
Pricing
Browser-based extraction costs more than the HTTP actor due to Chrome overhead. PPE events reflect the actual compute done:
| Event | Listed rate | When it fires |
|---|---|---|
browser-company-enriched | $0.05 / company | Once per company, only when the career site was read |
browser-page-processed | $0.005 / page | See the smart-pricing note below |
browser-job-result | $0.008 / job | Each validated, non-duplicate job (BYOK mode) |
browser-job-result-managed | $0.024 / job | Each validated, non-duplicate job (managed mode) |
browser-job-detail-result | $0.003 / detail page | Only with includeJobDetails: true: each delivered job whose employer-site detail page gave text or structured fields. Own key or no LLM |
browser-job-detail-result-managed | $0.009 / detail page | Same, when no API key is supplied (managed mode) |
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, and neither is a company where the extraction failed (LLM calls failed) and no jobs were delivered. A bot-challenge page that could not be passed is not a rendered page and is not charged as one.
Smart per-page pricing (why your actual bill is lower than the listed rate)
The actor decides internally which proxy pool to use for each URL, defaulting to a fast primary pool and only escalating to residential when a URL gets blocked (403, 429, or repeated timeouts). Because the primary pool costs a fraction of what residential does, we pass the saving on rather than pocket it:
browser-page-processedfires only on the 1st, 5th, 9th, ... render that succeeds on the primary pool (1 in 4).- Renders that fell back to residential bill 1:1, since that's where our real cost sits.
Result: on typical company career pages, you pay for roughly one page in four, and the actor's structural proxy strategy handles the "hard" URLs invisibly.
Cost examples
Small test (1 company, 3 primary-pool pages, 5 jobs):
$0.05 + 1 × $0.005 + 5 × $0.008 = **$0.095**
(first page bills, next two are absorbed by the 1-in-4 cadence)
Bulk run (900 companies, mostly primary pool, ~1,850 jobs): Roughly $65-75/run in practice, vs the ~$130 the flat listed rate would imply.
Historical context (why the pricing model is what it is)
An earlier flat per-page charge introduced 2026-07-27 hit deep-crawl users disproportionately. Investigation showed our real cost was almost entirely residential-proxy bandwidth, not compute. Fixing that (making the actor decide proxy strategy per-URL) removed the underlying cost driver, and the smart per-page cadence passes the saving on to you.
Each company stops when any of these fires: maxResultsPerCompany (default 200) jobs extracted, maxPagesPerCompany (default 20) pages rendered, 3 consecutive pages without a new job, or companyTimeBudgetSecs (default 240) seconds elapsed. The last one is reported as reason: company_time_budget on the company's job_extraction_done row, so one slow company cannot starve the others in a batch. If a run is aborted, migrated or about to time out, every unfinished company gets a job_extraction_done row with reason: aborted. The run status message lists the reason for every company that returned zero rows. The proxyConfiguration input has been removed; if you send one, it's silently ignored.
Blocked pages (HTTP 403/429, captcha or challenge pages) are retried on the same URL with the next proxy tier: datacenter, then residential, then the Apify unblocker. Slow pages are retried on residential only.
Troubleshooting: LLM key errors
Runs that fail with LLM provider ... rejected the supplied API key or LLM model ... is unavailable to your ... key almost always fall into one of these three buckets:
- Key has referer / IP restrictions. Common on Gemini keys minted in Google AI Studio. Google returns
API_KEY_INVALIDbecause the actor runs on Apify's AWS us-east-1 IPs, which aren't on your allow-list. Fix: remove restrictions on the key in Google AI Studio, or addapify.comas an allowed referer. - Quota exhausted. Free tiers on Gemini and Groq have daily and per-minute caps. Google returns
RESOURCE_EXHAUSTED, Groq returns HTTP 429. The exact error is in the log. Fix: wait for the reset, or move to a paid tier. - Model was retired. Vendors deprecate faster than their docs update. Gemini 2.5 Flash was restricted to existing users only. Groq retired Llama 3.3 70B Versatile and Llama 3.1 8B Instant. Fix: set
llmModelto a currently-available model. Actor defaults are refreshed when we detect a retirement, but an explicitllmModeloverrides the default.
One-line workaround: use OpenRouter. Set llmProvider: "openrouter" and llmApiKey: <your OpenRouter key>. OpenRouter accepts calls from any IP, aggregates spend on one balance, and lets you switch model providers via llmModel (for example google/gemini-3.6-flash, openai/gpt-oss-120b). One key, no per-provider restrictions.
Auto-chaining
The HTTP actor can automatically trigger this browser actor for JS-flagged companies:
- Run the HTTP actor with
enablePlaywrightFallback: true - Companies with
js_rendering_suspectedare collected - A browser actor run starts automatically (fire-and-forget)
- The browser run ID is saved in the key-value store as
BROWSER_FALLBACK_RUN_ID
LLM fallback chain
Like the HTTP actor, this actor supports automatic provider fallback. Just provide API keys for the providers you want to use:
{"geminiApiKey": "YOUR_GEMINI_KEY","llmApiKey": "YOUR_GROQ_KEY","openrouterApiKey": "YOUR_OPENROUTER_KEY"}
The system auto-discovers available providers and builds a fallback chain (e.g. Gemini → Groq → OpenRouter). If one provider's quota runs out, it instantly falls back to the next.
End-to-end pipeline
This actor is part of a 5-actor enrichment suite:
| Actor | Purpose | Memory | Link |
|---|---|---|---|
| Google Maps Scraper | Find companies by location | ~80MB | View |
| Website Job Extractor | Extract jobs (HTTP) | ~128MB | View |
| Website Job Extractor (Browser) | Extract jobs from JS pages | ~1-4GB | This actor |
| Website Contact Extractor | Extract contacts (HTTP) | ~256MB | View |
| Website Contact Extractor (Browser) | Extract contacts from JS pages | ~1-4GB | View |
Limitations
- Higher memory usage (~1GB vs ~128MB for HTTP)
- Slower execution (page rendering + wait times)
- Higher cost per result (2x HTTP rates)
- Use the HTTP actor first — only fall back to browser when needed
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) |
parse_error | The page was fetched but the extraction step failed |
no_jobs_found | A careers page was read and says explicitly that nothing is open (or its job list has no entries). Not used when the pages link to a job portal that could not be read, or list jobs without a link to each one: those end as parse_error and are not charged |
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) |
Why 0 jobs in one word (jobs_status and llm_calls_failed)
reason is the detailed cause. Every job_extraction_done row also carries jobs_status, a short answer that tells "read, nothing open" apart from "not read", and llm_calls_failed, the number of failed LLM extraction calls for the company. The values are the same as in the HTTP actor, so one parser reads both. The "no vacancies" wording is looked for in the visible text of the rendered page (after JavaScript ran), so JavaScript-rendered career pages report no_openings here.
jobs_status | Meaning |
|---|---|
jobs_found | At least one job row was emitted (llm_calls_failed can still be above 0) |
no_openings | The career page was read and states there are no vacancies (for example "keine offenen Stellen", "aucun poste vacant", "nessuna posizione aperta", "no open positions"), or a job board was read and lists nothing |
llm_failed | One or more LLM calls failed and no jobs were produced |
no_jobs_found | The site was read, no jobs were found and nothing says there are no vacancies |
not_read | The career page could not be fetched, rendered, discovered or finished (see reason and stop_reason) |
ats_handover | The jobs are on an ATS that career-site-jobs-scraper reads (see ats_url) |
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."}


