LinkedIn Jobs Scraper — Normalized Contract avatar

LinkedIn Jobs Scraper — Normalized Contract

Pricing

from $0.90 / 1,000 job results

Go to Apify Store
LinkedIn Jobs Scraper — Normalized Contract

LinkedIn Jobs Scraper — Normalized Contract

Public LinkedIn jobs mapped to nomad-agent-job-v1 from guest cards, detail markup, and JobPosting JSON-LD. Includes ready-made MCP, n8n, Make, and Airtable integration presets. Optional owner-managed AI fills only missing description-backed facts; optional DeepL translates selected short fields.

Pricing

from $0.90 / 1,000 job results

Rating

0.0

(0)

Developer

Nomad.Dev

Nomad.Dev

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

a few seconds ago

Last modified

Categories

Share

Unofficial integration. This Actor is independently developed and is not affiliated with, endorsed by, or sponsored by LinkedIn. LinkedIn is a trademark of its owner. Public page access is not authorization to crawl; review the LinkedIn Crawling Terms, the target site's other terms, and applicable law before use.

Ready-made integrations: MCP quickstart · n8n workflow · Make blueprint · Airtable preset · Claude / Codex skill

Fetch public LinkedIn job cards and detail pages without login or cookies, then map every statically available value to the repository's nomad-agent-job-v1 contract. Optional owner-managed position enrichment reads the full plain-text description and fills only contract fields the static parser left null.

It is a separate Actor so the existing compact and full-info LinkedIn output contracts remain unchanged.

Output

Every dataset item has exactly six top-level keys:

  • schemaVersion: always nomad-agent-job-v1
  • identity: LinkedIn source, posting ID, and canonical URL
  • data: normalized company, classification, location, employment, application, seniority, requirements, compensation, and constraints
  • custom: null by default; when companyProfileEnrichment succeeds, a versioned linkedin-company-profile-v1 payload containing exact facts from the bound canonical public company page
  • llm: not_requested by default; when enrichment is enabled it records the exact requested and filled data.* paths, status, provider, model, prompt version, and completion time
  • raw.description: complete plain-text description returned by LinkedIn
  • raw.descriptionHtml: complete source HTML returned by LinkedIn; untrusted and must be sanitized before rendering

Field schema

PathShape and meaning
identitysource (linkedin), nullable externalId, and nullable canonical posting url
data.companyNullable name, LinkedIn sourceId, department, company url, and logoUrl
data.classificationNullable industries[] and jobFunctions[]; domains[] and domainsRaw[] live directly under data
data.locations[]Raw label, country name/code, city, region, postal/street/facility fields, position count, and coordinates; the array itself may be null
data.employmentNullable work arrangements, applicant geography, schedules, contract types, duration, hours, and start date plus raw hours/date text
data.applicationPosted/deadline dates, reference number/issuer, timestamped applicant snapshot, observed application URL, direct-apply flag and raw method label, point-in-time availability evidence, hiring contacts, eligibility, and selection process
data.seniorityNullable raw labels and normalized generic or R1–R4 levels
data.requirementsNullable education paths, experience ranges, language requirements, required/preferred skills, certifications, qualifications prose, and specific requirements
data.benefits, data.funding, data.compensation, data.constraintsNullable benefits, programme, structured/raw pay, visa/work-authorization/security/location facts
llmStatus plus ordered requested/filled data.* paths and provider/model/prompt/completion provenance
rawNullable complete description and untrusted descriptionHtml

All declared keys are present. Unknown fields are null; an empty array is used only when the fetched source explicitly establishes an empty collection. The card's location label is retained in data.locations[].raw, even when no safe country/city normalization is available.

Two source-safe geography completions are applied first:

  • an ISO alpha-2 country supplied by LinkedIn is expanded to its canonical country name using pycountry (for example, ES to Spain);
  • when JSON-LD supplies a city and country but a null region, an explicit city, region, country card label can supply the region after the city and country components are validated.

If the region is still null and LinkedIn supplied coordinates plus a country, the Actor uses pinned offline reverse-geocode data. The inferred country must match the source country and the nearest reference point must be within 100 km; source and raw-label regions always win.

Static mapping uses LinkedIn's public detail markup and schema.org JobPosting JSON-LD. In particular, OccupationalExperienceRequirements.monthsOfExperience is converted to data.requirements.experience[].minimumYears. Prose-only requirements remain null unless optional position enrichment can recover them from the description.

Localized card URLs remain both the emitted identity URLs and detail source, so their richer JobPosting JSON-LD is retained. Exact observed label/value mappings cover English, Spanish, French, German, Portuguese, and Italian job criteria; unknown localizations stay null rather than being guessed. Text and list-of-Text jobBenefits and qualifications map to the existing common benefits and skills-qualifications fields; structured credential objects remain unmapped.

LinkedIn's source facts are kept in the common contract rather than a LinkedIn-only extension: industries and job functions are plain string arrays, the board-scoped company ID is data.company.sourceId, and the dynamic applicant label is a timestamped data.application.applicantSnapshot. Company branding uses data.company.logoUrl; there is no separate posting-media field.

Optional public company profiles

companyProfileEnrichment defaults to false. When enabled, the Actor accepts only the canonical HTTPS linkedin.com/company/<slug>/ URL already published by the normalized job and binds the returned page to that exact company slug (and source ID when the page exposes one). It never follows redirects or fetches the external company website, and it sends no account cookie, login, caller token, or proxy credential. Auth walls, challenges, foreign/wrong-company redirects, oversized pages, and selector drift are rejected.

At most 25 unique companies are considered per run with concurrency 2. Repeated jobs for the same canonical company reuse one in-run result, and recognized bound pages may reuse a parser-versioned owner cache for one day. A company failure is fail-open: the complete base job remains deliverable with custom: null, and there is no additional pay-per-event charge.

