# Changelog of LinkedIn Ad Library Scraper (`devilscrapes/linkedin-ad-library-scraper`) Actor

- **URL**: https://apify.com/devilscrapes/linkedin-ad-library-scraper/changelog.md
- **Full Actor documentation**: https://apify.com/devilscrapes/linkedin-ad-library-scraper.md

## LinkedIn Ad Library Scraper — Changelog

### 0.3.2 — 2026-09-12

#### Removed

- **`destination_url` dropped from the dataset schema, output model, and
  README.** Field-fill audit on the live Store listing (run
  `It8x4nLV1lcUyDXuW`) found it `null` on every single row this Actor has
  ever produced — a promised column that was structurally incapable of
  ever being filled, not an extraction bug on a real payload.
- **Live recon settled the question rather than guessing.** Fetched a real
  search page (`accountOwner=Microsoft`, 24 ads) and a real continuation
  page through Apify's RESIDENTIAL proxy and grepped every `href` in both:
  zero external URLs anywhere — every anchor is internal
  (`linkedin.com`/`licdn.com`). The external landing URL **does** exist on
  LinkedIn's side, but only on the separate per-ad
  `/ad-library/detail/<id>` page, and even there inconsistently: present as
  a direct `href` for `SPONSORED_STATUS_UPDATE` image/headline CTAs
  (confirmed: a real Microsoft ad resolved to
  `aitour.microsoft.com/flow/...`), embedded inline in ad copy for
  `SPONSORED_VIDEO` (a real ad resolved to an `lnkd.in` short link), and
  entirely absent from static markup for CTA-button creatives whose click
  target is resolved by client-side JS, not server-rendered HTML.
- **Not shipped as a fix because it isn't one — it's a new endpoint.**
  Populating `destination_url` correctly means fetching a second page per
  ad (`/ad-library/detail/<id>`), not fixing a selector on the page this
  Actor already fetches. That's N+1 additional proxied requests per run
  instead of one per ~24 ads — a real compute/cost/block-risk increase
  that needs its own spec, proxy-tier probe, and PPE-pricing review, not a
  same-day patch on a live monetized Actor. A promised-but-always-null
  column is worse than no column, so it was removed now; per-ad detail
  enrichment is a candidate follow-up feature, not folded into this fix.
- No other output field showed the every-row-empty pattern on a fresh live
  sample (48 ads across 2 advertisers/pages: `ad_id`, `creative_type`,
  `advertiser_name`, `ad_copy`, `image_urls` all 100%). One minor,
  non-blocking gap found in passing: `ad_copy` was `null` for 1/24 ads on a
  `salesforce` sample — creative type `SPONSORED_INMAILS`, whose card
  doesn't carry `p.commentary__content` or `h2` (the two selectors
  `_extract_ad_copy` checks). Single-sample; not fixed here, flagged for a
  future pass if it recurs at volume.

### 0.3.1 — 2026-09-10

#### Fixed

- **Default proxy tier reverted to `RESIDENTIAL`.** 0.3.0 briefly defaulted to
  the datacenter group on a LOCAL block-rate reading (datacenter 6/8 clean vs
  residential 2/8). A cloud run settled it the other way: datacenter went
  **0-for-5** on fresh sessions and failed, while the identical run on
  residential **SUCCEEDED with 80 unique ads over 4 pages in 20s**. The local
  reading was taken against a 27-IP static pack our own probing had already got
  flagged, so rotating the session id kept re-drawing from a poisoned pool.

#### Unchanged

- The Cloudflare-challenge retry on a fresh proxy session (added in 0.3.0) is
  kept — it is correct and load-bearing regardless of tier.

### 0.3.0 — 2026-09-10

- **Fixed a cloud-only failure**: a real cloud depth run (`microsoft`,
  `maxAdsPerAdvertiser=80`) failed in 4 seconds with 0 rows —
  `LinkedInBlockedError` fired correctly on page 1's very first Cloudflare
  challenge, but propagated immediately with no retry. Measurement (8 fresh
  Apify Proxy sessions/tier, `firefox133`, same URL) showed the block is
  **exit-dependent**, not a hard fingerprint block. `_fetch_with_retry` now
  opens a fresh session on a freshly minted Apify Proxy session id per HTTP
  attempt (via a new `GetProxyUrl` callable threaded from `main.py` through
  `scraper.py`) and treats a Cloudflare challenge as retryable, exactly like
  `408`/`429`/`503` — riding a different exit on the next attempt. Only
  after `MAX_RETRIES` attempts does a persistent block still raise
  `LinkedInBlockedError`; the fail-loud behaviour at that point is
  unchanged (this Actor bills per row and must never silently skip a
  blocked advertiser as "empty").
