# Changelog of 🔥 Linkedin Companies & Profiles Bulk Scraper (`bebity/linkedin-premium-actor`) Actor

- **URL**: https://apify.com/bebity/linkedin-premium-actor/changelog.md
- **Full Actor documentation**: https://apify.com/bebity/linkedin-premium-actor.md

## Changelog

All notable changes to the `linkedin-premium-actor` are documented here.

Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### \[Unreleased]

### \[10.36.0] — 2026-09-22

#### Fixed

- **Requests no longer claim to be two things at once.** Every call carried
  `sec-fetch-user: ?1` and `upgrade-insecure-requests: 1` — markers a browser only sends when
  a person navigates to a page — alongside the headers of an authenticated background call.
  No browser produces that combination. The HTTP client was adding them on its own; they are
  now suppressed on API calls and kept only on the one request that really is a page load.

### \[10.49.1] — 2026-09-10

#### Fixed

- **Some profiles came back with "View {name}'s verifications" as their headline.** The line the
  page renders between the name and the headline was being taken for the headline itself whenever
  it was written in an alphabet the filter did not cover — Greek, Finnish and Russian among them.
  Measured on a live sample: 5 rows in 20 before, 0 in 20 after, with no headline lost.

### \[10.49.0] — 2026-09-10

#### Added

- **`verified` on profiles** — whether the member carries LinkedIn's identity-verification badge.
  Returned on every profile at no extra cost: it comes out of the response the Actor already
  fetches, so runs are not slower and not more expensive.

### \[10.48.0] — 2026-09-10

#### Added

- **Courses and Publications are now returned**, as two new `profileFields` options. LinkedIn has
  always served these sections; the Actor knew how to ask for them and had nowhere to put the
  answer, so they were never returned. Both appear as columns in the Profiles table.

### \[10.47.0] — 2026-09-10

#### Fixed

- **The run log misstated what it was about to do.** Given four company URLs and a limit of 1 it
  announced "up to 1 companies across 4 targets" and then returned four. The limit is a
  **per-search** budget and has never applied to URLs you list directly; the log now says which of
  the two applies, and what the run can return at most.

#### Changed

- **The log tells the story of the run instead of repeating itself.** Each search names its term
  and page and how many matches it found, progress is reported on a cadence rather than one line
  per row, and the run closes on a count — including how many of your inputs matched nobody.
- **The Profiles and Companies tables show the whole record, not a tenth of it.** Everything the
  Actor returns is now a column: for a profile, the About text and the full experience, education,
  certifications, skills, languages, honors, projects, organizations, volunteering and contact
  sections alongside the identity and reach fields; for a company, the description, locations,
  specialities, hashtags, phone, jobs link and page type alongside the size and reach fields. No
  field sits behind the "all fields" toggle any more. Both tabs are labelled with an icon.
- **The Output tab links straight to profiles only, companies only, or a CSV export**, instead of
  a single undifferentiated dataset link.

### \[10.46.0] — 2026-09-10

#### Fixed

- **Runs are no longer routed halfway around the world.** Traffic was leaving from whatever
  country came up — eight consecutive draws landed in Italy, Canada, Brazil, the UK, South
  Africa, Trinidad and Vietnam — while the site being read is served from the United States, so
  a large share of requests timed out crossing the ocean twice. Measured over 4 072 requests, the
  distant routing lost up to 44 % of a run's capacity; it now leaves from the United States by
  default. Same input, same output, fewer timeouts and a faster run.

### \[10.45.2] — 2026-09-10

#### Fixed

- **Rows recovered from a results page that did not render its links came back with the profile's
  identity but none of its detail** — no experience, education, current position or summary. They
  now carry the same detail as any other row: measured against the previous release on identical
  keywords, experience went from 8 % to 99 % of rows, education 6 % to 90 %.
- **Those same rows were missing the `input` field**, so callers could not line an output up with
  the keyword that produced it. Every row carries it again.

#### Changed

- The warning and status message shown when a run stops early now describe what the caller sees —
  the run ended early and kept what it had — instead of naming internals.

### \[10.45.1] — 2026-09-10

#### Fixed

- Internal reliability work; input and output unchanged.

### \[10.45.0] — 2026-09-10

#### Fixed

- **A keyword could be reported as matching nobody while it had a full page of matches.** Some
  results pages come back without their result links rendered; the run read those as empty,
  returned a `NO_RESULTS` row for the keyword and stopped chasing it — which is why two runs on
  the same keywords could return 340 rows and 8. Those pages are now read through the identifiers
  they always carry, so the same keyword yields the same ten results per page either way.

### \[10.44.2] — 2026-09-10

#### Fixed

- **A keyword search could contribute one row that was not a search result.** A results page
  carries page furniture that reads like a match without being one, and it was being followed and
  returned alongside the real results — roughly one row per search, about 4 % of a delivery. Those
  rows are now excluded; genuine matches, including the top-ranked ones, are unaffected. Page
  counts are unchanged: a full page still yields ten results.

### \[10.44.1] — 2026-09-10

#### Changed

- **Searches are requested the way a browser requests them.** During an upstream incident the
  previous form of the request returned an error page for several minutes while this one kept
  working. Same results, same page size, same pagination, on both profiles and companies.

### \[10.44.0] — 2026-09-10

#### Changed

- **Runs are faster by default.** The default parallelism was set conservatively long ago and
  measurements showed it was holding runs back without protecting anything. Same input, same
  output, more results per minute.

### \[10.43.0] — 2026-09-10

#### Fixed

- **A run no longer stalls on a network route that has stopped answering.** One class of outage
  was not recognised as such, so the run kept retrying the same dead route instead of switching
  to a working one on the first signal. Output unchanged; affected runs finish faster and
  deliver more.

#### Changed

- Internal reliability work, off by default: input and output unchanged.

### \[10.42.0] — 2026-09-10

#### Fixed