companyFilters is a separate nomad-agent-linkedin-company-filter-v1 contract and requires companyProfileEnrichment: true. Job filters and optional AI enrichment run first. The Actor then retrieves company profiles for the remaining ordered candidates, evaluates the company expression, and only then applies maxItems. Without companyFilters, it keeps the cheaper behavior of fetching profiles only for the final maxItems prefix. A filtered run may therefore fetch more company pages than it returns, but never more than the 25-company cap. Profiles beyond that cap, or unavailable/unverified profiles, are unknown: unknownPolicy: "exclude" drops them and "abort" fails the run.

Enable it with the input boolean; no API key or nested configuration is needed:

{
"schemaVersion": "nomad-agent-job-search-input-v1",
"keyword": "software engineer",
"location": "Spain",
"companyProfileEnrichment": true
}

Company-filter fields are profileUrl, sourceId, name, description, websiteUrl, industry, employeeCountRange.raw, employeeCountRange.minimum, employeeCountRange.maximum, headquarters, organizationType, foundedYear, specialties, and followerCount. Operators are the typed subset eq, neq, contains, not_contains, in, not_in, overlaps, contains_all, is_null, is_not_null, gt, gte, lt, and lte. For example:

{
"schemaVersion": "nomad-agent-job-search-input-v1",
"companyProfileEnrichment": true,
"companyFilters": {
"schemaVersion": "nomad-agent-linkedin-company-filter-v1",
"expression": {
"all": [
{"field": "industry", "operator": "contains", "value": "software"},
{"field": "employeeCountRange.minimum", "operator": "gte", "value": 50}
]
},
"unknownPolicy": "exclude"
}
}

The company page is parsed statically from bound public HTML/JSON-LD. It is never sent to the position LLM. aiEnrichment concerns only still-null job facts found in the job description.

Successful enrichment uses schema ID apify/job_custom_schemas/linkedin-company-profile-v1.schema.json. Its closed custom.data contains the canonical profile URL, nullable source ID and observation time, plus nullable name, description, direct public website URL, industry, explicit employee range, headquarters, organization type, founded year, explicit specialties, and follower count. Missing evidence stays null; an empty array is not substituted for an unknown collection. These broader profile fields do not mutate the common data.company object.

Language coverage

Job titles, descriptions, company names, location labels, and Schema.org JobPosting fields are retained as Unicode text in any source language. The Schema.org property names and enum codes are language-independent.

LinkedIn's visible job-criteria labels and human-readable enum values are a separate surface. Deterministic normalization recognizes empirically aligned English, Spanish, French, German, Portuguese, and Italian headings, plus the same-job seniority and full-time values observed for those locales. Other localized values still remain null rather than being translated or guessed.

translateToEnglish is disabled by default. When enabled, the bundled fastText lid.176.ftz model identifies each selected value independently. Values confidently identified as English are kept as-is; DeepL auto-detects and translates only the remaining values. Translation runs after static normalization and optional LLM enrichment. The LLM receives only the original, untranslated raw.description and is instructed to preserve the source language for every extracted free-text value. The Actor then overwrites only data.title, data.classification.industries[], and data.classification.jobFunctions[] with English. Descriptions, raw, company names, location fields, and LLM-enriched prose outside those three paths are never sent for translation. raw and llm provenance remain unchanged. No parallel data.translation object is emitted. The owner supplies DEEPL_API_KEY as an Actor secret; callers never submit their own key. Translation failure fails the opted-in run atomically instead of returning a mixed-language dataset. The cancellation-resistant synchronous translator works on private deep copies; only the observed Actor task applies a complete result before the monotonic translation deadline. A timed-out worker cannot mutate deliverable records or write cache entries later.

Successful exact-text translations use the creator-owned native-TTL gateway with a 30-day TTL. Historical Apify KVS values remain readable during cutover. Each key binds the exact policy, confidently detected source language, and source text, so identical text requested in different runs or by different customers can reuse one result only under the same language. A value is cached only when fastText and DeepL agree on the normalized source language; disagreement or absent provider detection stays uncached. A miss falls back directly to DeepL. Cache read/write errors fail open and successful fresh translations are written back best-effort.

The cache lifetime is infrastructure policy and is not an Actor input. Cache keys are versioned by target language, model, context, field policy, source language, and exact source text. Failed or language-ambiguous translations are never cached. Record keys contain only a SHA-256 digest of that identity; record values contain the English value plus detector/provider language evidence in the common TTL envelope described below.

DeepL calls retry transient network failures and HTTP 429/500/502/503/504 up to four times. The nominal waits are 0.1, 0.5, 1, and 4 seconds with 20% jitter. A numeric Retry-After replaces any shorter wait; the final jittered delay is capped at 30 seconds. Requests preserve field order and are packed to at most 120 KiB and 50 texts, leaving 8 KiB below DeepL's 128 KiB request limit. Queue wait, HTTP timeout, retry sleep, and every new batch share one absolute deadline; without an earlier caller deadline the local ceiling is 120 seconds.

The manifest maps DEEPL_API_KEY to the owner secret @linkedinNormalizedDeepLKey; add that secret before the first deployment.

Pricing

