API engineering

How to Scrape LinkedIn Data: Python & API Guide (2026)

EnvoAPI teamUpdated 28 min read

How to Scrape Linkedin Data Hero Image
In this article

In this guide, you’ll learn how to scrape LinkedIn data with Python, JavaScript, or TypeScript: retrieve profile details, find available emails and phone numbers, collect company data, search jobs, extract posts, find leads, and export your results to CSV or Excel.

We’ll start with browser automation, show you the actual results and screenshots from our profile tests, then walk through an API workflow for collecting larger datasets.

Before you scrape LinkedIn: requirements and setup

Start with one profile URL and the fields you actually need. For the examples below, you’ll need:

  • A Windows, Mac, or Ubuntu computer with a browser and internet access. You can use the Windows Subsystem for Linux (WSL) to run the commands shown below.
  • Python 3.11+ or Node.js 24, depending on your language.
  • A code editor, terminal, and a public LinkedIn profile URL.
  • A proxy (optional).

Build a LinkedIn profile scraper with Playwright

We’ll use Playwright to open Chromium, visit a public profile, and extract the name and visible headline with CSS selectors. This makes the extraction easy to inspect: you keep both the page the code saw and the data it saved.

The scraper also inspects the final URL and canonical profile URL before accepting the name. This is important since a request may land on a different profile from the one you asked for.

Extract LinkedIn profile data with Python

Applies to every example on this page

Install Playwright and Chromium, then use the Python example below. It saves a screenshot so you can check the result with the JSON output.

Installbash
python -m pip install 'playwright==1.64.1'python -m playwright install chromium
browser_profile.pypython
import argparseimport jsonimport osimport refrom pathlib import Pathfrom urllib.parse import quote, unquote, urlsplitfrom playwright.sync_api import sync_playwrightdef canonical_profile_url(value):    parts = urlsplit(value)    if (parts.scheme != "https" or parts.hostname not in {"linkedin.com", "www.linkedin.com"}            or parts.username or parts.password or parts.port not in (None, 443)):        raise ValueError("Use an HTTPS linkedin.com/in/ profile URL.")    match = re.fullmatch(r"/in/([^/]+)/?", parts.path)    if not match:        raise ValueError("Expected one public profile slug.")    slug = unquote(match[1])    if any(c in slug for c in "/?#\\") or any(ord(c) < 32 for c in slug):        raise ValueError("Invalid profile slug.")    return "https://www.linkedin.com/in/" + quote(slug, safe="-._~") + "/"def main():    parser = argparse.ArgumentParser()    parser.add_argument("--url", required=True)    parser.add_argument("--headed", action="store_true")    parser.add_argument("--screenshot", default="output/browser-profile.png")    args = parser.parse_args()    target = canonical_profile_url(args.url)    proxy = None    if os.getenv("PROXY_SERVER"):        proxy = {"server": os.environ["PROXY_SERVER"]}        for field in ("username", "password"):            if os.getenv("PROXY_" + field.upper()):                proxy[field] = os.environ["PROXY_" + field.upper()]    result = {        "http_status": None, "status": "unprocessed", "profile_url": target,        "name": None, "headline": None, "source": "visible_public_profile_card",    }    with sync_playwright() as p:        browser = p.chromium.launch(headless=not args.headed, proxy=proxy)        try:            page = browser.new_page(viewport={"width": 1440, "height": 1000})            page.set_default_timeout(10000)            try:                response = page.goto(args.url, wait_until="domcontentloaded", timeout=40000)                result["http_status"] = response.status if response else None                path = urlsplit(page.url).path.lower()                if result["http_status"] != 200 or re.search(r"/(authwall|login|checkpoint)(/|$)", path):                    result["status"] = "access_failure"                else:                    name = page.locator("h1.top-card-layout__title")                    canonical = page.locator('link[rel="canonical"]').first.get_attribute("href")                    if (not canonical or canonical_profile_url(canonical) != target                            or name.count() != 1 or not name.is_visible()):                        result["status"] = "identity_not_matched"                    else:                        result["name"] = name.inner_text().strip() or None                        headline = page.locator("h2.top-card-layout__headline")                        if headline.count() == 1 and headline.is_visible():                            text = headline.inner_text().strip()                            result["headline"] = text if text and "*" not in text else None                        result["status"] = "partial" if result["name"] else "incomplete"            except Exception as exc:                result.update(status="navigation_error", error_type=type(exc).__name__)            try:                screenshot = Path(args.screenshot)                screenshot.parent.mkdir(parents=True, exist_ok=True)                page.screenshot(path=str(screenshot))            except Exception:                result["screenshot_status"] = "capture_failed"        finally:            browser.close()    print(json.dumps(result, indent=2))    return 0 if result["status"] == "partial" else 2if __name__ == "__main__":    raise SystemExit(main())
80 lines
Runbash
python browser_profile.py --url 'https://www.linkedin.com/in/kentcdodds/'

