LinkedIn Jobs Scraper — Normalized Contract
Pricing
from $0.90 / 1,000 job results
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.
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: alwaysnomad-agent-job-v1identity: LinkedIn source, posting ID, and canonical URLdata: normalized company, classification, location, employment, application, seniority, requirements, compensation, and constraintscustom:nullby default; whencompanyProfileEnrichmentsucceeds, a versionedlinkedin-company-profile-v1payload containing exact facts from the bound canonical public company pagellm:not_requestedby default; when enrichment is enabled it records the exact requested and filleddata.*paths, status, provider, model, prompt version, and completion timeraw.description: complete plain-text description returned by LinkedInraw.descriptionHtml: complete source HTML returned by LinkedIn; untrusted and must be sanitized before rendering
Field schema
| Path | Shape and meaning |
|---|---|
identity | source (linkedin), nullable externalId, and nullable canonical posting url |
data.company | Nullable name, LinkedIn sourceId, department, company url, and logoUrl |
data.classification | Nullable 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.employment | Nullable work arrangements, applicant geography, schedules, contract types, duration, hours, and start date plus raw hours/date text |
data.application | Posted/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.seniority | Nullable raw labels and normalized generic or R1–R4 levels |
data.requirements | Nullable education paths, experience ranges, language requirements, required/preferred skills, certifications, qualifications prose, and specific requirements |
data.benefits, data.funding, data.compensation, data.constraints | Nullable benefits, programme, structured/raw pay, visa/work-authorization/security/location facts |
llm | Status plus ordered requested/filled data.* paths and provider/model/prompt/completion provenance |
raw | Nullable 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,EStoSpain); - when JSON-LD supplies a city and country but a null region, an explicit
city, region, countrycard 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, monotonicrevision,nextOrder,nextFence, nullableactiveRun,policy,counters,transactions,operationFingerprints,lastOperation, andentries; policytracks observation version, output transformation version, and replay policy separately. Those representation values never alter delivery identity;- each entry: the cheap
candidateneeded for later enrichment—including its title, company, location, full public job URL, and bounded card metadata—plusfirstSeenAt,lastSeenAt,firstSeenOrder, nullablepostedAt, and nullabledeliveredAt; delivery state is bounded but not hash-only; counters.evictedDeliveredandcounters.evictedUnseenare 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, andabortedstates.open_for_reconciliationacquires the same fence while permitting explicit inspect/resume/abort/commit decisions; lastOperationbinds 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: nullmeans 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_URLandNOMAD_CACHE_GATEWAY_TOKEN;NOMAD_CACHE_GATEWAY_ACTOR_NAME, which namespaces cache identities; andNOMAD_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; andNOMAD_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-v1keyword,locationmaxItems: default100;0requests up to the full bounded 200-item windowpostedWithin:1h,24h,7d,30d(default), oranyworkArrangements: any union ofremote,hybrid, andonsite; omitted means all arrangements- optional versioned
filtersexpression described below - optional
strictGeographysource 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, andenabled: falsewith both strings empty explicitly opts outtranslateToEnglish(defaultfalse; additional charge for each returned row containing at least one English-translated selected value)aiEnrichment(default{"enabled": false, "accuracy": "silver"}; accuracy issilverorgold; callers must use this exact object shape and boolean values are rejected; no customer API key is requested)companyProfileEnrichment(defaultfalse; bounded public LinkedIn company facts in versionedcustom, with no login and no separate PPE charge)- optional separately versioned
companyFilters; requires company enrichment and evaluates company facts beforemaxItemschooses the final result prefix includeRaw(defaulttrue; setfalseto return top-levelraw: nullafter any requested enrichment has used the complete description internally)analyticsEnabled(defaultfalse; 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.