Each returned job costs $0.0009. When translateToEnglish is enabled, a row incurs one $0.006 English-translated result event when at least one selected value is returned in English, whether fulfilled from the 30-day cache or by a fresh DeepL request. A translated job therefore costs $0.0069 total. An already-English job remains $0.0009, even when the flag is enabled. When aiEnrichment.enabled is true, each successfully enriched row also incurs one tier-specific event; cache hits count and failed enrichment does not. Silver is the default accuracy tier, measured 99.44% across three runs of corrected GT v3, and costs $0.006 per success (position-enrichment). Its 50-description benchmark batches averaged 117.5 seconds (110.2–127.6 seconds). Gold adds a corrections-only verification run over the Silver result. It measured 117/118 (99.15%) in its initial verification test, costs $0.010 (position-enrichment-gold), and took 335.0 seconds end to end for the same benchmark size: 110.2 seconds for the first pass plus 224.8 seconds for verification. Invalid verification responses retain the Silver result. Gold has one verification run, not Silver's three-run pooled evidence. Timings vary with input size and provider load and are not per-job guarantees; neither accuracy result guarantees arbitrary future postings. Apify keeps PPE prices in platform configuration rather than the Actor manifest; configure and verify both events before publishing.

All enabled events share one Decimal maximum-total-charge budget. After complete details are available, the Actor reserves the jointly affordable ordered prefix at the worst-case per-row price before starting DeepL or LLM work. It then releases the unused translation/enrichment portion before delivery. Local and non-PPE development runs remain unmetered. A live PPE run fails before source processing if any enabled event is absent from live pricing.

The translation price is intentionally per result rather than per character so Actor users have a predictable bill. Only the three short field groups above are submitted to DeepL.

DeepL's current text-translation coverage includes all 36 languages listed by LinkedIn. This guarantees provider coverage for values the Actor successfully parses; it does not make LinkedIn's localized HTML labels structurally stable. The deterministic criteria-label parser is explicitly verified for English, Spanish, French, German, Portuguese, and Italian. JSON-LD, title, description, company, and location text remain language-agnostic Unicode surfaces in every locale.

The official compressed fastText language-identification model is bundled at about 0.94 MB and covers 176 languages. Classification uses a case-folded copy to handle uppercase titles, but preserves the original output. For an uncached value, the Actor skips DeepL only for an English prediction with probability at least 0.60; short or ambiguous values are sent to DeepL. Mixed-language rows are supported: their English values are retained and only their remaining values are translated.

Zero-result and error diagnostics are written to the run log, never inserted as non-contract dataset rows.

Raw output size

includeRaw defaults to true for compatibility. Set it to false for scheduled feeds and high-volume integrations that do not need exact source evidence; the result keeps all six contract roots and emits raw: null. This reduces dataset storage and transfer, but does not skip detail fetching or requested enrichment. The repository's RAW_OUTPUT_POLICY.md documents the versioned mode proposal and scripts/measure_output_size.py measures actual full-versus-no-raw bytes from a JSON/JSONL dataset export.

Rolling inventory and delivery

Cross-run suppression is enabled by default with "dedupe": {"enabled": true, "key": "", "replayEpoch": ""}. The empty key derives an isolated scope from this Apify user's canonical query/filter input, so unrelated users and searches do not share candidate backlog or delivery history. A nonempty key is an explicit opaque alert/profile scope and intentionally shares history across that user's profile searches. Optional replayEpoch is an opaque, 128-character reset generation: omission or an empty value preserves the historical scope exactly, while a new nonempty value intentionally replays the scope once and must then remain stable. Set enabled to false with both key and replayEpoch empty for a replay-all, storage-free run. Matching jobs may repeat while the feature is disabled. Within-run URL deduplication always remains enabled.

With dedupe.enabled: true, maxItems is a delivery limit, not a repeated slice of LinkedIn's newest cards. For a bounded selection, up to half the slots (rounded up) are reserved for the oldest retained unseen jobs; remaining slots prefer today's cards and then the rest of the FIFO backlog. This makes selection fairer but does not guarantee that a selected job passes later delivery stages.

Delivery state is isolated by Actor, APIFY_USER_ID, and dedupe.key. Pass a distinct public opaque key (for example a profile UUID) when alerts need separate histories. With an explicitly empty enabled key, the Actor derives the scope from the search and filter fields. The key is not a secret. Neither it, the user id, nor search values appear in the ledger record key; only a SHA-256 scope digest is stored there.

The persisted contract is normalized-job-inventory-v3 in the reset scope namespace normalized-job-delivery-scope-v2-reset. Legacy v1 KVS records are never decoded, copied, or claimed as migrated. The first 0.6 run therefore starts empty transactional history and may replay jobs returned by older Actor versions. V1 does not expose an acknowledged boolean with a silently true default: this release boundary is documented here, while any future intentional reset must use a new explicit state epoch/version. Encountering a legacy record at a new ledger key fails closed instead of overwriting it:

  • top level: schemaVersion, actorName, scopeDigest, freshnessDays, updatedAt, monotonic revision, nextOrder, nextFence, nullable activeRun, policy, counters, transactions, operationFingerprints, lastOperation, and entries;
  • policy tracks observation version, output transformation version, and replay policy separately. Those representation values never alter delivery identity;
  • each entry: the cheap candidate needed for later enrichment—including its title, company, location, full public job URL, and bounded card metadata—plus firstSeenAt, lastSeenAt, firstSeenOrder, nullable postedAt, and nullable deliveredAt; delivery state is bounded but not hash-only;
  • counters.evictedDelivered and counters.evictedUnseen are cumulative, non-sensitive evidence that the bounded ledger discarded state;
  • each transaction contains a deterministic batch ID, bounded opaque Actor-run and dataset IDs, ordered job keys and record hashes, expected per-row billing events, push evidence, actual per-event charged counts/prefixes/receipts, and an explicit reconciliation decision; no source text or credential is stored;
  • transitions cover pending, ambiguous/confirmed push, partial/full charge, committed, and aborted states. open_for_reconciliation acquires the same fence while permitting explicit inspect/resume/abort/commit decisions;
  • lastOperation binds an operation ID to a desired-state fingerprint. After an ambiguous CAS response, the Actor re-reads and adopts only its exact owner/fence/fingerprint, making exact operation retries idempotent;
  • deliveredAt: null means delivery has not yet been confirmed.

