I run a visa agency in Bali, and a surprising amount of my prospecting comes down to one question: which companies are hiring right now, and for what. A company advertising five engineering roles in a new market is a company that will shortly need paperwork for people. So I built an Apify Actor that reads LinkedIn's public guest job pages — the ones LinkedIn serves to logged-out visitors — and turns a job search into a table: title, company, location, seniority, applicants, salary when the employer posts it, the full description, plus the hiring company's website, size and headquarters on every row. No account, no cookies, nothing to get restricted.
To make sure it keeps working, a cron job has been firing the dullest possible input at it for a month:
{
"keywords": ["python developer"],
"location": "New York, NY",
"maxJobsPerQuery": 25,
"includeDescription": true,
"includeCompanyDetails": true
}
I only ever checked the exit status. Last night I opened the datasets instead and found I had accidentally built a panel: 30 runs of the identical search between 6 and 31 October 2026, 729 job rows, 69 distinct job ids. Diffing them killed a field I had been happily putting in reports.
The applicant count is two bar charts and a hole in the middle
applicants is the field everyone wants, because it is the only competition signal LinkedIn hands a guest for free. Across my 729 rows it was never null. It was also never below 25 and never above 200.
rows whose applicantsText was "Be among the first 25 applicants" 106
rows whose applicantsText was "Over 200 applicants" 197
rows with a plain number ("153 applicants") 426
---
rows reading exactly 25 or exactly 200 308
of those, rows that said a plain "25 applicants" / "200 applicants" 5
So 308 rows — 42% of the dataset — carry one of two numbers, and in 303 of them that number is a bucket boundary wearing a count's clothing. Below 25, LinkedIn stops counting and prints the recruiting-funnel pitch instead. Above 200, it stops counting and prints "Over 200". The observable range for the whole month, across 69 job postings at 69 companies, was [25, 200] — and the ends are not part of it.
The Actor exposes both forms on purpose: the parsed integer in applicants and LinkedIn's own wording in applicantsText. I used to treat the second as cosmetic. It is the only thing that tells you whether the first is a measurement. 19 of 69 postings sat on the floor at some point; 20 of 69 hit the ceiling. If you ORDER BY applicants DESC you get a tie between 20 postings that might be 200 or 2,000 apiece, and if you filter applicants < 30 to find uncontested roles, you will scoop up every job LinkedIn simply refused to count.
It also goes backwards
A counter of cumulative applications can only rise. Mine fell 13 times across 3 postings. Here is "Sr Backend Engineer (Python)" at Datasite, one row per run:
10 Oct Over 200 applicants posted 2026-10-06
11 Oct 194 applicants
13 Oct 195 → 200 applicants
17 Oct 192 applicants
18 Oct 188 applicants
19 Oct 187 applicants
23 Oct 196 applicants
24 Oct 195 applicants
26 Oct 192 applicants
28 Oct Over 200 applicants posted 2026-10-28
It crossed the ceiling, came back down under it, wandered 187–196 for ten days, and crossed again. Whatever LinkedIn is showing a guest there, it is not "applications received to date" — it behaves like a count of something currently active, recomputed per page build.
And one posting — "Python Developer" at Software Guidance & Assistance, job id 4470022567 — did this:
06 Oct Be among the first 25 applicants posted 2026-09-24
10 Oct 87 applicants posted 2026-10-09
11 Oct 72 applicants
12 Oct Be among the first 25 applicants
20 Oct Be among the first 25 applicants posted 2026-10-19
20 Oct 35 applicants
The same job id, the same URL, told me twice that I could be among its first 25 applicants after reporting 87.
The reason is in the date field, and it's the bigger problem
Look at the right-hand column above. postedAt moved on the same job id. It did that for 9 of the 59 postings I saw more than once, 16 times in total, and every single move was forward. Never once backwards.
That is employers (or LinkedIn's own refresh) re-dating a live posting. The id survives, the URL survives, the description survives — the clock restarts, and the applicant count tends to restart with it. The worst case in my panel is job 3952273769, "Python Developer" at Open Systems Technologies:
06 Oct run postedAt 2026-09-17 "2 weeks ago"
10 Oct run postedAt 2026-10-09 "14 hours ago"
31 Oct run postedAt 2026-10-31 "10 hours ago"
A posting I had been watching for 25 days, first advertised as two weeks old on the day I met it, introduced itself on 31 October as ten hours old. Three births for one job.
The practical consequences are worth more than the trivia:
-
datePosted: "day"does not mean "new". It means "LinkedIn's date field is within a day", and that field is editable. A freshness filter on this source returns reposts. If "new to me" is what you want, store your ownfirstSeenAtthe first time an id appears and trust that instead. -
Dedupe on
id, never onid+postedAt. Any pipeline keyed on both will resurrect the same job as a fresh lead every few weeks. That is exactly how an outreach list sends a company the same "I saw you're hiring" email three times. -
Don't diff
applicantson a row whosepostedAtchanged. The drop is bookkeeping, not cooling demand.
What did hold still
The same panel vouches for the rest of the schema, which is why I am keeping the Actor pointed at this source rather than giving up on it.
Salary never moved. 20 of 69 postings published pay, and across 729 rows not one of them changed salaryMin/salaryMax — including the hourly one (90.58–108.70/hour, parsed into salaryPeriod: "hour"). Posted pay is a fact the employer typed once.
Company numbers drift like real measurements. Bank of America's companyFollowers went 3,490,395 → 3,524,338 over the 25 days, monotonically, while companyEmployeesOnLinkedIn slid 227,047 → 226,890. Up-only on one, down-slowly on the other, no round numbers, no ceilings. That is what an uncensored field looks like, and it is the cleanest contrast with applicants I have.
What is simply absent, and why. Honest null rates from the 729 rows, so nobody has to discover them in production: description 100%, industries 100%, seniority 100%, companyWebsite 92%, companyHeadquarters 91%, companyFounded 73%, salaryText 28%, posterName 19% — and then applyUrl 0/729, jobFunction 0/729. Those last two are not bugs: LinkedIn shows the external apply link and the job function to signed-in members only. applyType still tells you whether the button is Easy Apply or an external site, and url opens the posting. workplaceType was non-null on 16 rows because I never set the remote filter — it is populated from the filter you searched with, not inferred from prose.
Reading one honestly
curl -s -X POST "https://api.apify.com/v2/acts/lergassy~linkedin-jobs-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"keywords":["python developer"],"location":"New York, NY","maxJobsPerQuery":25,"includeDescription":true}'
One real row from the 31 October run, trimmed only where text is long:
{
"type": "job",
"id": "4454812137",
"title": "Python Developer III",
"company": "Bank of America",
"location": "Jersey City, NJ",
"postedAt": "2026-10-18T00:00:00.000Z",
"postedText": "1 week ago",
"seniority": "Not Applicable",
"industries": "Banking",
"applicants": 200,
"applicantsText": "Over 200 applicants",
"applyType": "external",
"applyUrl": null,
"salaryText": "$102,900.00/yr - $179,900.00/yr",
"salaryMin": 102900,
"salaryMax": 179900,
"salaryCurrency": "USD",
"salaryPeriod": "year",
"companyWebsite": "https://www.bankofamerica.com",
"companyDomain": "bankofamerica.com",
"companySize": "10,001+ employees",
"companyEmployeesOnLinkedIn": 226890,
"companyFollowers": 3524338,
"companyHeadquarters": "Charlotte, NC",
"description": "Job Description:\nAt Bank of America, we are guided by a common purpose …",
"scrapedAt": "2026-10-31T20:30:30.830Z"
}
Note that applicants and applicantsText disagree about what kind of thing they are. Keep both columns. If I had stored only the integer a month ago, this whole investigation would have been invisible.
One more small thing the panel taught me about my own output. Three of the 30 runs returned 18 rows instead of 25, and I assumed LinkedIn had thinned the result page. It hadn't: the health-check account has a $0.05 cap on cost per run, and 18 rows at the tier price of $0.0027 is $0.0486 — a nineteenth row would have breached it. The Actor said so in two places I had not been reading: the run status message, and a free type: "notice" row appended to the dataset with the exact charge. Short runs are much more often a budget than a block. Filter type != "notice" in your pipeline and read it in your monitoring.
When this is the wrong tool
If you need competition analytics, a reliable "posted in the last 24 hours" feed, or external apply URLs, the guest pages cannot give you those, and no scraper reading them can either — the ceiling is LinkedIn's, not the Actor's. The per-search ceiling is also real: about 1,000 jobs, the same number LinkedIn shows any visitor, so wide searches have to be split by city or keyword. And if LinkedIn is only one of the boards you care about, reading it alone is a strange choice — Jobs API puts Indeed, LinkedIn and Greenhouse/Lever/Ashby/Workable boards into one schema.
What it is good at is exactly what my cron has been proving for a month: give it a role and a place, and every row comes back with the company attached — website, domain, industry, size, employees, headquarters — so "who is hiring for this, and are they big enough to matter" is one query instead of two pipelines. That is LinkedIn Jobs Scraper, $0.003 per job row, error rows free.
Store the integer, store the wording, store your own first-seen date. LinkedIn's dates and counts are editable; yours are not.
Top comments (0)