- **A profile is no longer delivered with a section silently missing.** When the response for one
  part of a profile came back empty, that part was merged as nothing and the profile was still
  emitted — so experience, education or skills could be absent from a row that reported success.
  The empty response is now retried instead.
- **A run recovers cleanly when the site interrupts a connection mid-run.** State that the site
  asked the client to discard was being kept and replayed instead, which made the rest of the run
  progressively less reliable. No input or output change.

### \[10.41.0] — 2026-09-09

#### Changed

- Internal reliability work: the engine's working state is now held in memory for the lifetime of
  the run in every mode. **No input, output, or behavior change** for callers — an interrupted run
  still resumes where it left off.

### \[10.40.0] — 2026-09-09

#### Fixed

- **A run no longer stalls when an upstream network route stops working.** It switches to a
  working route automatically and remembers the choice, so later runs start on it directly.

### \[10.39.0] — 2026-09-09

#### Changed

- Maintenance release. **No input, output, or behavior change** for callers.

### \[10.38.0] — 2026-09-09

#### Fixed

- **A run that ran out of capacity mid-way could fail outright**, even though it had already
  delivered results. It now finishes cleanly, keeps every row it collected, and reports how
  many were delivered before it stopped.

### \[10.37.0] — 2026-09-09

#### Changed

- Maintenance release. **No input, output, or behavior change** for callers.

### \[10.36.0] — 2026-09-09

#### Changed

- Maintenance release. **No input, output, or behavior change** — build tooling and internal
  documentation only.

#### Documentation

- **README brought up to date** with everything shipped since 10.11: the `company` and
  `jobTitle` search filters, the `contact` profile section, and the fields that were being
  returned but not documented — `input` / `status` / `reason`, `profileId`, `currentTitle`,
  `currentCompany`, `careerStartYear`, `firstDegreeYear`, `contactInfo`, `industryId`, and the
  company link and ISO dates on each experience entry.

### \[10.35.1] — 2026-09-09

#### Fixed

- The four filtering fields added in 10.35.0 shipped empty: they were derived after the row
  had already been assembled. Building the row in one place fixes it and keeps it fixed.

### \[10.35.0] — 2026-09-09

#### Added

- **Four flat fields for filtering, so you don't have to walk the experience array on every
  row**: `currentTitle` and `currentCompany` (the role the profile still lists as ongoing),
  `careerStartYear` (earliest year across the full history) and `firstDegreeYear` (earliest
  year across education). All derived from data already in the record — no extra request, no
  extra cost. A profile with no ongoing role gets no current fields rather than its most
  recent past one presented as current.
- The dataset table now shows `input`, `status` and `reason` on both profiles and companies,
  plus the new current-role and career-start columns on profiles. `input` names what produced
  each row, which is what ties an output back to the entry you sent.

#### Fixed

- **`websiteUrl` is always a usable URL.** LinkedIn serves it exactly as the company typed it,
  so it could arrive as `www.example.com` with no scheme — not fetchable, and not what the
  field promises. A value that looks like a host now gets `https://`; anything else is left
  exactly as received rather than dressed up as a URL.

### \[10.34.2] — 2026-09-09

#### Fixed

- **A repeated section no longer shifts every entry after it.** LinkedIn's "Show all" response
  renders its list twice. Where entries come in their own blocks that is harmless, but on the
  fallback path the second copy was read as more entries — one school took the repeat's first
  school as its DEGREE, and that degree then became a fourth school. The replay is now cut
  before anything is parsed. A profile with several degrees at the same university is
  untouched: only a tail that replays an earlier block is removed.

### \[10.34.1] — 2026-09-09

#### Fixed

- **`profileId` is now declared in the output schema**, so it shows up in the dataset table
  and preview like every other field. It has been in the data since 10.27.0 — only the
  description was missing.

### \[10.34.0] — 2026-09-09

#### Changed

- **The skills link under a role is now recognised by its position, not by its wording.**
  It was matched against a list of translations, which would miss any language not on that
  list and let the line reappear as a phantom job. LinkedIn lays an entry out as title,
  employer, then metadata, so a further primary line after the metadata is never part of the
  job — that is what identifies it now, in any language. Grouped blocks of consecutive roles
  at one employer are told apart by carrying several dates and are left untouched.

### \[10.33.0] — 2026-09-09

#### Changed

- **Profile cards and detail pages are now read the same way.** Since 10.32.0 the "Show all"
  pages parsed each entry from its own block while the profile card still read one flat list
  per section and guessed where entries ended — so the same profile could parse differently
  depending on which path served it. Cards use the same boundary now. Entry counts are
  unchanged on every captured profile; what changes is that an entry can no longer take a
  field from its neighbour. Layouts that render no such blocks keep the previous behaviour.

### \[10.32.1] — 2026-09-09

#### Fixed

- **The "LinkedIn helped me get this job" badge is no longer returned as a job.** It arrives
  with a tail of its own title in the company field, which is how it is recognised without
  depending on the wording; stray fragments carrying neither a date nor an employer go with
  it. Rows keeping either field are untouched, so a self-employed entry with no company, or a
  real role whose dates we read imperfectly, still comes through.

### \[10.32.0] — 2026-09-09

#### Fixed

- **Each experience and education entry is now read from its own container**, so an entry can
  no longer borrow the next one's employer. LinkedIn wraps every entry of a section in its own
  block; we were reading one flat list of text and guessing where each entry ended. That guess
  is what produced duplicated roles, a company name promoted to a job title, and rows with no
  dates at all. Entry counts now match the profile exactly.

### \[10.31.0] — 2026-09-09

#### Fixed

- **A phantom "Skills:" entry no longer appears in experience — and no longer steals the next
  role's employer.** Found by auditing 22 profiles and 224 roles after the previous release:
  the skills line LinkedIn renders under a role was read as a job title, and the following
  role's title became its company name. It is a link to a skills overlay, not content, and is
  now excluded in both the shapes LinkedIn renders it ("Skills: A, B" and "…and +5 skills").