Full normalized descriptions and raw HTML are not stored in delivery state. Retained cheap candidates are enriched only when selected for delivery, keeping the single state record normally bounded at 600 candidates. Entries referenced by an unresolved transaction are pinned against expiry/eviction—even if this temporarily exceeds 600—until explicit reconciliation prevents evidence loss.

The local retention control is state-aware. Completed delivery journals and inventory scopes containing only committed/aborted transactions use a 35-day maximum idle TTL. Open or quarantined journals, and inventory scopes containing at least one non-terminal transaction, use a 90-day reconciliation TTL so an ambiguous push or charge is not silently forgotten. An active inventory fence alone does not extend retention: its lease is at most 31 minutes and its ledger record remains on the 35-day TTL. Once a scope has no unresolved transaction, terminal transaction copies and retry fingerprints are removed at release (or the next claim), preventing regular scope activity from refreshing old billing evidence indefinitely. The independent completed journal and committed delivery tombstones are finite and use at most the 35-day TTL. Deployment remains gated until the exact build's physical expiry and historical-row cleanup are verified on the PostgreSQL backend.

Inventory correctness requires a backend with atomic versioned_get and compare_and_swap. Acquisition CAS-writes a monotonically increasing fence into the ledger before any LinkedIn work starts. Every candidate, transaction, and release mutation uses that exact version/fence; a stale owner cannot save or release over a newer run. Same-scope contention, ambiguous reads/writes, unknown state, and unresolved transactions fail closed. A 30-second background heartbeat renews the active fence independently of candidate writes and cancels the run on failure. Lease TTL is bounded by the intended 1,800-second release timeout plus a 60-second shutdown reserve; correctness does not assume that an undeployed Actor timeout setting has already taken effect.

The current creator-owned Apify KVS adapter exposes only unconditional GET/PUT and is therefore intentionally rejected for delivery state. Every live PPE run, including runs with cross-run dedupe disabled, uses a separate per-run delivery journal in the same CAS service. Configure NOMAD_DELIVERY_LEDGER_URL with an HTTPS service that implements atomic versioned reads/CAS and NOMAD_DELIVERY_LEDGER_TOKEN with its bearer credential. Without both values, a live PPE run stops before source processing with a configuration error. Local/non-PPE runs need no journal. This is not a claim that ordinary Apify KVS has transactional behavior. The gateway must reject redirects, enforce request/response size limits, provide create-only CAS with monotonic versions and real TTL, and preserve linearizability across replicas. scripts/mock_delivery_ledger.py is an executable TLS reference mock and contract-test backend, not production storage. Historical authenticated probe evidence covered the earlier single-host SQLite/v1 gateway path: authentication rejection, one-winner concurrent CAS, conflicts, update, expiry, and ABA prevention. It does not prove the current PostgreSQL/0002_gateway_hardening gateway or the current v2 delivery candidate live. Exact-commit PostgreSQL readiness/restore evidence, overlapping same-scope Actor canaries, and delivery reconciliation remain unproven live release gates. Repository CI and local reports are engineering evidence only; they cannot approve deployment, source rights, privacy/legal review, or Store publication.

Every delivery read, journal transition, dataset push, charge, and inventory commit is admitted with the remaining monotonic delivery-stage budget and a bounded client await where the SDK exposes a cancellable awaitable. Push and charge intents are persisted before their irreversible platform calls, and a 20-second local reserve is retained for ambiguity evidence. These bounds stop new operations between stages; they do not claim that an Apify platform operation can be revoked after the remote service receives it. A platform client that suppresses cancellation may outlive its requested timeout and consume teardown reserve. The Actor keeps observing such an operation, rejects its late result, and leaves the persisted idempotency key/intent quarantined for reconciliation instead of abandoning it and starting another push or charge.

The ledger's conditional scheduling SLA is run-based. It reserves R = ceil(maxItems / 2) slots for the oldest retained unseen entries, so a backlog of U eligible entries is selected within at most ceil(U / R) successful same-scope runs, provided each earlier selected entry is fully charged and committed and the candidate remains fresh. The current upper bound, oldest-unseen age, backlog, and eviction counters are emitted as run telemetry. This is not an unconditional wall-clock or end-to-end delivery promise: final filters, incomplete details, capacity, charge/reconciliation failure, expiry, and storage availability can all prevent delivery. If the 600-entry bound is exceeded, the ledger evicts oldest delivered observations first and then newest unseen arrivals, preserving the oldest backlog; every eviction is both logged and counted. Nonzero unseen evictions mean the scope exceeded this Actor's delivery capacity and needs smaller/more frequent runs or a partitioned external ledger.

The always-on live-PPE journal is independent of inventory. It persists the ordered record hashes, expected row events, dataset baseline/rolling hash, push intent/evidence, and append-only charge attempts before each irreversible boundary. Journal schema nomad-delivery-journal-v2 is a closed, replay-checked state machine: evidence kinds, fields, ordering, batch IDs, row/event counts, record hashes, dataset offsets, idempotency keys, operation fingerprints, chargedAt, and terminal state must all agree. A fabricated or partially written complete envelope is rejected. Journal mutations are copy-on-write; local state advances only after a successful CAS and exact validated readback, so a failed or poisoned CAS response cannot leave a speculative in-memory transition.