Add --headed to watch the browser. For an optional proxy, set PROXY_SERVER and any required PROXY_USERNAME and PROXY_PASSWORD in your terminal.

The result from our Python run

json
{  "http_status": 200,  "status": "partial",  "profile_url": "https://www.linkedin.com/in/kentcdodds/",  "name": "Kent C. Dodds",  "headline": null,  "source": "visible_public_profile_card"}
Screenshot of a public LinkedIn profile in a browser
A public profile card in a saved browser check.

Our run produced a partial record: the name is present, but the headline is null. Be sure to account for partial records in your dataset. One page loading successfully does not mean all fields have been collected.

The saved screenshot shows more fields in the same profile card. These include location, education, website links, About text, followers, and connection counts. To extract these, review the returned HTML and add selectors for the elements present.

Python: parse more profile fields

This standalone example opens the profile and parses its HTML with Beautiful Soup. The selectors come from our saved page; adjust them if LinkedIn changes its layout.

Installbash
python -m pip install beautifulsoup4
profile_fields.pypython
import jsonimport osimport refrom urllib.parse import parse_qs, quote, unquote, urljoin, urlsplitfrom bs4 import BeautifulSoupfrom playwright.sync_api import sync_playwrightdef canonical_profile_url(value):    parts = urlsplit(value)    if (parts.scheme != "https" or parts.hostname not in {"linkedin.com", "www.linkedin.com"}            or parts.username or parts.password or parts.port not in (None, 443)):        raise ValueError("Use an HTTPS linkedin.com/in/ profile URL.")    match = re.fullmatch(r"/in/([^/]+)/?", parts.path)    if not match:        raise ValueError("Expected one public profile slug.")    slug = unquote(match[1])    if any(c in slug for c in "/?#\\") or any(ord(c) < 32 for c in slug):        raise ValueError("Invalid profile slug.")    return "https://www.linkedin.com/in/" + quote(slug, safe="-._~") + "/"def parse_profile(html):    soup = BeautifulSoup(html, "html.parser")    def text(selector):        node = soup.select_one(selector)        return node.get_text(" ", strip=True) or None if node else None    header = soup.select_one(".profile-info-subheader")    labels = list(header.stripped_strings) if header else []    websites = []    for link in soup.select('[data-section="websites"] a[href]'):        url = urljoin("https://www.linkedin.com", link["href"])        parts = urlsplit(url)        if parts.hostname == "www.linkedin.com" and parts.path == "/redir/redirect":            url = parse_qs(parts.query).get("url", [url])[0]        if urlsplit(url).scheme in {"http", "https"}:            websites.append(url)    return {        "location": text(".profile-info-subheader > span"),        "education": text('[data-section="educationsDetails"] .top-card-link__description'),        "websites": list(dict.fromkeys(websites)),        "about": text('[data-section="summary"] .core-section-container__content > div:first-child'),        "followers": next((value for value in labels if value.endswith(" followers")), None),        "connections": next((value for value in labels if value.endswith(" connections")), None),    }url = os.getenv("LINKEDIN_PROFILE_URL", "https://www.linkedin.com/in/kentcdodds/")target = canonical_profile_url(url)proxy = Noneif os.getenv("PROXY_SERVER"):    proxy = {"server": os.environ["PROXY_SERVER"]}    for field in ("username", "password"):        if os.getenv("PROXY_" + field.upper()):            proxy[field] = os.environ["PROXY_" + field.upper()]with sync_playwright() as p:    browser = p.chromium.launch(proxy=proxy)    try:        page = browser.new_page()        page.set_default_timeout(10000)        response = page.goto(url, wait_until="domcontentloaded", timeout=40000)        path = urlsplit(page.url).path.lower()        if not response or response.status != 200 or re.search(r"/(authwall|login|checkpoint)(/|$)", path):            raise RuntimeError("Profile unavailable; no data extracted.")        canonical = page.locator('link[rel="canonical"]').first.get_attribute("href")        name = page.locator("h1.top-card-layout__title")        if (not canonical or canonical_profile_url(canonical) != target                or name.count() != 1 or not name.is_visible()):            raise RuntimeError("Could not match the requested profile.")        result = {"profile_url": target, "name": name.inner_text().strip()}        result.update(parse_profile(page.content()))        print(json.dumps(result, indent=2, ensure_ascii=False))    finally:        browser.close()
78 lines

