# Changelog of LinkedIn Jobs API & Scraper \[NO COOKIES] ✅ (`unseenuser/linkedin-jobs-scraper`) Actor

- **URL**: https://apify.com/unseenuser/linkedin-jobs-scraper/changelog.md
- **Full Actor documentation**: https://apify.com/unseenuser/linkedin-jobs-scraper.md

## Changelog

### 1.3

**The free-plan cap is now 5 results per run, down from 15.** Paying users are
unaffected and still receive results uncapped.

Nothing else about the behaviour changed. A capped run still finishes as
`SUCCEEDED` rather than raising an error, the run's status message still
explains what happened, and the rows that are returned are complete records -
the cap truncates the dataset, it does not thin the rows.

The figure lives in one constant and the status message reads from it, so the
message, the input form and this file all say the same number.

**Enrichment concurrency now follows the cap.** Eight workers fetch in
parallel, which was fine while the cap was 15 but wasted upstream calls once
it was 5: eight jobs were already in flight by the time the fifth row landed
and set the cap, so three fetches were paid for and thrown away. The worker
count is now the lesser of the concurrency and the cap, which is unchanged for
paying users (their cap is unlimited) and exactly five for free ones. A free
run of either mode now makes no upstream call whose result it cannot keep.

### 1.2

Reliability. No new fields, no input changes, nothing renamed - but runs that
used to stop early now finish, and rows that carried no data are no longer
written or billed.

#### Runs were stopping long before the end of the results

The upstream API answers an overloaded moment with HTTP 200 and an empty body
rather than a 429. Two paths believed it.

**Empty search pages ended pagination.** A page with no jobs on it was read as
"no more jobs exist", so a search matching 3,204 postings could return 25 and
report success. An empty page is now re-requested on the same backoff ladder a
failed fetch gets, up to seven times, before the search accepts it as the end.

**Empty job responses became rows.** A job fetch that came back with nothing in
it was still written to the dataset as a record with every field null. In one
418-row run, 194 rows - 46% - were empty shells, and every one of them was
billed. A job is now written only if it has an ID, a title or a description.

Measured on the same eleven saved tasks, run twice with identical inputs:
**643 rows before, 2,934 after, and blank rows from 46% to none.**

**Retries raised from six attempts to eight**, with the backoff capped at 20
seconds so a long ladder cannot stall a run. `Retry-After` is still honoured
where the API sends it.

#### Documentation that did not match the Actor

Several statements were measured against 3,434 rows from thirteen runs and
turned out to be wrong. They are corrected rather than removed.

- **Pricing was stated three different ways, none of them right.** The FAQ, the
  disclaimer and the Store description described a pay-per-event model at
  $0.010 per job. The Actor bills per result. The real figure is $0.0045, and
  Apify platform usage is included.
- **Salary fill rate was published as ~15%.** Measured: 59% overall, 62% across
  2,540 US rows, 21% on a Germany/Netherlands/Ireland run. The figure understated
  the most valuable field in the output by four times.
- **Applicant-tracking-system coverage was published as ~56%.** Measured: 27%
  for `applicantTrackingSystem`. `atsProvider` reaches 46% because it also reads
  the apply URL, and that distinction is now stated.
- **Workplace type was published as ~83%.** Measured: 69%.
- **The comparison table claimed this Actor returns hiring-manager identity.**
  It does not, and never has. That data is excluded deliberately: job posters
  are identifiable people, and returning their names would make every user of
  this Actor a data controller for a dataset they did not ask to hold.
- **"You will also need your own Harvest API key" was wrong.** There is no
  `apiKey` input. The key is the publisher's and comes from the environment.

#### Documentation that was missing

Five things the Actor already did and never said:

- **Paste a LinkedIn search URL.** Set your filters on LinkedIn, copy the URL,
  paste it in. Keywords, location, date, workplace type, employment type,
  seniority and company IDs are read out of the query string. This is also how
  to search a city rather than a country - the URL carries LinkedIn's `geoId`
  and it is passed through unchanged. The feature shipped some time ago and was
  documented in a form tooltip and one clause.
- **No cookies, no login** as a section rather than a phrase. There is no
  `li_at` field, no session, and no account of yours in the chain.
- **What the derived fields mean** - `ghostJob`, `atsProvider`,
  `salary.providedByEmployer` and `extractedSkills` are worked out from the
  posting rather than copied from LinkedIn.
- **A worked example** pairing one real input with one real row.
- **Run figures** - 466 of 467 runs succeeded in August, none failed, none
  timed out.

The FAQ went from 12 entries to 21.

### 1.1

Three new output fields, and a round of correctness fixes behind them.

**Nothing was removed, renamed, or re-typed.** Every field 1.0 returned is still
returned, under the same key, with the same type. Existing tasks, schedules,
integrations and saved runs keep working untouched; they simply see extra keys
they can ignore. The one input change is additive too: `includeCompanyData`
defaults to `true`, which is what 1.0 always did.

#### New fields

**`atsProvider` / `atsSource`** - the applicant tracking system behind the
posting. LinkedIn states it on some jobs; where it does not, the vendor is read
off the apply URL's host, which is present on every job. `atsSource` says which
of the two it came from, so a detected value is never mistaken for a stated one.
Coverage went from 57% to 67% of jobs. It reads the host wherever it can; where
an employer hosts the ATS on their own domain it falls back to a short list of
tracking parameters the vendor itself appends, which is how a job on
`careers.cisco.com` is still identified as Phenom. The path is never read. Where
neither applies it returns `null` rather than guessing, because a posting on an
employer's own careers domain with no vendor marker has no ATS to report, and
saying so is more useful than inventing one. `applicantTrackingSystem` is
untouched.