- **A value split across inline markup is returned whole.** Text carrying emphasis or a link
  in the middle used to arrive as several separate values. Extraction now concatenates a text
  run the way LinkedIn's own renderer does, so a label and its value stay one field.

### \[10.30.0] — 2026-09-09

#### Fixed

- **Experience and education are no longer cut off at the collapsed profile view.** The
  profile card renders only the first few entries; everything behind "Show all N" was never
  fetched, and the entries missing were always the OLDEST — so someone who started in 1994
  read as starting in 2015, with a payload that looked complete. Both sections now follow
  that link. On the reported profile: 5 experiences → 16, 2 educations → 5, back to a 1998
  first role. The education request is skipped when LinkedIn shows no "see all" link, so
  short profiles cost nothing extra.
- **Multi-line descriptions are returned whole instead of being shredded into fake entries.**
  A description is one node whose children are its lines; reading them one by one kept only
  the first line and re-read the rest as new experience entries — a line of prose becoming a
  job title, a bullet becoming a company name. Descriptions now come back complete, with
  their line breaks, and the invented entries are gone. Tell-tale of the old behaviour: no
  returned description ever contained a newline.
- **A profile URL written with the wrong apostrophe now resolves.** LinkedIn builds a handle
  like `…-d’avila-…` with a typographic apostrophe; the ASCII one looks identical and gets
  substituted by spreadsheets and CRMs, and LinkedIn 404s on it. A 404 on a slug containing
  either apostrophe is now retried once with the other spelling. Only that substitution —
  dropping the apostrophe entirely lands on a different member.
- **Vanity URLs with punctuation or symbols no longer truncate.** a slug containing an apostrophe
  was looked up as the part before it; registered marks, emoji and zero-width characters hit the
  same wall. Worse than a miss: when the truncation happened to be someone else's real
  handle, the run returned THE WRONG PERSON as FOUND. A slug is now bounded by URL
  delimiters rather than by a list of allowed characters.

### \[10.29.0] — 2026-09-09

#### Fixed

- **`company` now filters by employer, so you can get the people who work somewhere.** It was
  being sent as a free-text keyword, which searches profile *text* rather than an employer —
  filtering "engineer" by company Stripe returned a single profile, and not one at Stripe.
  The field now resolves to the company and uses LinkedIn's real current-company filter; all
  results come back at that company. It accepts a company URL, a slug or a name, and a name
  that cannot be resolved falls back to the old keyword rather than losing your filter.
- **`jobTitle` is matched as an exact phrase.** It was sent unquoted, so "Data Scientist" was
  two loose words and matched far more than the title asked for.

### \[10.28.0] — 2026-09-09

#### Fixed

- **Companies now carry the industry label LinkedIn actually displays.** The API we read still
  serves the pre-rename taxonomy, so a company showed as "Computer Software" where the page
  says "Software Development", and "Information Technology & Services" where it says "IT
  Services and IT Consulting" — reported by users comparing our output against the profile in
  front of them. The numeric industry id is the same on both sides, so the label is now
  resolved through LinkedIn's published Industry Codes V2 table (434 industries). An industry
  outside that table keeps the label the API served, and `industryId` still ships alongside
  for anyone matching industries across systems.

### \[10.27.0] — 2026-09-09

#### Added

- **Location filtering now works on company search.** It never did: the company vertical
  ignores the `geoUrn` facet the people search uses — accepting it without error, which is
  why adding a country quietly returned the same worldwide results. Its own facet is
  `companyHqGeo`, read off LinkedIn's own filter rather than guessed. The location is
  resolved the same way as for profiles and rides every page of results, so a long run
  cannot drift back to worldwide halfway through.
- **Every profile row carries `profileId`**, LinkedIn's stable member id (`ACoAAA…`). Unlike
  the vanity name it survives the member editing their profile URL, and it is accepted back
  as input, so it is the identifier to join on across systems. It was already being read on
  every profile — it is what the follow-up requests are built from — but never reached the
  output. Distinct from `urn`, the older numeric namespace LinkedIn rarely exposes.

### \[10.26.0] — 2026-09-09

#### Added

- **Numeric company URLs work: `/company/1441` resolves to the real company.** Asked for in three separate threads since April 2024. LinkedIn answers the id page with `200` and hops to the slug client-side, so there is no redirect to follow — the slug is read from the page, anchored on the tab links (`/home/`, `/about/`, `/people/`…) that only the *current* company renders. Verified live on the exact examples from those issues: `3653845` → Snowflake, `7208` → Crispin, `1441` → Google. Costs one extra request, and only for inputs that are ids.
- When LinkedIn answers with its empty app frame instead of the rendered page — same URL, same request — the page is asked for again, up to three times. That frame name-drops a dozen other companies as often as the real one, so guessing from it is not an option; an id that never resolves ends up as an honest `NOT_FOUND` carrying its input.

### \[10.25.0] — 2026-09-09

#### Added

- **Two new search filters: `company` and `jobTitle`** — both free text, both optional. They combine with `keywords` and `location`, which is the real AND across criteria that the "AND Filters" issue asked for, and they answer "give me the people at this company" without needing a company id. Verified live: `keywords=engineer` + `company=Vinted` + `jobTitle=Data Scientist` returns Staff and Senior Data Scientists at Vinted.
- Blank values are ignored rather than sent as an empty filter, and a run without them behaves exactly as before (`origin` stays `GLOBAL_SEARCH_HEADER`).

### \[10.24.0] — 2026-09-08

#### Changed

- **Fewer lookups lost part-way through a long run.** Internal reliability work on how the engine talks to LinkedIn. Input, output and pricing are unchanged.

### \[10.23.0] — 2026-09-08

#### Changed

- **Internal reliability work on the engine's outgoing requests**, so a long run holds up from start to finish instead of degrading. Nothing about the input or the output changes.

### \[10.22.0] — 2026-09-08

#### Added