Save it as profile_fields.py and run:

Runbash
python profile_fields.py

Missing text fields stay None, and counts keep their displayed values, such as 14K followers and 500+ connections. The About text may be truncated; this only extracts what the returned HTML provides.

Why LinkedIn scraping hits login walls

The code above worked, but when we tried another profile URL, LinkedIn returned HTTP 999 and a sign-up wall instead of profile data.

Chromium screenshot showing LinkedIn’s sign-up wall instead of the requested profile
The profile request returned HTTP 999 and a sign-up wall.

When you scrape a page, validate the response before storing it. Keep an access failure separate from a profile with empty optional fields. Changing a selector won’t recover a name from a sign-up page.

A similar frustration appears in this r/webscraping discussion, where u/bar_pet wrote:

LinkedIn is one of the hardest to scrape real-time.

u/bar_petReddit

That’s one commenter’s assessment. Our two profile attempts illustrate the practical issue: a single usable result doesn’t tell you whether the next URL will return usable data.

Why browser scraping gets harder at scale

Access is only one part of the problem. Browser-based web scraping also has a resource cost: each Chromium worker needs memory and CPU to load and render pages, including pages that end at a login wall.

We compared Python requests with a reused browser against a local static HTML fixture on an Apple M2 Max, using 5 runs per client. This isolates client overhead. It doesn’t measure how fast either approach can scrape LinkedIn.

Median across five runs for each method
Measured metricPython requestsReused browser
Client startup34.788 ms295.464 ms
First request + extraction1.840 ms22.574 ms
Warm latency: median of run-level p50s0.432 ms14.175 ms
Sampled peak process-tree RSS34.562 MiB448.812 MiB
Sampled process-tree CPU time per run0.095 s0.734 s
Measured localhost fixture results comparing median warm latency and sampled peak process-tree memory for Python requests and reused Chromium
Local static-HTML benchmark on an Apple M2 Max: five runs per client, reusing the browser or HTTP session. These results measure client overhead, not LinkedIn throughput or API speed.

In this test, Chromium used about 13× the peak memory and 7.7× the CPU time of Python requests. That overhead matters when you add workers, alongside the costs of proxies, retries and maintaining the scraper.

If you mainly need the returned fields, an API can remove browser workers from your application. You still need to handle missing values, request failures and credit usage. Here’s how that workflow looks with EnvoAPI.

Use a LinkedIn scraper API to retrieve profile data

With EnvoAPI’s LinkedIn Profile API, you send a profile URL and receive JSON with fields such as fullName and headline. Your application uses an API key; this request doesn’t require your LinkedIn account, cookies or a proxy.

EnvoAPI is an independent, third-party service. It is separate from the official LinkedIn API and its access permissions, which is why it’s often called an unofficial LinkedIn API.

We’d test a few sample LinkedIn profiles before scraping in bulk. Check whether the LinkedIn data returned covers your use case, how incomplete fields are represented and what each lookup costs. Compare that with the browser, proxy and maintenance work your application would otherwise need.

Get your EnvoAPI key

Create an account to get 100 free credits, then generate an API key in the dashboard.

Set the key in your macOS/Linux terminal, or in WSL on Windows. Replace the placeholder with your own key and keep it out of source control. The command below sets it as an environment variable:

Environmentbash
export ENVO_API_KEY='your_envoapi_api_key'