Every charge uses a deterministic persisted idempotency key through the reviewed Apify 2.7.3 low-level adapter. The pinned low-level POST /charge is treated as atomic all-or-error: definitive API/HTTP rejection restores the manager's exact prior in-memory accounting, while timeout, transport, cancellation, protocol, unknown-outcome, and post-commit charging-log failures retain conservative accounting and require reconciliation. The adapter's synthetic ChargeResult is created only after the exact atomic request returns successfully. charged_count must be present, an exact integer (never a boolean), within the requested range, and equal to the requested count in live PPE; missing, malformed, partial, or excess evidence fails closed. Ambiguous push, dataset, charge, or CAS outcomes quarantine the run; they are never automatically re-pushed or re-charged. When dedupe is enabled, inventory writes deliveredAt only after that independent batch is fully charged. A row with an incomplete detail fetch is never enriched, translated, pushed, or charged; its inventory entry remains retryable on the next run. postedWithin is the one public freshness control for both source discovery and the inventory work queue: 1h, 24h, 7d, or 30d maps to the equivalent LinkedIn f_TPR duration and expiry window; any omits f_TPR and disables posting-age expiry for unseen work. It does not create indefinite finalized delivery evidence: committed delivery tombstones remain capped at 35 days. The default is 30d. Known posting dates are authoritative for work-queue expiry; unknown-date entries expire from first discovery. The creator-managed raw HTTP response cache has a 30-minute logical read TTL and remains separate from delivery state. Search pages, canonical LinkedIn job pages, and guest-detail fallback pages use distinct source-URL/query-bound keys in the native response cache. A miss or cache error fetches LinkedIn live. This cache intentionally stores recognized public LinkedIn response bodies: they are fresh for no longer than 30 minutes and physically retained only through the bounded six-hour stale-if-error grace. HTTP failures are not written to cache. For canonical and guest detail pages, empty bodies, auth-wall shells, and responses without both full text and source HTML are also rejected, so an incomplete detail fetch remains retryable. Search caching is page-based, not an assembled-result cache: every LinkedIn start offset has its own raw-body record, and each run still paginates, parses, combines, and URL-deduplicates the pages it needs.

Every live LinkedIn attempt is also admitted by the creator's shared fleet gateway. Two reported LinkedIn 403/429 outcomes open the fleet circuit for one minute; while open, concurrent Actor containers start no further LinkedIn traffic. After the reset, one lease is admitted as the half-open probe and all other leases remain refused until that probe succeeds or reopens the circuit. An open circuit may use only a still-valid, source-validated stale response from the bounded six-hour stale-on-error window. The run summary RUN-SUMMARY then explicitly reports stale: true, partial: true, the source observation time, stop reason, and a reschedule.notBefore recommendation. The Actor does not silently reboot or create a second paid run; an Apify Schedule/task controller can honor that recommendation.

Creator-owned storage

LinkedIn caches do not use the calling run's default KVS. The Actor owner configures the private native-TTL gateway with:

  • NOMAD_CACHE_GATEWAY_URL and NOMAD_CACHE_GATEWAY_TOKEN;
  • NOMAD_CACHE_GATEWAY_ACTOR_NAME, which namespaces cache identities; and
  • NOMAD_CACHE_GATEWAY_REQUIRED=1, which disables optional caching on partial configuration instead of silently restarting legacy KVS writes.

The gateway upserts one row per Actor/cache-kind/full-SHA-256 key. It stores recognized public LinkedIn response bodies for 30 minutes plus at most six hours of stale-if-error retention. It separately stores exact translated values and sanitized validated LLM patches for 30 days; those rows do not contain raw source descriptions or prompts. A private maintainer prunes 256 hash partitions through bounded indexed deletes using a distinct maintenance credential and a gateway-owned cursor/lease. It can run without legacy KVS credentials. See ../shared-kvs-expiry-cleaner/README.md for the wire contract, logical versus physical deletion semantics, cleanup-lag alerts, capacity math, and cutover/rollback runbook.

Delivery, fleet admission, provider-spend control, and cache access use different capability secrets. NOMAD_PROVIDER_SPEND_TOKEN is intentionally separate from NOMAD_FLEET_CONTROL_TOKEN; the cache uses its own NOMAD_CACHE_GATEWAY_TOKEN.

Four historical KVS resources remain configured for migration reads and delivery compatibility:

  • NOMAD_POSITION_CACHE_KVS_ID: public LinkedIn HTTP bodies, 30 minutes;
  • NOMAD_TRANSLATION_CACHE_KVS_ID: exact translation results, 30 days;
  • NOMAD_LLM_CACHE_KVS_ID: validated enrichment patches, 30 days; and
  • NOMAD_DELIVERY_KVS_ID: legacy delivery store allocation; it is not used for transactional delivery state because the KVS API lacks CAS.

NOMAD_SHARED_KVS_TOKEN maps to the owner-only nomadSharedKvsToken secret, scoped to those stores. Historical records use {"schemaVersion":"nomad-creator-kvs-entry-v1","storedAt":...,"expiresAt":...,"value":...}. Legacy reads accept only that envelope, bounded v2 pointers, and immutable content-bound targets. Exact expired immutable targets are reclaimed through their due indexes; pre-v2 mutable keys remain read-only migration data. Once the gateway is configured, cache writes never fall back to KVS. Missing configuration, authentication failure, expiry, malformed JSON, and transport errors all fail credential/domain circuit for a 30-second cooldown. Cache domains treat unavailable, expired, or malformed records as misses. Search and detail HTTP bodies also require source-specific validators before persistence; unknown/challenge HTML is never cached. Optional writes start only when the entire bounded synchronous owner-KVS operation plus a safety margin fits inside their stage. Once started, the Actor observes the worker through cancellation before returning; it never hands a mutating PUT to the detached hard-timeout helper or reports a write as skipped while that PUT can still complete later. A skipped/failed translation cache write never invalidates a successful paid translation. Delivery does not use this fail-open API: when enabled, it calls the configured HTTPS atomic ledger and fails before scraping on uncertainty.

