# LinkedIn Jobs Scraper — Normalized Contract (`nomad-agent/linkedin-enrich-translate-normalize-scraper`) Actor

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.

- **URL**: https://apify.com/nomad-agent/linkedin-enrich-translate-normalize-scraper.md
- **Developed by:** [Nomad.Dev](https://apify.com/nomad-agent) (community)
- **Categories:** Jobs
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.90 / 1,000 job results

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

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

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

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

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## LinkedIn Jobs Scraper — Normalized Contract

> **Ready-made integrations:** [MCP quickstart](https://github.com/Exdenta/nomad-agent-job-scrapers/blob/main/docs/mcp.md) · [n8n workflow](https://github.com/Exdenta/nomad-agent-job-scrapers/tree/main/integrations/n8n) · [Make blueprint](https://github.com/Exdenta/nomad-agent-job-scrapers/tree/main/integrations/make) · [Airtable preset](https://github.com/Exdenta/nomad-agent-job-scrapers/tree/main/integrations/airtable) · [Claude / Codex skill](https://github.com/Exdenta/nomad-agent-job-scrapers/blob/main/.agents/skills/linkedin-enrich-translate-normalize-scraper/SKILL.md)

Fetch public LinkedIn job cards and detail pages without login or cookies, then
map every statically available value to the normalized `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

| 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, `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:

```json
{
  "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:

```json
{
  "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. Translation credentials are
creator-managed; 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.

#### 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. Current event prices are
shown in Apify before a paid run starts.

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.

### 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.

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.

Delivery state uses a creator-managed transactional service rather than the
calling run's ordinary KVS. If the service is unavailable or returns ambiguous
state, a paid run stops before scraping or leaves the transaction quarantined
for reconciliation instead of risking duplicate delivery or billing.

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.

### Storage and retention

LinkedIn caches do not use the calling run's default KVS. Creator-managed,
tenant-isolated storage keeps one row per Actor, cache kind, and hashed 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.

Cache authentication and maintenance are creator-managed. Unavailable,
expired, or malformed cache records are treated as misses. Search and detail
responses must pass source-specific validation before persistence, so challenge
or unknown HTML is never cached. A skipped or failed optional cache write never
invalidates a successful paid translation. Delivery state remains fail-closed
on uncertainty.

Inventory keys remain tenant-isolated. The opaque scope digest binds Actor name,
the caller's Apify account identity, 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 the caller's account identity 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.

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:

```json
{
  "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:

```json
{
  "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.

Enrichment configuration is creator-managed and OpenRouter-only. Users cannot
override the provider, model, or API key through Actor input. Requests use one
audited endpoint without provider fallback and deny provider data collection
through OpenRouter's routing controls. The selected endpoint is non-ZDR and
does not provide an EU-residency guarantee; enabling either enrichment tier
accepts that retention-policy boundary for public job description text.

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.

Receiver configuration is creator-managed, HTTPS-only, and is neither accepted
as Actor input nor included in the event.

### Example

```json
{
  "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](https://github.com/Exdenta/OinkAIJobSearch/blob/main/skill/linkedin-enrich-translate-normalize-scraper/scripts/parse_output.py)
adds stronger structural checks and returns a typed convenience view while
retaining the complete record. A minimal inline adapter is:

```python
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

```bash
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}'
```

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.

Contact the Actor creator through the Apify issue tab for privacy requests.

# Actor input Schema

## `schemaVersion` (type: `string`):

Version of the shared normalized job-search Actor input contract.

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

Job title, skill or role to search for (e.g. <code>software engineer</code>, <code>product manager react</code>). Leave empty to search all recent postings for the given location.

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

City, region or country to filter by (e.g. <code>Spain</code>, <code>London</code>, <code>European Union</code>). Leave empty for worldwide results.

## `linkedinSearch` (type: `object`):

Optional, separately versioned LinkedIn search extension. Supply up to 8 keyword/location partitions; the Actor visits them in deterministic round-robin order, makes at most 24 search-page requests across the entire run, deduplicates postings globally, and applies maxItems only once to the combined result. Omit searches (or use an empty array) to use the top-level keyword/location search. Non-empty top-level keyword/location cannot be combined with non-empty searches.

## `strictGeography` (type: `object`):

Optional fail-closed physical-location enforcement after static source normalization and before LLM enrichment. Every configured country, region, and city axis must match on the same normalized location object. Countries are uppercase ISO-2 codes. Unknown source geography is excluded by default or can abort the run; description prose and LLM-filled locations are never accepted as evidence.

## `workArrangements` (type: `array`):

Optional LinkedIn workplace filter. Select one or more values; unions such as <code>\["remote", "hybrid"]</code> are sent to LinkedIn together. Omit this field to include every arrangement.

## `translateToEnglish` (type: `boolean`):

After normalization, optional LLM enrichment, and final filtering, fastText identifies each normalized job title, industry, and job-function value; confident English values are kept as-is and only the remaining values are translated with the Actor owner's DeepL account. Exact-text results are checked in a creator-owned Apify key-value store shared across runs and customers; a miss falls back directly to DeepL. English-only results incur no translation charge. The full description, raw text/HTML, company, location, LLM provenance, and LLM-enriched prose outside the three selected paths remain in their original language. Each English-translated result incurs an additional $0.006 whether cached or fresh.

## `aiEnrichment` (type: `object`):

Reads only a complete public plain-text description and fills normalized fields that static LinkedIn markup left null. Static facts always win, and no customer API key is requested. Silver is the default: 99.44% measured parsing accuracy across three runs, $0.006 per successfully enriched result, and an average 117.5 seconds for a 50-description benchmark batch (110.2–127.6 seconds). Gold adds a corrections-only verification run over Silver: 99.15% initial measured parsing accuracy, $0.010 per successfully enriched result, and 335.0 seconds end to end for the same benchmark size (110.2-second first pass plus 224.8-second verification). Invalid verification output safely keeps the Silver result. Timings are benchmark measurements, not per-job guarantees, and vary with input size and provider load. Failed or unnecessary enrichment is not charged.

## `companyProfileEnrichment` (type: `boolean`):

When enabled, fetches a bounded set of public LinkedIn company pages already linked by selected job postings and places exact public profile facts in the versioned custom extension. Requests use no login, account cookie, caller token, redirect, or arbitrary external URL. Duplicate companies are fetched once per run and may reuse the Actor owner's validated one-day cache. A blocked, unavailable, oversized, unbound, or deadline-limited company page leaves the complete base job unchanged with custom null. This option adds no separate pay-per-event charge.

## `companyFilters` (type: `object`):

Optional nomad-agent-linkedin-company-filter-v1 expression over the separately versioned public company-profile extension. Requires companyProfileEnrichment=true. Eligible company profiles are retrieved after job and optional AI filters but before maxItems selects the final output prefix. Unavailable, blocked, unverified, deadline-limited, or over-cap profiles are unknown and are excluded by default or can abort the run.

## `includeRaw` (type: `boolean`):

When enabled (default), each result includes the complete LinkedIn plain-text description and source HTML in <code>raw</code>. Disable it to return <code>raw: null</code>. Raw text remains available internally for requested position enrichment before it is removed from the output.

## `postedWithin` (type: `string`):

One freshness window for both live LinkedIn discovery and the retained unseen-job inventory. <code>any</code> omits LinkedIn's publication-time filter and disables inventory age expiry.

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

Maximum number of job postings to return. Set <code>0</code> to request up to the Actor's full bounded window of 200 items. Values above 200 are not accepted.

## `dedupe` (type: `object`):

Cross-run delivery suppression is enabled by default. Leave <code>key</code> empty to isolate history by this Apify user and the canonical query/filter scope, or provide a public opaque alert/profile key to intentionally share history across that profile's searches. Optional <code>replayEpoch</code> starts a new delivery generation; keep it stable after an intentional replay. Set <code>enabled</code> to false with both <code>key</code> and <code>replayEpoch</code> empty for an explicit storage-free opt-out. The first 0.6 run starts a new transactional history because the former KVS state is not migrated.

## `filters` (type: `object`):

Optional nomad-agent-job-filter-v1 AND/OR expression over allowlisted nomad-agent-job-v1 fields. Use the JSON shape documented in the README. The Actor strictly validates schemaVersion, expression depth, field paths, operators, and value types at runtime. Card-stable predicates run before detail requests; predicates proven false from final static normalized facts run before LLM enrichment; remaining predicates run exactly after optional LLM enrichment. Filters see source-language values before optional output translation. maxItems remains the final delivery limit.

## `analyticsEnabled` (type: `boolean`):

Opt in to one fail-open HTTPS event after the run. It contains only Actor/build version, a success category, a coarse duration bucket, emitted-item count, names of enabled features, closed-enum source-health codes, aggregate cache/retry counters, and the number of position-enrichment provider attempts. It never includes your Apify user/run/task IDs, search values, input or output records, URLs, raw errors, tokens, source text, translations, extracted values, or a persistent caller identifier.

## Actor input object example

```json
{
  "schemaVersion": "nomad-agent-job-search-input-v1",
  "keyword": "frontend developer typescript",
  "location": "Germany",
  "linkedinSearch": {
    "schemaVersion": "nomad-agent-linkedin-search-v1",
    "searches": [
      {
        "keyword": "frontend engineer",
        "location": "Spain"
      },
      {
        "keyword": "typescript developer",
        "location": "European Union"
      }
    ],
    "orderBy": "newest"
  },
  "strictGeography": {
    "schemaVersion": "nomad-agent-linkedin-strict-geography-v1",
    "countries": [
      "ES"
    ],
    "regions": [
      "Catalonia"
    ],
    "cities": [
      "Barcelona"
    ],
    "unknownPolicy": "exclude"
  },
  "translateToEnglish": false,
  "aiEnrichment": {
    "enabled": true,
    "accuracy": "silver"
  },
  "companyProfileEnrichment": false,
  "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"
  },
  "includeRaw": true,
  "postedWithin": "30d",
  "maxItems": 100,
  "dedupe": {
    "enabled": true,
    "key": ""
  },
  "filters": {
    "schemaVersion": "nomad-agent-job-filter-v1",
    "expression": {
      "all": [
        {
          "field": "data.title",
          "operator": "not_contains",
          "value": "manager"
        },
        {
          "field": "data.employment.workArrangements",
          "operator": "overlaps",
          "value": [
            "remote",
            "hybrid"
          ]
        }
      ]
    }
  },
  "analyticsEnabled": false
}
```

# Actor output Schema

## `dataset` (type: `string`):

No description

## `runSummary` (type: `string`):

Versioned bounded-coverage state and an optional retry recommendation. Integrations must validate the record before starting another paid run.

# API

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

## JavaScript example

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

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

// Prepare Actor input
const input = {};

// Run the Actor and wait for it to finish
const run = await client.actor("nomad-agent/linkedin-enrich-translate-normalize-scraper").call(input);

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

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

```

## Python example

```python
from apify_client import ApifyClient

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

# Prepare the Actor input
run_input = {}

# Run the Actor and wait for it to finish
run = client.actor("nomad-agent/linkedin-enrich-translate-normalize-scraper").call(run_input=run_input)

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

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

```

## CLI example

```bash
echo '{}' |
apify call nomad-agent/linkedin-enrich-translate-normalize-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,nomad-agent/linkedin-enrich-translate-normalize-scraper"
        }
    }
}

```

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

## OpenAPI specification

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