Send a profile URL with cURL

This cURL command sends a profile URL to the profile details endpoint, saves the response to profile-response.json, and prints the HTTP status and elapsed time. Start here before wiring the request into a larger script:

GET/v1/profiles/details/by-urlbash
curl --get 'https://api.envoapi.com/v1/profiles/details/by-url' \  --header "Authorization: Bearer $ENVO_API_KEY" \  --header 'Accept: application/json' \  --data-urlencode 'url=https://www.linkedin.com/in/kentcdodds/' \  --connect-timeout 10 --max-time 60 \  --silent --show-error --fail-with-body \  --output profile-response.json \  --write-out 'HTTP %{http_code}\nTotal time: %{time_total}s\n'

What we got back

Our saved request returned HTTP 200 in about 2.15 seconds and used 1 credit. The response included About text, work experience, education, skills and languages. We’ve shortened the arrays and omitted some fields below so it’s easier to scan.

json
{  "data": {    "fullName": "Kent C. Dodds",    "firstName": "Kent C.",    "lastName": "Dodds",    "username": "kentcdodds",    "headline": "Software Engineer and Educator",    "linkedinUrl": "https://www.linkedin.com/in/kentcdodds/",    "about": "My mission is to make the world a better place through quality software.I do that by helping developers build better software and better products.That's been my full-time work for years: teaching, building open source, recording workshops and podcasts, and sharing what I'm learning as I go. These days I'm especially focused on product engineering: the judgment, constraints, and hard questions about what should be built before we start writing code.If you've used Testing Library, taken Epic React or Epic Web, or run into something I wrote about testing, React, or AI-assisted development, you may already know my work. I'm still making more of it.LinkedIn is one of the places I share what I create: articles, courses, podcasts, talks, and whatever I'm experimenting with next. My home base is https://kentcdodds.com and that's the easiest way to find everything in one place.If something here is useful to you, I'm glad! If you want to follow along, you're in the right place.",    "location": {      "displayName": "American Fork, Utah, United States",      "locality": "American Fork, Utah",      "country": "United States",      "countryCode": "US"    },    "industry": {      "publicId": "industry_2hxvE1",      "name": "Information Technology & Services"    },    "counts": {      "followerCount": 14101,      "connectionCount": 1490    },    "experience": [      {        "title": "Software Engineer Educator",        "company": {          "name": "Kent C. Dodds Tech LLC",          "linkedinUrl": "https://www.linkedin.com/company/kentcdodds/",          "employeeCountRange": {            "start": 2,            "end": 10          }        },        "location": "Utah, United States",        "dateRange": {          "start": {            "year": 2019,            "month": 2,            "day": null          },          "end": null,          "isCurrent": true        }      }    ],    "education": [      {        "school": {          "name": "Brigham Young University"        },        "degree": "Master of Science Information Systems (MISM)",        "fieldOfStudy": "Web Development",        "dateRange": {          "start": {            "year": 2007,            "month": null,            "day": null          },          "end": {            "year": 2014,            "month": null,            "day": null          },          "isCurrent": false        }      }    ],    "skills": [      "Web Applications",      "Web Development",      "JavaScript",      "Node.js",      "HTML",      "CSS",      "HTML 5",      "Programming",      "jQuery",      "OS X"    ],    "languages": [      {        "name": "American Sign Language",        "proficiency": "elementary"      },      {        "name": "English",        "proficiency": "nativeOrBilingual"      }    ]  },  "meta": {    "creditCost": 1,    "sections": {      "counts": "complete",      "experience": "complete",      "education": "complete",      "skills": "incomplete",      "certifications": "complete",      "organizations": "complete",      "languages": "complete",      "courses": "incomplete",      "volunteerExperience": "complete",      "honors": "complete"    },    "skills": {      "hasMore": true    }  }}
110 lines

In meta.sections, most sections are complete, but skills and courses are incomplete, and meta.skills.hasMore is true. Long lists like these are cut short in the profile response. To get the full list, use the dedicated endpoint for that section, which supports pagination.

Extract LinkedIn data with Python, JavaScript or TypeScript

EnvoAPI’s SDK lets you call named methods instead of assembling endpoint paths by hand. It also exposes parameters and response fields to your editor, which helps when you’re mapping LinkedIn data into your own schema and speeds up development.