Inventory keys remain tenant-isolated. The opaque scope digest binds Actor name, APIFY_USER_ID, and either the public dedupe.key or canonicalized search and profile inputs. Source-job identity then uses the stable LinkedIn job ID (with canonical URL only as its fallback). Observation, LLM/normalization transformation, output translation, and replay-policy versions are persisted as metadata but never enter this tenant/profile/query/job delivery identity. dedupe.key remains an opaque profile scope: changing keywords while retaining the same explicit key intentionally shares its history. dedupe.replayEpoch is included only when nonempty, so its default preserves the existing digest; changing it deliberately starts a new delivery generation and does not happen automatically with representation updates. These tenant/search values are not stored in the record key. If APIFY_USER_ID is unavailable, an enabled cross-run dedupe request fails closed rather than creating a shared or run-local substitute scope. The response, translation, LLM, and inventory resources are separate stores so their retention and access policies cannot accidentally overlap. Store provisioning and token rotation are deployment operations outside this Actor.

This completed breaking input migration requires the explicit current schemaVersion; omitted-schema Tasks and removed keys fail closed rather than silently running in repeat-all mode. It consolidates the two overlapping freshness knobs into postedWithin, replaces the boolean workplace knob with workArrangements, replaces specialized exclusion lists with the versioned filters expression, removes the explicit job-ID denylist, and replaces deliveryMode plus dedupeKey with the shared nested dedupe contract.

Runtime rejection covers all former public keys: timeFilter, postedSince, remote, deliveryMode, dedupeKey, titleExclude, companyExclude, skipJobId, aiProvider, aiModel, openaiApiKey, openaiModel, mistralApiKey, mistralModel, anthropicApiKey, cacheTtlSeconds, and translationCacheTtlSeconds. Each error names its current replacement or the owner-managed policy that supersedes it. Other unknown fields fail closed and nearby typos suggest a current key.

Input

The public search and filtering inputs are:

  • schemaVersion: nomad-agent-job-search-input-v1
  • keyword, location
  • maxItems: default 100; 0 requests up to the full bounded 200-item window
  • postedWithin: 1h, 24h, 7d, 30d (default), or any
  • workArrangements: any union of remote, hybrid, and onsite; omitted means all arrangements
  • optional versioned filters expression described below
  • optional strictGeography source contract. Countries use uppercase ISO-2 codes; configured country/region/city axes must match on the same physical location object. Unknown source facts are excluded or can abort the run
  • dedupe: default {"enabled": true, "key": "", "replayEpoch": ""}; the empty key isolates by Apify user and canonical query/filter scope, a public opaque profile key intentionally shares history across that user's searches, an optional nonempty replay epoch starts a fresh delivery generation, and enabled: false with both strings empty explicitly opts out
  • translateToEnglish (default false; additional charge for each returned row containing at least one English-translated selected value)
  • aiEnrichment (default {"enabled": false, "accuracy": "silver"}; accuracy is silver or gold; callers must use this exact object shape and boolean values are rejected; no customer API key is requested)
  • companyProfileEnrichment (default false; bounded public LinkedIn company facts in versioned custom, with no login and no separate PPE charge)
  • optional separately versioned companyFilters; requires company enrichment and evaluates company facts before maxItems chooses the final result prefix
  • includeRaw (default true; set false to return top-level raw: null after any requested enrichment has used the complete description internally)
  • analyticsEnabled (default false; see the privacy section below)

Search-response, translation, and LLM-result cache lifetimes are owner-managed infrastructure policy and are not public Actor inputs.

Generalized filters

filters has schema version nomad-agent-job-filter-v1 and one recursive expression. A node is either {"all": [...]}, {"any": [...]}, {"not": {...}}, or a predicate with field, operator, and (except for null checks) value. For example:

{
"filters": {
"schemaVersion": "nomad-agent-job-filter-v1",
"expression": {
"all": [
{"field": "data.title", "operator": "not_contains", "value": "manager"},
{"any": [
{
"field": "data.employment.workArrangements",
"operator": "overlaps",
"value": ["remote", "hybrid"]
},
{"field": "data.compensation.minimum", "operator": "gte", "value": 70000}
]}
]
}
}
}

The allowlisted paths are enumerated in the Actor input schema. [] in a path, such as data.requirements.requiredSkills[].name, projects the field from every array item. Operators are eq, neq, contains, not_contains, in, not_in, overlaps, contains_all, is_null, is_not_null, gt, gte, lt, and lte. String equality, membership, and substring comparisons are case-insensitive; ordering operators accept numbers only.

Card-stable identity, title, and company-name predicates are evaluated before detail requests. Every other path is treated as unknown at that stage, not as a failed match. After static detail normalization, predicates over source-owned fields and already-non-null null-fill roots are evaluated again; rows proven false are removed before any LLM request. Predicates whose null values may still be filled remain unknown. The complete expression is evaluated exactly after optional LLM extraction but before optional English output translation, so filter text targets the source language. Strict geography runs at the static boundary and accepts source location evidence only. When a rich filter is present the Actor may parse more than maxItems candidates; it returns at most maxItems survivors. Raw descriptions, custom, and LLM-provenance paths are not filterable.

Example strict geography input:

{
"strictGeography": {
"schemaVersion": "nomad-agent-linkedin-strict-geography-v1",
"countries": ["ES"],
"regions": ["Catalonia"],
"cities": ["Barcelona"],
"unknownPolicy": "exclude"
}
}

Position LLM enrichment (aiEnrichment, owner-managed)