- **`industryId` on company records** — LinkedIn's numeric industry id, alongside the existing label. The label itself comes from LinkedIn's **older** taxonomy (`Computer Software` where its UI now says `Software Development`), and that is not a field we picked wrong — LinkedIn serves the same entity under both names. The id is the one value that survives its renamings, so it is the safe thing to join on.

### \[10.21.1] — 2026-09-08

#### Fixed

- **`input` was lost when a profile changed identity mid-flight.** Seeding an `ACoA…` id emits the profile under its *real* vanity name, so looking the entry up by the emitted name found nothing and the row came back with `input: null`. The caller's entry now travels with the request from the seed. Caught by a regression sweep on the deployed build, not by a test.

### \[10.21.0] — 2026-09-08

#### Added

- **Contact info as an opt-in profile section.** Tick "Contact info" in "Profile data to scrape" and each profile gains a `contactInfo` object: the member's profile URL and their websites, each with the displayed label and the category they chose (Company, Blog, personal…). Outbound links are unwrapped from LinkedIn's `safety/go` redirector, so you get the real destination. One extra request per profile, hence opt-in. Asked for in 2024.
- Only what the member made public is returned — **most profiles expose no email address**, and the field is simply absent then rather than guessed at.

### \[10.20.0] — 2026-09-08

#### Added

- **Profile IDs (`ACoA…`) are accepted as input**, on their own, inside an `/in/{id}` URL, or wrapped in `urn:li:fsd_profile:{id}`. The actor used to refuse them with "Raw LinkedIn IDs are not supported"; that refusal was ours alone — LinkedIn's profile URL serves the full profile for an ID exactly as it does for a vanity name, so no id→slug resolution is involved. The output carries the profile's **real** vanity name, not the ID you sent (`ACoAAACu9SEB…` → `joanmarcriera`).
- `urn:li:member:{numeric}` — LinkedIn's older ID namespace — is still refused, with a message that now says which ID form does work. That URN is not a profile-URL path segment.

### \[10.19.0] — 2026-09-08

#### Added

- **`/school/` and `/showcase/` URLs are accepted on `get-companies`.** LinkedIn serves both from the very same company endpoint — only our input marker was turning them away, before we even tried. Asked for in 2024 and promised then. Verified live: Smith College and Charter College (schools), Microsoft Azure, Generali Global Assistance and InnerOrbit (showcase pages) all return full company records.

### \[10.18.0] — 2026-09-08

#### Added

- **Every output row names the input that produced it**, in a new `input` field — the search term for a search result, the entry you actually typed for a direct URL (query string and all, not the slug we derived from it). Asked for in three separate threads since April 2024. Outputs and inputs now line up without relying on ordering or parsing the run log. Two encodings of the same profile collapse to one target, and the first entry wins.

### \[10.17.2] — 2026-09-08

#### Fixed

- **The engine's requests are aligned with what a real browser sends**, from a live capture of LinkedIn's own traffic. Verified live: profiles, experience and company links all still come back.

### \[10.17.1] — 2026-09-08

#### Fixed

- **A path that has stopped answering is now abandoned the first time it says so**, instead of being retried until the run runs out of attempts. Measured over five weeks, the previous policy never once took effect — which is why a run that started badly stayed bad for its whole length. Fewer wasted attempts, more results per run.

### \[10.17.0] — 2026-09-08

#### Added

- **`companyId` and `companyUrl` on every experience entry** — the most requested field on the actor, asked for across four separate issue threads since January 2024. Feed either straight into the company scraper to join profiles to companies. No extra request: LinkedIn already ships the link with the experience card.
- The mapping follows LinkedIn's own grouping — one company link per consecutive run of roles at the same employer, so several roles at one company all carry it. An employer with **no LinkedIn page** yields an entry with no id rather than shifting the mapping onto the wrong company; if the counts ever disagree, no link is attached at all rather than risk placing someone at an employer they never worked for.

### \[10.16.3] — 2026-09-08

#### Fixed

- **The location filter works again.** It was not the geo lookup itself: the filter was resolved once, at startup, on a single attempt — so a run whose first attempt failed lost the filter for its whole length and quietly searched worldwide (searches, which retry, were unaffected). Each attempt now starts clean, and the budget is 4. Verified live: `Cyprus` resolved to geo id `106774002` on the second attempt and the results came back from Limassol and Nicosia.

### \[10.16.2] — 2026-09-08

#### Fixed

- **A location filter that cannot be resolved no longer becomes a silent worldwide search.** When the location lookup failed, the run dropped the filter, logged a warning and searched the whole world — the caller received unfiltered data that looked filtered, with nothing in the output to say so (seen on a production run: `software engineer` + `Cyprus`). The lookup is now retried once, and if it still fails the affected searches are **skipped** and each keyword gets a row `{ input, status: "NOT_FOUND", reason: "LOCATION_UNRESOLVED" }`. Direct profile URLs are unaffected. New `LOCATION_UNRESOLVED` value in the dataset schema's `reason` enum.
- The run now reports every location it could not resolve, instead of only warning about the first.

### \[10.16.1] — 2026-09-08

#### Fixed

- **A profile record is never reported as `FOUND` when it carries no data.** A vanity URL that no longer resolves (a renamed profile) returns a page that is neither LinkedIn's 404 nor a profile. Once the retries gave up, that page was emitted with `status: "FOUND"` and nothing but the input in it — a false positive the caller could not tell from a real result. Such a record is now catalogued `NOT_FOUND` with the new reason `NO_DATA`. Identity fields *or* any non-empty section count as data, so a profile carrying only sections (no identity block) is still `FOUND`.
- New `NO_DATA` value in the dataset schema's `reason` enum, with the enum's description rewritten to cover all four reasons.

### \[10.16.0] — 2026-09-08

#### Added