Choose your language and reuse the ENVO_API_KEY you set earlier. Each example makes the same profile request.

Applies to every example on this page

Python

With Python 3.11 or later, install the SDK:

Installbash
python -m pip install envoapi

Pass the profile URL to get_details_by_url(), then read the fields from the returned model:

client.profiles.get_details_by_url()python
import osfrom envoapi import EnvoAPIwith EnvoAPI(api_key=os.environ["ENVO_API_KEY"]) as client:    response = client.profiles.get_details_by_url(        url="https://www.linkedin.com/in/kentcdodds/",    )    profile = response.body.data    print(profile.full_name, profile.headline)

The method reference lists the other lookups you can use in your project. For a longer-running job, save completed responses and record failures separately so you can resume without repeating every successful request.

Extract a name from a LinkedIn profile URL

Use the SDK and API key from the previous section:

Applies to every example on this page

Python

client.profiles.get_details_by_url()python
import osfrom envoapi import EnvoAPIwith EnvoAPI(api_key=os.environ["ENVO_API_KEY"]) as client:    response = client.profiles.get_details_by_url(        url="https://www.linkedin.com/in/kentcdodds/",    )    print(response.body.data.full_name)

Besides the fullName, the same response in body.data includes a headline, location, About text, work experience, education, skills, languages and follower or connection counts. Select the fields your application needs and preserve missing values.

Already have the response? Read its name field directly. There’s no reason to spend another request just to retrieve a value you’ve already saved.

Extract emails and phone numbers from LinkedIn

A complete-looking profile can still have no contact data. We’d work through the available sources: profile text, contact fields, linked websites, then email discovery. We’ll also show where each email comes from, so you know whether it was publicly listed or found through other methods.

Find emails and phone numbers in profile text

People sometimes put contact details in their About text, a description or an experience entry. The following regex looks for email addresses and possible phone numbers in that text. The or [] also handles an experience section returned as null:

client.profiles.get_details_by_url()python
import osimport refrom envoapi import EnvoAPIprofile_url = "https://www.linkedin.com/in/kentcdodds/"with EnvoAPI(api_key=os.environ["ENVO_API_KEY"]) as client:    profile = client.profiles.get_details_by_url(url=profile_url).body.to_dict()["data"]text = "\n".join([profile.get("about") or ""] + [    role.get("description") or "" for role in (profile.get("experience") or [])])emails = sorted(set(re.findall(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+", text)))phone_matches = re.findall(r"(?<!\w)\+?\d[\d(). \t-]{5,}\d(?!\w)", text)phones = sorted({p for p in phone_matches if 7 <= len(re.sub(r"\D", "", p)) <= 15})print({"emails": emails, "phone_candidates": phones})

Check the LinkedIn profile contact details

If the text contains no contact details, try the profile contact endpoint. Public email addresses and phone numbers appear in emailAddress and phoneNumbers; linked sites appear in websites. This snippet continues from the previous example and reuses profile_url:

client.profiles.get_contact_by_url()python
with EnvoAPI(api_key=os.environ["ENVO_API_KEY"]) as client:    contact = client.profiles.get_contact_by_url(url=profile_url).body.to_dict()["data"]print(contact.get("emailAddress"))print(contact.get("phoneNumbers"))print(contact.get("websites"))

Extract email addresses from the profile’s website

Next, check contact["websites"] or the profile’s About text for a personal or business website. Send a request to EnvoAPI’s website email endpoint to find email addresses published on that site. Replace the example URL below:

GET/v1/websites/emailsbash
curl --get 'https://api.envoapi.com/v1/websites/emails' \  --header "Authorization: Bearer $ENVO_API_KEY" \  --data-urlencode 'url=https://example.org/' \  --silent --show-error --fail-with-body

Find a work email using the company domain

The email finder can use a person’s name and employer’s domain to look for a work address. Confirm the current employer and its website first. A LinkedIn company page URL isn’t an email domain. Replace these placeholders:

GET/v1/emails/findbash
curl --get 'https://api.envoapi.com/v1/emails/find' \  --header "Authorization: Bearer $ENVO_API_KEY" \  --data-urlencode 'firstName=FIRST_NAME' \  --data-urlencode 'lastName=LAST_NAME' \  --data-urlencode 'domain=example.com' \  --silent --show-error --fail-with-body

The finder tries common address patterns and checks them with the mail server.

Search for public contact details with ChatGPT or Claude

If you still need another source, use ChatGPT, Claude or another AI assistant with web search enabled. Give it the profile URL, name, employer and known website, then ask for published business contact details with evidence. Here’s a prompt you can adapt:

Search the web for publicly listed business emails or phone numbers associated with [profile URL]. Use [name], [employer] and [website] to check the identity. Return each contact with its source URL and supporting text. Label company switchboards and shared inboxes separately. Do not invent contacts or treat guessed email patterns as confirmed. If you find nothing, say so.

Open the cited pages and check the identity yourself. A similar name or a matching company isn’t enough on its own. Use the assistant to find sources, then decide whether those sources support adding the contact to your record.

Verify email validity, disposable domains and catch-all status

Once you have an email address, use the email verification endpoint. Replace the example with your candidate:

GET/v1/emails/verifybash
curl --get 'https://api.envoapi.com/v1/emails/verify' \  --header "Authorization: Bearer $ENVO_API_KEY" \  --data-urlencode '[email protected]' \  --silent --show-error --fail-with-body

Check data.status and data.reason for the mail-server result, plus isDisposable and isCatchAll. A valid status means the server accepted the address during the check. A catch-all server may accept nonexistent mailboxes too. unverifiable means the check couldn’t confirm the address.

For LinkedIn lead generation, start with a useful definition of a prospect: a job title, a type of business or a specific employer. EnvoAPI’s people and company search endpoints let you build a shortlist before retrieving details. We’d make that filtering step explicit in any sales prospecting workflow.

Find people who match your target

Send a relevant role or phrase to the people search endpoint. This example starts with a job title:

client.profiles.search_by_keyword()python
import osfrom envoapi import EnvoAPIwith EnvoAPI(api_key=os.environ["ENVO_API_KEY"]) as client:    response = client.profiles.search_by_keyword(        keywords="head of marketing", offset=0,    )    print(response.body.to_dict()["data"])

Review the returned profiles against your target role, employer and location. A keyword match is a starting point, so check the current role before keeping a result. Save the LinkedIn URLs for the profile and contact lookups.

Find companies in your target market

If your target is a type of business, start with the company search endpoint:

client.companies.search_by_keyword()python
import osfrom envoapi import EnvoAPIwith EnvoAPI(api_key=os.environ["ENVO_API_KEY"]) as client:    response = client.companies.search_by_keyword(        keywords="software development", offset=0,    )    print(response.body.to_dict()["data"])

Shortlist relevant businesses, then search for people at those companies. Confirm each person’s current employer before joining their profile and company data.

Both examples request the first page. To continue, use meta.paging.nextOffset while hasMore is true, and set a page budget in your own script. The pagination guide explains how to follow the returned values. Don’t assume the first page contains every match.

Turn matching profiles into contactable leads

Once the shortlist is useful, connect it to the steps you’ve already built:

  1. Retrieve each person’s profile details to confirm their name, role and company.
  2. Follow the email and phone lookup steps: profile text, contact details, linked websites, then email discovery if needed.
  3. Check email verification results and keep each contact’s source.
  4. Export the leads to CSV or Excel or save them to your CRM, deduplicating by profile URL.

We’d spend enrichment credits on profiles that match the target, then review contact quality before exporting. For outreach, use the person’s role and company context to make the introduction relevant, follow the rules that apply to your campaign, and honor opt-outs.

Export LinkedIn data to CSV or Excel

Now turn the selected LinkedIn data into something you can review or import into a CRM. We’ll export the same lead records to CSV and Excel, keeping one row per person and the profile URL beside their contact details.

Prepare one row per lead

Use the profile and contact dictionaries from the contact workflow. Create leads once, then append a row after each person’s lookups. If you skipped a contact request, set contact = {} for that person so an earlier result can’t carry over.

python
import jsonleads = []  # Create this list once, before processing your profiles.# Add one row after each profile and contact lookup.leads.append({    "name": profile.get("fullName"),    "headline": profile.get("headline"),    "linkedin_url": profile.get("linkedinUrl"),    "email": contact.get("emailAddress"),    "phone_numbers": json.dumps(contact.get("phoneNumbers") or [], ensure_ascii=False),})

Add any reviewed emails or phone numbers from the other lookup steps to the matching row, together with their source and verification result. Keep missing fields empty. The profile URL gives you a stable reference when two people share the same name.

Save the list as CSV and Excel

Install pandas and the Excel writer. This example uses DataFrame.map, which requires pandas 2.1 or later:

Installbash
python -m pip install 'pandas>=2.1' openpyxl

After collecting your rows, create the DataFrame and write both files:

python
import pandas as pddf = pd.DataFrame(leads).fillna("")# Keep external text as text when opened in a spreadsheet.def spreadsheet_text(value):    if isinstance(value, str) and value.lstrip().startswith(("=", "+", "-", "@")):        return "'" + value    return valuedf = df.map(spreadsheet_text)df.to_csv("leads.csv", index=False, encoding="utf-8-sig")df.to_excel("leads.xlsx", index=False, sheet_name="LinkedIn leads", engine="openpyxl")

You’ll get leads.csv and leads.xlsx in your current directory. index=False removes the extra row-number column; UTF-8 with a BOM helps Excel read the CSV’s text correctly. The text guard prefixes values that could otherwise be interpreted as spreadsheet formulas. Check how your CRM handles those prefixes before importing.

For export options, see the pandas references for CSV and Excel export.

For a larger list, save completed records as you go and deduplicate by profile URL before enrichment. Keeping the responses also lets you rebuild an export with different columns without repeating the data collection.

Scrape LinkedIn jobs: search listings and get job details

If you’re building a job board or tracking hiring, start with a search and retrieve details for the listings worth keeping. This separates discovery from the more detailed job data you’ll use in your application.

Search for jobs by keyword

Use job search to find LinkedIn job listings for a role. The endpoint also supports location and company filters: use a location ID or company IDs in the formats shown in the reference. A company slug isn’t the required company ID.

client.jobs.search()python
import osfrom envoapi import EnvoAPIwith EnvoAPI(api_key=os.environ["ENVO_API_KEY"]) as client:    response = client.jobs.search(keywords="software engineer", start=0)    print(response.body.to_dict()["data"])

Search results include job URLs, titles, companies and locations. Choose a relevant listing and keep its URL for the details request.

Get the job description and application details

Run this example and paste a job URL from the search results. The job details endpoint returns the description and available seniority and application information:

client.jobs.get_details()python
import osfrom envoapi import EnvoAPIwith EnvoAPI(api_key=os.environ["ENVO_API_KEY"]) as client:    response = client.jobs.get_details(url=input("LinkedIn job URL: ").strip())    print(response.body.to_dict()["data"])

Save the listing URL and collection date with the details. That gives you a way to deduplicate jobs and track later changes. Treat each saved listing as a snapshot: the role may close or the page may disappear.

Scrape LinkedIn company data

Company data adds business context to a lead list: which organization the person works for and which account their record belongs to. Search for relevant companies first, then retrieve details for the shortlist.

Find companies by keyword

The company search endpoint takes keywords and supports filters such as location, industry and company size. Start with a phrase that describes your target market:

client.companies.search_by_keyword()python
import osfrom envoapi import EnvoAPIwith EnvoAPI(api_key=os.environ["ENVO_API_KEY"]) as client:    response = client.companies.search_by_keyword(keywords="software development", offset=0)    print(response.body.to_dict()["data"])

Get company details from a LinkedIn URL

Copy a company page URL from the results and paste it below. The company details endpoint returns the available business information for that URL:

client.companies.get_details_by_url()python
import osfrom envoapi import EnvoAPIwith EnvoAPI(api_key=os.environ["ENVO_API_KEY"]) as client:    response = client.companies.get_details_by_url(url=input("LinkedIn company URL: ").strip())    print(response.body.to_dict()["data"])

Use the LinkedIn company URL to join a company record to your lead list. Matching on a name alone can mix up businesses with similar names. From there, find people at the companies you’ve shortlisted and follow the contact workflow.

Scrape LinkedIn posts and engagement data

LinkedIn post scraping can support topic research, company monitoring or a closer look at an industry conversation. Start with the question you want to answer, then collect the post data needed for it. A post’s reaction count and the underlying discussion need different requests.

Find posts by keyword or hashtag

Use post search with a phrase related to your research:

client.search.search_posts_by_keyword()python
import osfrom envoapi import EnvoAPIwith EnvoAPI(api_key=os.environ["ENVO_API_KEY"]) as client:    response = client.search.search_posts_by_keyword(keywords="developer tools", offset=0)    print(response.body.to_dict()["data"])

For a hashtag, use client.search.search_posts_by_hashtag(hashtag="devtools", offset=0). Both methods appear in the SDK method reference.

Retrieve a post and explore its engagement

Paste a matching LinkedIn post URL into the following example. The post details endpoint returns its text, author information and reaction counts:

client.posts.get_by_url()python
import osfrom envoapi import EnvoAPIwith EnvoAPI(api_key=os.environ["ENVO_API_KEY"]) as client:    response = client.posts.get_by_url(url=input("LinkedIn post URL: ").strip())    print(response.body.to_dict()["data"])

To analyze the discussion or individual reactions, use the separate post comments and post reactions endpoints.

Frequently asked questions about LinkedIn scraping

Can LinkedIn detect scraping and ban your account?

Yes. LinkedIn says it continues to improve its defenses against scraping and unauthorized automation. Its policy on prohibited software warns that accounts using those tools may be restricted or shut down. If a scraping tool uses your logged-in LinkedIn account, that account is part of the risk. A Chrome extension is subject to the same policy.

Is it legal to scrape LinkedIn?

There isn’t a universal yes or no. LinkedIn’s terms of service prohibit unauthorized scraping, and legal obligations also depend on your location, access method and use of the data. Scraping publicly available data doesn’t remove privacy obligations. The UK ICO explains that public business contact details can still fall under data protection and direct-marketing rules. Review the requirements for your workflow before collecting or using data at scale.

What data can you scrape from LinkedIn?

Depending on availability and access, a LinkedIn scraper can retrieve profile names, headlines, locations, work experience, education and skills; company details; job listings; and posts with engagement data. Emails and phone numbers aren’t available for every profile. Inspect the actual fields and section status before deciding whether a response contains the data you need.

Does LinkedIn have an official scraping API?

LinkedIn offers APIs for specific integrations. Its Profile API, for example, requires approved access and has permission and data restrictions. Official LinkedIn API access doesn’t provide an unrestricted way to retrieve arbitrary profiles. EnvoAPI is a separate, third-party service — an unofficial LinkedIn API.

Can you scrape email addresses and phone numbers from LinkedIn?

Sometimes. Contact details may be published in profile text or returned by a contact lookup, but many profiles provide none. Follow the contact workflow above to check those fields and linked websites. Keep published addresses separate from inferred candidates, retain their sources and review verification results before using them.

Can you scrape LinkedIn without coding?

Yes. A no-code workflow can send profile URLs to an API through an HTTP request action, map the returned fields and save rows to a spreadsheet or CRM. You still need authentication and handling for missing data. If you choose a Chrome extension or another browser-based LinkedIn scraper, check whether it uses your signed-in session and review the account risks above. You can also use ChatGPT to extract LinkedIn data by connecting it to EnvoAPI through MCP.

How do you scrape LinkedIn using Python?

Use Playwright to open a page and extract visible fields, or call an API SDK to receive structured data. The Python browser example shows the first approach; the EnvoAPI SDK example shows the second. After checking the returned fields, export the results to CSV or Excel.

Can you scrape LinkedIn company pages and job postings?

Yes, subject to availability and access restrictions. With EnvoAPI, search for companies or jobs, then pass a matching URL to the relevant details endpoint. Keep separate schemas for company records and job postings so each export contains fields that make sense for that record type.

  • API engineering
  • LinkedIn API
  • Web scraping
  • Profile enrichment
  • Server-side API keys
  • Job data

EnvoAPI team

Developer relations

Written and reviewed by the engineers and developer-relations staff who build and operate the EnvoAPI data enrichment platform.

Enrich your own records in real time

Run live profile, company and job lookups on your own data. One API key, structured JSON back.

All articles