- **Default proxy tier switched to datacenter (`BUYPROXIES94952`), not
  RESIDENTIAL.** The same measurement found RESIDENTIAL US cleared only
  2/8 attempts vs. datacenter's 6/8 — RESIDENTIAL was simultaneously the
  worse-performing tier, the more expensive one, and the one that
  classified the Actor as anti-bot-class for publish-gate purposes.
  RESIDENTIAL stays fully operator-selectable via input; nothing is
  hardcoded.
- Correction to the record: 0.2.0's "residential+firefox133 walked 216 ads
  over 9 pages, zero 4xx" claim was a single exit (n=1), not a clearance
  rate, and should not have been read as one.

### 0.2.0 — 2026-09-10

- **Real multi-page depth.** Replaced the v1 one-fetch-per-advertiser
  limitation with a genuine pagination walk over LinkedIn's keyset-cursor
  `searchPaginationFragment` endpoint. The v1 "client-side infinite scroll,
  out of scope" conclusion was wrong — see spec CORRECTION 2026-09-10 #2.
- Termination is primarily a `200` response with an **empty body**, not
  `isLastPage:true` (which a multi-advertiser recon walk found is rarely
  emitted). Distinguishes a normal end-of-list from a genuinely empty
  advertiser (skip cleanly, no charge) from an upstream shape change on a
  continuation page (fail loud for that advertiser, keep already-collected
  rows) — and, found via a real (non-mocked) `apify run` against the live
  target, from a confirmed `isLastPage:true` on an otherwise ad-less
  continuation page (an advertiser whose ad count is an exact page-multiple,
  e.g. exactly 24) — also a normal, clean end of list, not a shape change.
- Ads are deduped by id (content-fingerprint fallback) across the whole
  per-advertiser walk before being pushed/charged, so an overlapping page
  boundary can never double-bill.
- New `maxAdsPerAdvertiser` input field; `maxPagesPerAdvertiser` is now a
  real cap (was "reserved" in v1). `maxRunSeconds` ceiling lowered to 3 480s
  so the Actor ships collected rows ahead of the platform's 3 600s hard kill.
- The run status message now leads with a `[COMPLETE]` / `[TRUNCATED]`
  marker — this Actor bills per row, so a partial walk must never read
  identically to a finished one.
- **Fixed a live anti-blocking defect found while probing the fix**:
  `chrome131` returned a Cloudflare 403 challenge through every Apify Proxy
  tier tested (datacenter, RESIDENTIAL US, RESIDENTIAL GB) while clearing
  fine unproxied. Dropped it from the browser-impersonation rotation
  (`firefox133` only, the sole proxy-verified-clean profile) and added a
  named `LinkedInBlockedError`, raised the moment a response body contains
  the Cloudflare challenge marker regardless of status code, so a block can
  never be parsed as a quiet "0 ads" result.
- This is the fix that unblocks the Actor going public — v1 stayed private
  specifically because of the shallow-depth limitation.

### 0.1.0 — 2026-09-10

- First implemented release. Batch-searches LinkedIn's public Ad Library by
  advertiser name (`?accountOwner=<name>`), one fetch per advertiser (the
  `?page=N` parameter is a confirmed no-op — see spec's "CORRECTION
  2026-09-10").
- Per-advertiser fault isolation: a blocked or unreadable advertiser is
  logged and skipped; the run only fails if every advertiser errored.
- Genuinely-empty results ("No results found") succeed with zero rows,
  distinct from a page that could not be read at all.
- Honest partial-collection status messaging: when more ads exist than this
  version collects, the run says so explicitly instead of under-delivering
  silently.
- Pay-Per-Event pricing: `actor-start` ($0.20) + `ad-result` ($0.003/row).

### 0.0.1 — 2026-05-15

- Initial scaffolded release (skeleton only, no scraping logic).