- **`startDateISO` / `endDateISO` on every experience entry.** Machine-readable siblings of the existing dates: `"Jan 2013"` → `"2013-01"`, a bare `"2013"` stays `"2013"`, and an open-ended `"Present"` yields no ISO value. **`startDate` / `endDate` keep LinkedIn's original strings unchanged** — nothing that parses them today breaks.
- **Every input now leaves a row in the dataset.** A keyword that matches nobody used to produce nothing at all, so callers could not line inputs up with outputs. Such a search now emits one record `{ input, status: "NOT_FOUND", reason: "NO_RESULTS" }` (people and company searches alike, page 1 only). It is not counted as a delivered result and consumes no daily quota. New `input` field and new `NO_RESULTS` value in the `reason` enum of the dataset schema.

#### Changed

- Brought the engine back in step with LinkedIn's current client — the pinned version was ~1000 builds stale. Verified against a live run (7 requests, 0 failures).

### \[10.15.2] — 2026-09-08

#### Fixed

- **Profile / company URLs with a non-ASCII vanity slug are no longer truncated.** Slugs were bounded by an ASCII-only character class, so the lookup stopped at the first accented character — a slug was cut at its first accented letter, sometimes down to two characters. Percent-encoding the URL client-side hit the same wall (`%` ended the class too), so there was no caller-side workaround. Most truncations 404'd as `NOT_FOUND`; some resolved to a **real but unrelated member**, returned as a `FOUND` match for the requested person. Slugs are now matched as Unicode and percent-decoded once (the percent-encoded and the accented form of the same slug are one target), then re-encoded exactly once for the request. Affects ~12–20% of profiles in datasets with Portuguese / Spanish / French names.
- Same fix on the keyword-search path: accented `/in/{vanity}` and `/company/{universalName}` slugs found in a page of search results were silently dropped instead of being kept. Verified on a live `engenheiro civil` search: 6 members extracted before, 8 after, none lost.

### \[10.15.1] — 2026-07-30

#### Changed

- Internal pacing tuning. No change to input or output.

### \[10.15.0] — 2026-07-30

#### Changed

- **`profileFields` default is now `["about"]` (cheapest section: 1 card, 0 pagers), not “scrape everything”.** Empty / omitted / unknown-only input no longer fetches all cards+pagers — pick the sections you need, or select every option for a full scrape. Schema `default`/`prefill` updated so the Apify UI starts with About.

### \[10.14.3] — 2026-07-30

#### Changed

- **Default request concurrency settled at a middle ground** after a higher setting proved too aggressive on live runs. Use `profileFields` when you want a run to go faster.

### \[10.14.2] — 2026-07-30

#### Changed

- **A profile's sections are fetched side by side again** instead of strictly one after another, so each profile completes faster.

### \[10.14.1] — 2026-06-23

#### Changed

- Routine maintenance to keep the engine in step with LinkedIn's current client, before the pinned version goes stale. Both versions served data in testing — this is upkeep, not a fix.

### \[10.14.0] — 2026-06-23

#### Fixed

- **A run no longer burns its attempts on a path that has stopped answering (v10.13 regression).** LinkedIn bounces such a request into a redirect loop, which v10.13 could only see as an opaque `Too many redirects` — so it kept retrying the same dead path indefinitely and the success rate collapsed while the run still looked healthy. The loop is now recognised for what it is and the run moves on immediately, instead of spending ten redirects and eight retries on it.
- Success is only credited on a real `2xx`. Previously any status below `400` — a bounce included — counted as one.

### \[10.13.0] — 2026-06-15

#### Fixed

- **Transient network trouble no longer costs a run its momentum.** Connection resets, rate limits, timeouts and empty responses were being handled as though they were permanent failures; they are now told apart and simply retried, which removes the main source of churn on a long run.

#### Changed

- Routine maintenance to keep the engine in step with LinkedIn's current client, now held in a single place for easier upkeep.

#### Added

- Optional tuning knobs for request concurrency and pacing, so a run's aggressiveness can be adjusted without a code change.

### \[10.12.0] — 2026-06-08

#### Added

- Migration-resilient resume: the v2 engine now persists a lightweight per-run progress checkpoint (delivered profiles + per-keyword counts) to the run's key-value store, so after an Apify host migration it resumes only the not-yet-delivered targets instead of restarting from zero. Eliminates the duplicate results previously emitted on migration.

### \[10.11.4] — 2026-06-08

#### Changed

- Tuned request concurrency for better resource use under load.

### \[10.11.3] — 2026-06-08

#### Changed

- Daily result limit temporarily lowered from 150,000 to 10,000 while the rebuilt v2 engine is being stabilized (a notice is now shown in the run log). Will be raised over time.

### \[10.11.2] — 2026-06-05

#### Added

- **`status` + `reason` on every output record** (profiles and companies). Each item now carries `status` (`FOUND` / `NOT_FOUND`) and `reason` (`DOES_NOT_EXIST`, `UNREACHABLE`, or `null` when found), defaulted at emit time. Non-existent or unreachable handles are now **catalogued** as `NOT_FOUND` records instead of vanishing silently: a LinkedIn 404 → `DOES_NOT_EXIST`, a fetch that exhausts retries → `UNREACHABLE`, and a company that resolves to no entity → `DOES_NOT_EXIST`. Both fields are documented in the output schema and shown as the first column in the Profiles/Companies table views.

#### Fixed

- **Profiles occasionally returned with only their identity block missing** (no `firstName`/`lastName`/`headline`/`location`/`profileId`, but experience/education/skills present). The cause: LinkedIn sometimes serves a garbled, partial profile page — not a 404, long enough to look real, but with the top-card identity block absent. That case is now detected (no name **and** no profile id) and the page is requested again, up to three times. The detection is deliberately conservative — it fires only when *both* are missing, so a real profile that merely lacks a headline is never retried away. If every attempt still returns a partial page, the actor keeps the partial data rather than dropping the profile.
- **Profile `summary` was duplicated.** LinkedIn renders the About text twice (a truncated collapsed preview + the full expanded text); the parser joined both, doubling the summary verbatim. It now de-duplicates — dropping any paragraph that is a duplicate of, or a truncated prefix of, a longer one. Confirmed on a 15-profile live run: 0 duplication (e.g. a 537-char doubled summary is now the correct 268 chars).
- **Profile `firstName`/`lastName` missing on some profiles**, with the name leaking into `headline`. Some profile pages carry no `firstName`/`lastName` fields at all — the name only exists as the top card's first rendered line. The top-card extractor now isolates that name line (so it no longer leaks into `headline`) and recovers the name from it when the fields are absent. Live name coverage went 6/15 → 10/15 (every profile that returns a page now gets its name).