Plain-text raw.description is the only posting evidence sent for position extraction. The complete plain-text description is sent without an application-level character cap. Raw HTML and the rest of the normalized record are never sent. The evaluated modes may fill only these currently-null targets:

  • domains, required skills, preferred skills, and selection process;
  • education, explicit numeric experience, human-language requirements, and certifications/licenses; and
  • detailed benefits, work authorization, explicit location/travel preference, and prose-only remote/hybrid/onsite arrangement.

Other normalized fields remain static-source-only until they have their own evidence set and evaluated extraction contract.

Source-owned fields are never requested from the model: posting/company IDs, URLs and branding, title/company identity, published time, applicant counts, direct-apply state, apply-method labels, availability observations, hiring contacts and their photos, source classification labels, and every *Raw provenance field outside the explicit hours/start/pay phrases above. Existing static values always win, including empty arrays that the source explicitly established. The model response is schema-validated before merge.

Before a provider request, the Actor checks the creator-owned native LLM-result cache, then historical KVS during migration. The key binds the complete description to the provider, model, prompt, system prompt, sanitizer version, audited routing policy version, exact allowlist/order, and complete manifest fingerprint. A hit therefore avoids the provider request and its cost without allowing an older extraction or privacy policy to masquerade as a current result.

The store record key is the opaque llmcache_ SHA-256 identity and its value is a validated, sanitized extraction patch plus served-model, served-provider, generation, finish, token, cost, and routing-policy provenance. It never stores the source description, prompt text, provider key, raw provider response, raw HTML, or complete job record. Old entries without current provenance and entries from providers outside the current allowlist are ignored. The patch can contain public facts extracted from the posting, such as skills, compensation, an application email, or a work location. It has an owner-controlled 30-day TTL in the native cache. Historical pre-v2 KVS bytes may outlive logical expiry until the legacy stores are retired. Cache failures fall through to the Actor owner's provider. Failed, malformed, and contract-invalid replies are never cached; a valid extraction containing zero facts is cached.

Extraction scans each posting in four thematic field-group calls. Silver returns that atomic result. Gold sends the result and description to one additional corrections-only verification pass, for at most five calls per posting. Both tiers isolate selection_process, use low reasoning excluded from the response, require exact strict JSON Schemas, omit temperature, and disable provider fallback. A failed first-pass group discards all group patches for that posting; a malformed, ungrounded, or failed Gold verifier retains the complete first pass. Only HTTP 408, 429, 5xx, and transient transport failures may retry once within the absolute deadline and the run reservation; Retry-After is capped at five seconds. Other 4xx responses, routing/privacy drift, non-stop finishes, malformed JSON, inconsistent usage/provenance, and invalid output never retry. The deadline is an absolute monotonic value. A provider coroutine that suppresses cancellation is quarantined at an independent timer boundary; its late result cannot modify a posting or enter cache. The default limits are 24 provider rows per group, 32 attempts per group, 1,200 output tokens per request, 60 seconds per request, 300,000 input tokens, 64,000 output tokens, and USD 0.25 of provider spend per run. A missing key, manifest, or description leaves the base posting intact and sets that row's llm.status to failed. A successful call may legitimately fill zero fields. Each completed enrichment incurs one Silver position-enrichment or Gold position-enrichment-gold pay-per-event charge whether the validated patch came from cache or a fresh provider request. Failed enrichment is not charged.

Creator configuration is owner-only and OpenRouter-only. The manifest maps OPENROUTER_API_KEY to the Apify secret nomadPositionLlmOpenRouterKey. The Silver and Gold routing manifests are pinned separately in the owner environment and use one audited endpoint without fallback. Anthropic IDs, OpenRouter routers, aliases, modifiers, free/latest variants, and malformed catalog IDs are rejected as incomplete configuration. Users cannot override provider, model, or key through Actor input. Configure the both enrichment events in Apify pricing before publishing this version. Each versioned tier routing manifest is mandatory and must name the immutable catalog model ID, the same single provider in its order and keyed legal/endpoint/region disclosure, policy version, review timestamp, and price ceilings. Every request denies data collection, restricts both only and order to that provider, disables provider fallback, and requires every parameter. Both benchmarked endpoints are explicitly disclosed as non-ZDR; selecting either tier accepts that retention-policy difference for public description text. Runtime discovery never widens the list. See OPENROUTER_PRIVACY_AND_SPEND.md for subprocessors, retention, the explicit absence of an EU-residency claim, per-run hard caps, and the private gateway's atomic cross-run daily spend cap.

Migration in Actor 0.6: the former provider, model, and customer API-key inputs were removed. Only the exact tiered aiEnrichment object remains public; the former boolean form is rejected. Cache lifetimes are owner-managed infrastructure policy.

Optional anonymous run analytics

analyticsEnabled is disabled by default. When a caller explicitly enables it and the Actor owner has configured a receiving server, the Actor sends one best-effort HTTPS event after the run. Analytics failure never changes the run status, dataset, or billing.

The event contains only:

  • Actor name/version and Apify build number;
  • a closed-enum success category and coarse duration bucket;
  • the number of dataset items actually emitted;
  • names of enabled features, never their values; and
  • closed-enum health codes for search, detail enrichment, LLM enrichment, translation, and delivery; and
  • aggregate cache/retry counters (creator-store hits, misses, legacy counters, DeepL requests and retries, batch count, largest request size, LLM-result cache hits/misses, and position-enrichment provider-attempt count).

It never contains an Apify user, run, task, dataset, or storage ID; search keywords or locations; input/output records; job URLs; raw errors; tokens; IP-derived data; or a persistent caller identifier. The Actor does not persist the event. The receiving service must expire raw events within 30 days, after which only non-identifying aggregates may remain. Contact the Actor creator through the Apify issue tab about this processing.