**`extractedSkills`** - the skills a job asks for, read out of the description
text. LinkedIn's own skill tags are only shown to signed-in users, so the
existing `skills` field is empty on every job this Actor returns and always has
been. These are inferred from the text, which is why they live in a separate
field rather than filling that one. Dictionary-based, around 200 canonical
skills, because a wrong skill is worse than a missing one. 89% of jobs get at
least one, averaging 7.7. Guards against words that are both products and
ordinary English: "able to react to change" is not React, "payment rails" is not
Rails, "your normal workday" is not Workday.

**`ghostJob`** - a score, a risk band, and the list of reasons behind them, for
how likely a posting is to be advertised without really being filled. Built from
one payload with no memory between runs, so it flags postings worth a second
look rather than passing a verdict; the strongest signal of all - the same role
reposted every few weeks - needs cross-run state and is not here. Every
contributing signal is returned alongside the number, so the score can be
argued with instead of trusted blindly.

Alongside the age, applicant-volume and company signals, it reads what the
posting says about itself: that it is advertised on behalf of another company,
that the role leads to placement with an unnamed partner, that it requires a
training bond, or that it is a talent pool rather than an opening. This matters
because the industry field is not enough on its own - a train-and-deploy firm is
often filed under "IT Services and IT Consulting", not staffing. These patterns
are deliberately narrow, because a signal attaches a score to a named company on
the strength of its own words: a posting warning candidates about "agencies
working on behalf of Anthropic" is a real job saying the opposite, and is not
flagged.

#### Fixes

- **Skills were being lost at every bullet boundary.** LinkedIn's plain-text
  description is the HTML with its tags deleted and nothing put in their place,
  so two list items run together into one word and "Embedded Operating
  SystemsLinux Driver Architecture" hides Linux inside a word. Matching now runs
  against the HTML with block boundaries restored, which is where most
  requirements live. `description.text` is unchanged; the restored text is used
  for matching only and is never emitted.
- **Amounts written without thousands separators were being truncated.** The
  pattern that reads a figure could match `210` out of `210000` and stop, so a
  range like `$147000 - $210000` came back empty, and in the worst case
  `$8000 - $9000` was returned inverted as 900 to 8000. Numbers with commas were
  never affected, which is why it took a posting written without them to show
  it. The figure pattern is now defined once, with a guard that refuses a
  partial match.
- **An empty salary object is now absent rather than present.** Where the API
  returned a salary with nothing in it but `providedByEmployer: false`, the
  record carried a `salary` object whose every figure was null. `salary` is now
  `null` in that case, as it already was elsewhere, and the description is read
  instead - which the stray object had been silently preventing.
- **Salaries the parser could not see.** A range with the currency after both
  numbers (`224,000 USD - 356,500 USD`) matched nothing, and a rate with no
  currency at all (`Pay around 50/hr`) was skipped entirely. Both are now read.
  The second returns `currency: null` rather than assuming USD, because the
  posting did not say. A bare figure needs a pay word beside it to count, so
  `40 hours/week` is not mistaken for a wage.
- **Salary is now chosen, not taken.** The parser used to return the first
  figure in the description that parsed. On a Ramp internship that meant
  `$10,000 per year`, lifted from "Fertility HRA (up to $10,000 per year)". It
  now collects every candidate with its position, scores each by the words
  around it, and returns nothing when the best one still looks more like a
  benefit than like pay.
- **European salary formats.** `€35.000` parsed as 35, then tripped the
  "under 200 means hourly" heuristic and came back as an hourly rate. Thousands
  and decimal separators are now decided by position, so `35.000` is 35000,
  `88.59` is 88.59, and `1.234,56` is 1234.56. Where our re-parse lands 100x or
  more above the figure the API supplied, ours wins.
- **`applicantCountBucket` on jobs with no number.** A posting showing only
  "Be among the first 25 applicants" returned a null bucket as well as a null
  count, even though the payload said which bucket it was. Count and bucket are
  now resolved together, reading the message before giving up.
- **Duplicate URLs in details mode.** De-duplication compared URL text, so the
  same job submitted in two URL forms was fetched twice and billed twice. It now
  compares job IDs.
- **Network errors were never retried.** Connection resets and timeouts carry no
  HTTP status, and the retry check read a missing status as "permanent". They
  are now retried like 429s and 5xxs.
- **Error rows could fail an entire run.** The dataset schema required fields
  that a friendly error row does not carry, so writing one was rejected. That
  path had never worked since the schema was added; it does now.
- **Charge warnings.** The Actor is priced per result, but the code still called
  `charge()` for events that no longer exist, producing hundreds of warnings a
  run. Removed.
- **Seniority values** now use LinkedIn's own labels ("Mid-Senior level",
  "Entry level") instead of a private vocabulary.

#### Results stream as they are found

Rows now appear in the dataset as each job finishes, instead of all at once at
the end. A long run is readable while it is still going, and a run that is
stopped early keeps everything it had already collected.

#### Costs

Company data is fetched without the paid `withCompany` parameter, which returns
the same data. If a response ever comes back without a company, the Actor
detects it and pays for the parameter from then on. No change to what you are
charged; this is upstream cost, and it is why the price did not move.

### 1.0

Initial release. Two modes - search LinkedIn jobs with filters, or enrich a list
of job URLs - returning the full job page for every result: description, salary,
applicant count, parsed location, and the hiring company's profile.