### \[10.11.0] — 2026-06-05

#### Added

- **Output schema** (`.actor/dataset_schema.json` + `.actor/output_schema.json`, wired via `actor.json` `storages.dataset` / `output`). Declares every field the actor can emit (profiles + companies, union, all nullable, nested objects) and gives the Console two dedicated table views — **Profiles** and **Companies**. Built from real run outputs, not just the TS interfaces.
- **README fully rewritten** following the Apify Academy best-practice structure: v10 "rebuilt from scratch" header, documented **input** (all fields + example) and **output** (maximal profile + company JSON examples covering every emittable field/section), data tables, how-to, pricing, FAQ/legality, and the new banner.
- `urn` (member URN) is now documented in the output schema and README — it was already emitted but undocumented.

#### Changed

- **Output field names harmonized between profiles and companies** (breaking for the v2 output shape):
  - `followerCount` (company) → **`followersCount`** (now shared with profiles).
  - `backgroundImageUrl` (profile) → **`coverImageUrl`** (now shared with companies).
  - `industryName` (profile) → **`industry`** (now shared with companies).
  - Distinct-by-design fields kept as-is: profile `profilePictureUrl` vs company `logoUrl`, profile `summary` vs company `description`.

### \[10.10.1] — 2026-06-05

#### Fixed

- **`get-companies` returned the wrong company.** The company parser picked the first company in the response — which also carries affiliated and similar companies — whenever the name match missed, and that match was case-sensitive, so a slug like `Bebity` (≠ stored `bebity`) silently fell through to an unrelated company (it returned *Blanche.Agency* for `Bebity`). It now selects the company the query actually resolved to, case-independently.
- **A company NAME (not a URL) is now searched, not looked up as a slug.** Under the legacy `isUrl:true` flag, a bare token in `get-companies` (e.g. `Bebity`) was treated as a direct slug lookup, which resolves to the wrong company. A markerless entry now routes to a proper company search; only an explicit `/company/{slug}` URL stays a direct lookup. Profile routing is unchanged.

### \[10.10.0] — 2026-06-05

#### Added

- **Experience role descriptions** — each experience item now carries a `description` field (the role blurb / "…see more" text). It is detected structurally, so it is never confused with the location.

#### Fixed