Creator setup uses the HTTPS receiver at https://api.oinkjobsearch.com/api/v1/actor-analytics and the owner-only NOMAD_ACTOR_ANALYTICS_TOKEN environment variable. The endpoint must be HTTPS. Neither value is accepted as Actor input or included in the event.

Example

{
"schemaVersion": "nomad-agent-job-v1",
"identity": {
"source": "linkedin",
"externalId": "4446226935",
"url": "https://www.linkedin.com/jobs/view/software-engineer-4446226935"
},
"data": {
"title": "Software Engineer",
"company": {
"name": "Acme",
"sourceId": "6453825",
"department": null,
"url": "https://www.linkedin.com/company/acme",
"logoUrl": "https://example.com/logo.png"
},
"classification": {
"industries": ["Software Development"],
"jobFunctions": ["Engineering"]
},
"domains": null,
"domainsRaw": null,
"locations": [
{
"raw": "Madrid, Community of Madrid, Spain (Hybrid)",
"countryName": "Spain",
"countryCode": "ES",
"city": "Madrid",
"region": "Community of Madrid",
"postalCode": null,
"streetAddress": null,
"facilityName": null,
"positionsAvailable": null,
"latitude": null,
"longitude": null
}
],
"employment": {
"workArrangements": ["hybrid"],
"applicantLocationRequirements": null,
"workSchedules": ["full_time"],
"contractTypes": null,
"durationMonths": null,
"hoursPerWeek": null,
"hoursPerWeekRaw": null,
"startDate": null,
"startDateRaw": null
},
"application": {
"postedAt": "2026-08-03",
"deadline": null,
"referenceNumber": "4446226935",
"referenceNumberIssuer": "Acme",
"applicantSnapshot": {
"count": 108,
"raw": "108 applicants",
"capturedAt": "2026-08-05T12:00:00Z"
},
"url": null,
"email": null,
"directApply": null,
"applyMethodRaw": "Easy Apply",
"availability": {
"status": "observed_available",
"evidence": [
{"kind": "search_card_present", "value": "4446226935"}
],
"observedAt": "2026-08-05T12:00:00Z"
},
"hiringContacts": [
{
"name": "Ada Recruiter",
"title": "Talent Partner",
"organization": null,
"url": "https://www.linkedin.com/in/ada-recruiter",
"photoUrl": "https://media.licdn.com/example.jpg",
"email": null,
"address": null
}
],
"eligibilityCriteria": null,
"selectionProcess": null
},
"seniority": {
"raw": ["Entry level"],
"levels": ["entry"]
},
"requirements": {
"education": null,
"experience": null,
"languages": null,
"requiredSkills": null,
"preferredSkills": null,
"certifications": null
},
"funding": {
"programme": null
},
"compensation": {
"currency": null,
"exact": null,
"minimum": null,
"maximum": null,
"period": null,
"raw": null
},
"constraints": {
"visaSponsorship": null,
"workAuthorization": null,
"securityClearance": null,
"locationPreference": null
}
},
"custom": null,
"llm": {
"status": "not_requested",
"requestedFields": [],
"filledFields": [],
"provider": null,
"model": null,
"promptVersion": null,
"completedAt": null
},
"raw": {
"description": "Full plain-text posting…",
"descriptionHtml": "<p>Full HTML posting…</p>"
}
}

Parse dataset output

The skill's zero-dependency parser adds stronger structural checks and returns a typed convenience view while retaining the complete record. A minimal inline adapter is:

def parse_linkedin_output(item: dict) -> dict:
expected = {"schemaVersion", "identity", "data", "custom", "llm", "raw"}
if set(item) != expected:
raise ValueError("unexpected nomad-agent-job-v1 envelope")
if item["schemaVersion"] != "nomad-agent-job-v1":
raise ValueError("unsupported schemaVersion")
if item["identity"].get("source") != "linkedin" or item["custom"] is not None:
raise ValueError("not a normalized LinkedIn record")
data = item["data"]
return {
"id": item["identity"].get("externalId"),
"postingUrl": item["identity"].get("url"),
"title": data.get("title"),
"company": data["company"].get("name"),
"locations": data.get("locations"),
"postedAt": data["application"].get("postedAt"),
"applicationUrl": data["application"].get("url"),
"applyMethodRaw": data["application"].get("applyMethodRaw"),
"availability": data["application"].get("availability"),
"description": (
item["raw"].get("description") if item["raw"] is not None else None
),
"llmStatus": item["llm"].get("status"),
"normalized": item,
}
def parse_linkedin_outputs(items: list[dict]) -> list[dict]:
return [parse_linkedin_output(item) for item in items]

Keep normalized (or persist the original item separately) so the adapter does not discard requirements, compensation, contacts, raw provenance, or future consumer fields. Preserve null versus [].

Run through the API

curl -X POST \
"https://api.apify.com/v2/acts/nomad-agent~linkedin-enrich-translate-normalize-scraper/run-sync-get-dataset-items?token=<YOUR_APIFY_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"keyword":"software engineer","location":"Spain","maxItems":10,"translateToEnglish":true}'

LinkedIn's terms restrict automated access; review the target site's terms and applicable law for your use case. Named hiring contacts, profile/photo URLs, application emails, raw descriptions, and source HTML can contain personal data. For routine and high-volume use, set includeRaw=false, configure a bounded dataset retention period, restrict downstream access, and support correction, deletion, and objection requests. See the local PRIVACY_RETENTION_AND_GDPR_REVIEW_2026-08-09.md and APIFY_PUBLICATION_CHECKLIST_2026-08-09.md.