Export ad-creative records from LinkedIn's public Ad Library by advertiser name — creative type, ad copy, image URLs, ad-detail link, timestamps. Real multi-page depth via LinkedIn's own pagination, not just page one. Batch advertisers in one run, fault-isolated. JSON/CSV export.
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.