- **Profile sections cross-contaminated each other (intermittent, ~1 run in 4).** When LinkedIn returned every section inlined into one block, the section boundary matched that single block for *all* sections, so experience / education / certifications / volunteer each received the full concatenated text and mislabelled it. Sections are now scoped to their own subtree, which holds for both layouts.
- **`location` on experiences contained the role description note** (and the note was lost when a real location existed). Notes now go to the new `description` field; `location` only ever holds a real location.
- **Company "media / CTA" taglines parsed as fake roles** ("Learn more here", promo lines) — they broke multi-role grouping (sub-roles lost their company). They are now recognised structurally and dropped. The same fix removes uploaded **certification attachments** ("…certificate.pdf") that were emitted as bogus certifications.
- **Multi-role groups with a standalone employment-type line** ("Permanent", "Full-time") lost the company on each sub-role and put the type into `location`. The type is now read as `employmentType` (the secondary line *before* the date; location is the one *after*).
- **Blank "+N skills" affordance nodes split experiences** — a whitespace-only node was treated as a job title, swallowing the next real role. Whitespace-only text is now ignored everywhere.
- **Education "Activities and societies" became a bogus school entry** (or was silently dropped when long). It is now captured as the entry's `activities` field.
- **Honors & Projects emitted noise entries** — the "Associated with {company/school}" association line and the "Other contributors" avatar affordance were parsed as separate entries (and the association line stole the real entry's description). Both are now skipped.
- **Skills empty-state placeholder** ("Nothing to see for now" / "Skills that X adds will appear here.") was emitted as two skills on profiles with no skills. Now filtered.
- **A typed name submitted with the legacy `isUrl:true` flag was treated as a direct profile slug** instead of a search (e.g. a typed first-and-last name was fetched as `/in/{that name}`, which exists for nobody). Under `isUrl:true`, only entries that can actually be a slug (URL marker or a space-free token) are routed to a direct lookup; anything else is a search.

### \[10.9.0] — 2026-06-04

#### Fixed

- **v1-shaped inputs were silently ignored, returning the schema default (`keywords:["web dev"]`, `limit:1`) instead of the submitted targets.** Root cause: the schema exposed its fields under `v2__`-prefixed names, so Apify injected the `v2__*` **defaults** for every field a v1-shaped input (`action`/`keywords`/`limit`) didn't provide. Those injected defaults flipped `normalizeInput`'s `isV2` discriminator to true (v2-wins precedence), so the engine read the empty `v2__search:["web dev"]` / `v2__limit:1` defaults and discarded the real `keywords`/`limit`. Net effect: a run of 4 profile URLs with `limit:4` searched "web dev" capped at 1 and emitted a single record.

#### Changed

- **Input fields renamed back to the shared v1/v2 names** (`v2__action`→`action`, `v2__search`→`keywords`, `v2__limit`→`limit`, `v2__location`→`location`, `v2__profileFields`→`profileFields`, `v2__proxyConfiguration`→`proxyConfiguration`). There is now a single set of field names, so `normalizeInput` and the startup-summary config read them directly — the `v2__` vs v1 discriminator (and the v2-wins precedence that caused the bug) is gone. Legacy `isUrl:true` is still honored as a direct-lookup hint; `isName` remains a no-op. Behavior is otherwise unchanged.

### \[10.8.0] — 2026-06-03

#### Changed

- **Simplified v2 input.** Targeting collapses from four concepts (`action` + `isUrl` + `isName` + the implicit meaning of `keywords`) to two: **`v2__action`** (Profiles / Companies) and **`v2__search`** (one bulk list of search terms, URLs, or `/in/`·`/company/` slug-paths). URLs are auto-detected; a bare term is a search; a `/in/{slug}` or `/company/{slug}` marker forces a direct lookup (returns one result, ignores the limit). Removed the confusing `isUrl`/`isName` toggles and the `enrichWith*` fields from the v2 UI. The `location` field becomes `v2__location` (same autocomplete behavior).

#### Added

- **`normalizeInput` backward-compat layer** (`src-v2/dtos/normalize-input.ts`). All v2 fields are prefixed `v2__`; the presence of any `v2__` key marks an input as v2. Legacy v1-shaped inputs (`action`/`keywords`/`isUrl`/`isName`) still work unchanged — they ride through the schema's `additionalProperties: true` and are mapped to the canonical model (`isName` is dropped, being a no-op in v2). Raw LinkedIn IDs are rejected with a clear message instead of issuing a dead request. The schema requires nothing (`required: []`) so v1-shaped inputs still pass platform validation. Covered by offline tests in `src-v2/__tests__/run.ts`.

### \[10.7.0] — 2026-06-03

#### Added

- **`location` filter now works (`get-profiles`).** The previously-inert "🌍 Location" input is wired into people-search. Each entry is resolved **autocomplete-style**: free text (e.g. `Paris`, `United States`, `Greater London`) is matched against LinkedIn's own location suggestions and the **top match** is used; a raw geo id (`106383538`), a `urn:li:geo:…` URN, or a URL carrying `geoUrn=…` is used directly (no lookup). Multiple entries widen the filter (matches ANY). The filter is resolved once at startup and rides every search request, pagination included. Unmatched entries are **skipped with a warning** (the run continues; if none resolve it runs unfiltered) — the matched place names are surfaced in the run log. Profiles only — company search is unaffected.

### \[10.6.0] — 2026-06-03

#### Fixed

- **`limit` ("🔢 Limit of result per query") no longer caps directly-provided URLs, and is now per-query.** It was implemented as a single **global** counter applied to *every* queued item — including profile/company URLs passed in directly. With the default `limit=1`, a run of N URLs (e.g. the `big-one` preset: 493 profile URLs) returned only **1** item. Now: direct URLs bypass the per-query cap (all N are scraped, still subject to the daily quota), and in search mode `limit` is a **per-keyword** budget — `limit=5` over 2 search terms yields up to 10 rows, matching the input-schema description. It composes with the daily-quota cap from 10.5.0 (per-query limit AND global remaining quota both apply).

#### Added

- **`profileFields` multi-select (`get-profiles`)** — choose which profile sections to scrape. Each option costs one extra request per profile, so fewer selections = faster and cheaper runs. Empty/absent = scrape everything (backward compatible). Base identity (name, headline, location, picture, counts) is always returned.
- **Company output enriched** (`get-companies`): `CompanyData` now also exposes `phone` (`{ number, extension }`), `callToAction` (`{ text, type, url }`), `verified` (page verification), `active`, `pageType` (COMPANY/SCHOOL/SHOWCASE), `jobSearchUrl`, and `hashtags` (associated `#tags`, leading `#` stripped). Field shapes validated against LinkedIn's own models and covered by offline tests.

### \[10.5.0] — 2026-06-02

#### Added

- **Per-user daily result quota in v2**, ported from v1. Counts delivered dataset items (profiles **and** companies, one shared quota) per Apify `userId` over a 24h sliding window. Configurable through `DAILY_LIMIT` (default `150000`), and shared with v1 so both draw from the same budget.
- The run's effective cap is `min(input.limit, remaining)`; the run stops queuing work at that cap, so the daily limit is never exceeded. Each emitted item counts once.
- **Graceful degradation**: if the quota store is unreachable, the limiter steps aside and the run proceeds unthrottled.
- A quota line in the run log (`Daily quota: X/Y used today — Z remaining`); everything else about the counter stays on the debug channel.

#### Changed

- Unlike v1 (which fails the run), hitting the daily cap now lets the run **succeed**: allowed results are delivered and the run finishes with a warning + an Apify status message (`Daily limit … Resets in ~N min`). A run that is already at the cap exits immediately.

### \[10.4.0] — 2026-06-02

#### Added

- **`action=get-companies` in v2** — company search **and** company details (previously threw). Company search mirrors people-search and collects `/company/{universalName}` slugs; company details are one request per company → one dataset item.
- `CompanyData` output: name, universalName, companyId, description, tagline, websiteUrl, industry, employeeCount + range, foundedYear, specialities, headquarter, all locations, followerCount, logoUrl, coverImageUrl, linkedinUrl.
- Offline tests over real captured responses: parser fields and slug extraction.

### \[10.3.1] — 2026-06-02

#### Changed

- **The engine's internal work queue is never written to the run's storage.** v10.3.0 wrote it during the run and deleted it in a `finally` block — so it was visible mid-run and the cleanup was best-effort. It is now held in memory only. Scoped to that queue: the dataset output persists exactly as before, and general-debug mode still keeps the queue for the Apify crawler UI.

### \[10.3.0] — 2026-06-02

#### Added

- **General-debug mode** (`src-v2/`): activate via the `GENERAL_DEBUG=1` env var or a hidden input field. When on, the crawler's stats and state are exposed in the Apify UI; when off, none of it is.
- **Two log streams**: a friendly, safe run log by default; a detailed one under general-debug. All levels (debug/info/warning/error).
- **Startup summary**: every run opens with the scraper version, log mode, general-debug state, action/limit/keyword counts, and a warning for any missing required configuration.

#### Changed

- Engine internals now log on the private channel only; the default run log carries the user-facing narrative.
- **The default run log no longer carries the crawler's own chatter** — routine retry warnings that expose the target URL and retry count. A genuine fatal error still surfaces. Private mode shows everything.

#### Fixed

- **Per-second "Statistics" log spam in public mode.** The previous suppression trick set the interval to ~41 years; multiplied out it overflowed a timer's 32-bit signed range and silently clamped to ~1 ms, flooding the log dozens of times per second with a line that leaked engine internals. Suppression is now done by log level; the interval stays a sane value (`60s` public / `30s` private).
- **The work queue no longer lingers in the run's storage in public mode.** Other engine state was already gated out of the Key-Value store, but the queue is a separate store and stayed behind. It is now dropped at shutdown when general-debug is off — the dataset output is untouched, and private mode keeps it for the Apify crawler UI.

### \[10.2.1] — 2026-06-02

#### Fixed

- **Idempotent profile seed**: a re-queued profile no longer resets its live accumulator, which used to dedupe its own parts away and strand the profile so it never emitted.
- Unresolved section requests are filtered out defensively (the project's TypeScript is deliberately loose).

### \[10.2.0] — 2026-06-02

#### Added

- **Unified single-queue v2 pipeline.** Every LinkedIn call (search → profile → sections → detail pages) is now one queued request instead of a direct first phase plus an inline second one, and a profile's follow-up calls run concurrently. Output unchanged: one dataset item per profile.
- **Two-wave profile accumulator** (`src-v2/linkedin/profile-accumulator.ts`): a per-profile completion barrier that emits one aggregated item, best-effort — a part that exhausts its retries still lets the profile through.

#### Changed

- Search retries now ride the crawler's own retry policy instead of a hand-rolled loop.
- Profile handling split into shell / section / detail-page handlers plus an extractor registry.

#### Fixed

- A per-`(profile, part)` key on section requests stops the crawler deduping one profile's parts against another's — their URLs are identical, the profile lives in the request body.

### \[10.1.0] — 2026-06-02

#### Added

- **`proxyConfiguration` input** (standard Apify proxy editor) — supports both Apify Proxy (groups/country) and custom proxy URLs.
- **Sections behind a "Show all" link are now loaded**, generically: the engine reads which sections a profile defers and requests each one, instead of special-casing a single section.
  - **skills** wired + verified live (6–57 clean skills).
  - **certifications** wired (prefers the complete list over the card's inline preview) + verified live ({name, issuer, date}).
  - **honors / projects / organizations / languages** wired + verified live via a generic entity grouper: new `ProfileData` fields `honors` ({title, description}), `projects` ({name, dateRange, description}), `organizations` ({name, role, dateRange, description}); languages now come from the same path.
  - courses/publications: recipes ready, parsers still to add. volunteer/patents: still to capture.
- **Offline test harness** (`pnpm run test:v2`, `src-v2/__tests__/`): assertions over real captured responses (request shape, section discovery and parsing, top-card name/headline/location).
- **New profile fields** `profilePictureUrl` + `backgroundImageUrl`: the member's own images, scoped to the top card so a recommendation's or a company's avatar is never picked up, at the largest available rendition (≈800×800 photo, 350×1400 cover).
- **`followersCount` / `connectionsCount`**, read from the top card's rendered text — they are not numeric fields in the response. Handles both the single-line ("255 followers") and split ("229" + "connections") forms, and "500+". Verified live: followers on creator profiles, connections on normal profiles.

#### Changed

- `pnpm start` now runs **v2** (`src-v2/main.ts`) as the classic Apify actor entry (also used by `apify run`). v1 stays reachable via `pnpm run start:v1`. Added `build:v2`.
- **Section requests now carry only what LinkedIn's own client sends.** They no longer depend on the profile id being read first, so a missed extraction no longer zeroes the profile — it is a health signal, not a gate.

#### Fixed

- **Top-card parsing**: headline/name/location were mis-assigned (name or company used as headline; a comma-bearing headline classified as location; pronouns "She/Her" used as headline; comma-less locations dropped). Now parsed by stable document order — verified live on four profiles covering each of those shapes.
- **`firstName`/`lastName`** now have a fallback for the large pages where the tree walk misses the profile shape.
- **Top-card name with a nickname** (e.g. "Zsófia Réka (Sophie) Tóth"): the exact full-name match failed and the headline became the name; it now falls back to a starts-with-first + contains-last match to skip the name line.

#### Notes

- **Known gaps**: skills, certifications, honors, projects, organizations and languages now work. Remaining: **courses, publications** (recipe ready, parser to add) and **volunteer/patents**. `industryName` is not served on this path; `followersCount`/`connectionsCount` are read from the top card. `languages` name/proficiency pairing is fragile when a language has no proficiency level.

### \[10.0.0] — 2026-06-02

#### Added

- **v2 scraping engine (`src-v2/`)** talking directly to LinkedIn's modern interface — no dependency on `@bebity/linkedin-scraper`.
- Two-phase profile pipeline: keyword/URL search → vanity names, then one request per profile plus one per section (summary, experience, education, certifications, volunteer, languages, skills).
- Documentation under `docs/` (`ARCHITECTURE.md`, `DATA-SOURCING.md`).

#### Changed

- Version set to `10.0.0` to mark the v2 rewrite (previously tracked the `@bebity/linkedin-scraper` package version, `7.x`).

#### Notes

- Input/output and published actor identity stay compatible with v1.
- v2 currently supports **profiles only** (`get-profiles`); `get-companies` still throws.
- `enrichWithCompany` / `enrichWithContact` are accepted in the input schema (v1 compat) but not yet implemented in v2.
- v1 engine (`src/`, wrapping `@bebity/linkedin-scraper`) remains the deployed/shipping path.
