You can scrape public LinkedIn jobs, company pages, posts and profiles with one Python file and no login: our free script toolzerhub/linkedin-scraper on GitHub. It is 408 lines and uses two packages, curl_cffi and BeautifulSoup. On 2026-10-17 it read a company page in 1.5 seconds from our datacenter server with no proxy. LinkedIn's User Agreement forbids scraping, so read the last section first.
How do you set up the LinkedIn scraper?
Clone the repo, install the two packages and run company on a public company name. We ran everything on 2026-10-17 with Python 3.12.13, curl_cffi 0.16.3 and BeautifulSoup 4.15.0, at commit 46d58e8. Times are from our datacenter server with no proxy (a Hostinger IP in Germany) unless a line says residential: a proxy with a US exit.
git clone https://github.com/toolzerhub/linkedin-scraper.git
cd linkedin-scraper
pip install -r requirements.txt
python linkedin_scraper.py company nasa
No browser runs. The script opens one curl_cffi session that sends requests the way Chrome does. Every command takes -o file.json and --proxy URL.
self.http = requests.Session(impersonate="chrome", proxies={"http": proxy, "https": proxy} if proxy else None)
Jobs come from the HTML fragments LinkedIn's logged-out job pages load; company, profile and post pages are read through their application/ld+json block.
How do you scrape a LinkedIn company page?
Run company with a name from the page's URL; it returns industry, size, headquarters, website, specialties, address, follower and employee counts and similar pages. Trimmed output for NASA (founded is null because its page lists no year):
{
"slug": "nasa",
"company_id": "2003",
"name": "NASA - National Aeronautics and Space Administration",
"industry": "Aviation and Aerospace Component Manufacturing",
"website": "http://www.nasa.gov",
"company_size": "10,001+ employees",
"employee_count": 53106,
"followers_count": 7140505,
"type": "Government Agency",
"founded": null,
"headquarters": "Washington, DC",
"address": { "street": "300 E Street SW", "city": "Washington", "region": "DC", "postal_code": "20546", "country": "US" },
"similar_companies": ["spacex", "tesla-motors", "jet-propulsion-laboratory", "european-space-agency"]
}
How do you search LinkedIn jobs with Python?
Run jobs with keywords, --location and --limit; it returns title, company, location, post date and an "Actively Hiring" flag for each job, 10 per request.
[
{
"job_id": "4476590411",
"title": "Data Analyst",
"company": "Searchability NS&D",
"company_url": "https://www.linkedin.com/company/searchability-ns-d",
"location": "London Area, United Kingdom",
"posted_date": "2026-10-07",
"salary": null,
"actively_hiring": true
}
]
salary was null on all 300 London jobs we saved, because search cards carry no pay. The job page does: job 4476590411 returned "£37,000.00/yr - £60,000.00/yr" with the description, seniority, employment type and applicant hint, in 0.4 and 0.7 seconds.
--posted day works: on 3 queries in our probe, none of its 10 jobs was in the unfiltered list. The README also promises a workplace filter, but LinkedIn's remote, job type, experience level and sort parameters changed 0 or 1 of the 10 jobs on the first page.
How many jobs can the script return?
Up to the 998 we asked for in one command (--limit 1000; the script counts before dropping repeats), but not every time: it returned 100 jobs on 7 of 8 runs and 300 on 5 of 6. It ends a search when a page request fails, keeps the jobs it has and prints a warning that does not say why. All runs:
| Run | Datacenter | Residential |
|---|---|---|
--limit 100, 4 runs each |
60, 100, 100, 100 jobs in 22.8 to 30.1 s | 100, 100, 100, 100 jobs in 22.7 to 40.4 s |
--limit 300, 3 runs each |
300, 298, 300 jobs in 71.9 to 81.1 s | 300, 300, 300 jobs in 85.7 to 102.9 s |
--limit 1000, 2 runs each |
418 jobs in 120.3 s, 998 jobs in 271.0 s | HTTP 429 on the first page, 998 jobs in 297.1 s |
Most of the time is the script's own 2-second wait between requests. Two runs ended early, at 60 and 418 jobs; three early stops in 9 datacenter runs against none in 9 residential runs is too few to credit the proxy.
try:
html = self.get(JOBS_SEARCH, {**params, "start": len(jobs)}, BASE + "/jobs/search").text
except ScrapeError:
if jobs: # keep the pages we already have
print(f"Warning: stopped at {len(jobs)} jobs: LinkedIn limited the next page.", file=sys.stderr)
break
raise
What does the script return for posts and profiles?
post <url> returns the text, date, author and like and comment counts; --comments adds the first page of comments. profile <name> returns the name, headline, about text, followers, work history, education and a few recent posts. Profiles are personal data, so we ran it on one public figure's official profile and show counts, not text.
The profile came back with 3 work-history entries, 1 school and 8 recent posts on 6 of 6 runs. On one post, --comments returned 8 of its 617 comments on 4 of 4 runs; the README says "about 10".
Does LinkedIn block the script?
Not for one page at a time. All 18 one-page commands we ran returned data on each connection. The README and the error text say LinkedIn blocks datacenter IPs; on 2026-10-17 it did not block ours.
| Command | Datacenter, no proxy | Residential proxy |
|---|---|---|
company microsoft |
1.5 to 1.8 s | 1.9 to 2.3 s |
jobs --limit 10 |
0.6 to 0.7 s | 0.9 to 1.3 s |
job <id> |
0.4 to 0.7 s | 0.9 to 1.0 s |
profile |
1.3 to 1.7 s | 2.1 to 2.3 s |
post |
1.0 to 1.1 s | 1.3 to 1.5 s |
Across these rows and the longer job runs, the proxy was 1.1 to 1.8 times slower, and its cost is yours.
What failed was several pages in one command. A 5-name and a 10-name company command both ended with HTTP 999 and no output on both connections: the script builds one session for the whole command and prints nothing until every name is done. Run one company per command and pause between them.
li = LinkedIn(args.proxy)
if args.command == "jobs":
data = li.jobs(args.keywords, args.location, args.posted, args.workplace, args.limit)
elif args.command == "job":
data = [li.job(j) for j in args.jobs]
elif args.command == "company":
data = [li.company(c) for c in args.companies]
What the script can't do, and the API calls that do
The free script cannot read the LinkedIn Ad Library, a company's recent updates or its affiliated pages. We make ToolzerHub, and its LinkedIn API returns them with one API key and no proxy of yours. On 2026-10-17 we asked for ads from advertisers matching "Microsoft":
curl -H "x-api-key: $TOOLZERHUB_API_KEY" \
"https://api.toolzerhub.com/v1/linkedin/ads/search?account_owner=Microsoft"
The call returned 24 ad cards and a match_count of 26,217; the filter matches advertiser names containing the word, partners included. A card has an id, format and thumbnail. ads/detail returns the copy, payer, run dates, impression band and targeting. Trimmed to one ad:
{
"ad_id": "1573006783",
"ad_format": "Single Image Ad",
"advertiser": "Microsoft",
"paid_for_by": "ZENITH UK (MEDIA) LIMITED",
"run_dates": "Ran from Oct 7, 2026 to Oct 17, 2026",
"headline": "DTI Group liefert hochpräzise Process Intelligence mit Azure AI",
"total_impressions": "1k-5k",
"targeting": { "segments": [
{ "parameter": "Language", "mode": "include", "summary": "Targeting includes Deutsch" },
{ "parameter": "Location", "mode": "include", "summary": "Targeting includes Deutschland, Österreich" }
] }
}
Of the first 10 ads of the Microsoft walk, 7 had dates, an impression band and targeting; the other 3 had the payer only, and their run_dates holds "Paid for by" and the payer's name. Send each pagination_token to ads/search/pagination with the same filters. It reads two pages, opens the first 20 ads, retries an HTTP error once (a timeout still stops it) and writes a CSV. We ran it with its HTTP calls sent through our own command-line client, not requests.
import csv
import os
import sys
import requests
BASE = "https://api.toolzerhub.com/v1/linkedin"
HEADERS = {"x-api-key": os.environ.get("TOOLZERHUB_API_KEY", "")}
FIELDS = ["ad_id", "advertiser", "paid_for_by", "run_dates", "total_impressions", "ad_format", "headline", "body"]
def get(path, **params):
"""One API call, 1 credit. Tries twice on an HTTP error; None means it kept failing."""
for attempt in range(2):
try:
resp = requests.get(f"{BASE}/{path}", params=params, headers=HEADERS, timeout=60)
resp.raise_for_status()
return resp.json()["data"]
except requests.HTTPError:
if attempt:
return None
def main(advertiser, path, pages=2, details=20):
page = get("ads/search", account_owner=advertiser) # 24 ads a page
ids, calls = [], 1
for number in range(pages):
if not page:
break
ids += [ad["ad_id"] for ad in page["ads"]]
if number == pages - 1 or page["is_last_page"] or not page["pagination_token"]:
break
page = get("ads/search/pagination", account_owner=advertiser, pagination_token=page["pagination_token"])
calls += 1
ids = list(dict.fromkeys(ids))
rows = 0
with open(path, "w", newline="", encoding="utf-8") as f:
out = csv.DictWriter(f, fieldnames=FIELDS, extrasaction="ignore")
out.writeheader()
for ad_id in ids[:details]:
ad = get("ads/detail", ad_id=ad_id) # the search cards carry no copy, payer or dates
calls += 1
if ad:
out.writerow(ad)
rows += 1
print(f"{advertiser}: {len(ids)} ads found, {rows} detailed rows, {calls} calls -> {path}")
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])
export TOOLZERHUB_API_KEY=your_key
python linkedin_ads.py Shopify shopify_ads.csv
Output on 2026-10-17, after 29.8 seconds net of the client's start-up:
Shopify: 48 ads found, 20 detailed rows, 22 calls -> shopify_ads.csv
Five runs took 18.3 to 29.8 seconds for 22 or 23 calls. Dates were on 12 of 20 Shopify rows, 18 of 20 Microsoft and 2 of 20 HubSpot. Following the token gave 1,464 distinct ads in 61 calls on two Microsoft walks and one Shopify walk; we stopped there, with is_last_page still false.
API timings are net of our client's 0.21-second start-up, measured on a call that fails before the network. The median over 59 calls was 1.2 seconds, the longest 3.2. One of 303 calls in our jobs walks took 31, so set a 60-second timeout.
Where the API is not better than the script
The company route returns the same fields plus 10 recent updates with reaction counts (8 of 8 companies) and 1 to 52 affiliated pages. One post gave 16 comments on 3 of 3 calls against the script's 8. For jobs the script did as well, free.
A job search takes start up to 1,000. We walked it in steps of 10: 100 successful calls gave 881, 890 and 881 distinct jobs on three walks over two queries, against the script's 998 on two runs. Pages overlap, so deduplicate by job_id. Each walk ended in an upstream error at start=1000.
On the London query, experience_levels, job_types, workplace and sortBy returned the same 10 jobs as no filter on 7 of 7 variants, as in the script probe; date_posted and company_id did change the results. The ads company_name filter returned data: null on 4 of 4 calls; account_owner works.
What the API costs
A free key gives you 100 credits a month with no card: get a free API key. Every call above is 1 credit, and failed calls are not charged. Plans are $29 for 20,000 credits ($1.45 per 1,000), $79 for 60,000 ($1.32), $199 for 165,000 ($1.21) and $495 for 500,000 ($0.99), so 1,000 calls cost $0.99 to $1.45 on a monthly plan.
The Shopify run would use 22 credits: $0.02 to $0.03. A 100-call jobs walk gave about 885 distinct jobs for $0.10 to $0.15, and each description is one more call.
In the 7 days to 2026-10-17, post had 132 successful calls and 1 server error. The job and ads routes had under 10 calls each (one job search had a server error), so we made 10 live calls on each: all returned 200, and 9 of 10 job searches returned jobs. The tenth, location Amsterdam, was empty for the script too.
Is it legal to scrape LinkedIn?
LinkedIn's User Agreement forbids it, and whether it is also unlawful depends on where you are and what you collect. The agreement (effective November 3, 2025) says you agree not to "Develop, support or use software, devices, scripts, robots or any other means or processes (such as crawlers, browser plugins and add-ons or any other technology) to scrape or copy the Services, including profiles and other data from the Services".
The agreement applies to anyone who accesses the Services, not only members. LinkedIn's robots.txt says "The use of robots or other automated means to access LinkedIn without the express permission of LinkedIn is strictly prohibited." The script reads only what a logged-out visitor sees, and neither text makes an exception for that.
In hiQ Labs v. LinkedIn, the Ninth Circuit in April 2022 affirmed an injunction that kept LinkedIn from blocking hiQ; per Proskauer, hiQ had "raised at least serious questions" that scraping public profiles is lawful under the CFAA, a US anti-hacking law.
The district court dissolved that injunction in August 2022, and a November 2022 ruling left hiQ facing a finding that it breached the User Agreement through scraping and fake accounts. In December 2022 the parties filed a proposed consent judgment: $500,000 against hiQ and, as Proskauer reports it, a ban on hiQ scraping LinkedIn in violation of its User Agreement, logged in or not. Proskauer notes the settlement sets no binding precedent.
This is not legal advice. LinkedIn's own API is the supported route; what the official API costs covers it. The same script-versus-API test for other sites is in scraping TikTok with Python and scraping Google Maps with Python.
We are not affiliated with LinkedIn Corporation.
Sources
- toolzerhub/linkedin-scraper (commit 46d58e8), https://github.com/toolzerhub/linkedin-scraper, run 2026-10-17
- LinkedIn User Agreement (effective November 3, 2025), section 8.2, https://www.linkedin.com/legal/user-agreement, read 2026-10-17
- LinkedIn robots.txt, https://www.linkedin.com/robots.txt, read 2026-10-17
- Proskauer, hiQ and LinkedIn settlement (2022-12-08), https://newmedialaw.proskauer.com/2022/12/08/hiq-and-linkedin-reach-proposed-settlement-in-landmark-scraping-case/, read 2026-10-17
- ToolzerHub pricing, https://toolzerhub.com/pricing, checked 2026-10-17
- ToolzerHub OpenAPI spec (credits), https://api.toolzerhub.com/openapi.json, read 2026-10-17