inprofile
Officialby boxed-dev
README.md
# inprofile
Read a LinkedIn profile into structured JSON, using the session cookie from the browser you already signed in to. No browser automation, no API keys, no scraping service, no credentials handed to anyone. Everything runs on your machine.
```console
$ inprofile get averystone # abridged; null fields omitted here, not in the output
{
"source_input": "averystone",
"target_url": "https://www.linkedin.com/in/averystone/",
"identity": {
"full_name": "Avery Stone",
"headline": "Chair, Northwind Foundation and Founder, Helio Energy",
"location": "Seattle, Washington, United States",
"about": "Chair of the Northwind Foundation. Founder of Helio Energy. ...",
"profile_url": "https://www.linkedin.com/in/averystone/",
"followers": 1204663
},
"experiences": [
{
"title": "Co-chair",
"company": "Northwind Foundation",
"company_url": "https://www.linkedin.com/company/1122334/",
"start": "2000",
"end": null
}
],
"education": [{ "school": "Ashford University", "start": "1973", "end": "1975" }],
"skills": [],
"languages": [],
"certifications": [],
"unavailable_sections": []
}
```
## Install
Requires Python 3.11 or newer and a LinkedIn account whose interface language is English.
Not on PyPI yet. Install from the repository:
```bash
git clone https://github.com/boxed-dev/inprofile
cd inprofile
uv tool install . # or: uv sync, then prefix commands with `uv run`
inprofile auth # paste your li_at cookie once
```
The only dependencies are `httpx` and `pydantic`. There is no browser to install.
`auth` stores the cookie at `~/.inprofile/cookies.json`, mode `600`. To find it: sign in to LinkedIn in your own browser, open developer tools, and copy the value of the `li_at` cookie for `linkedin.com`. `INPROFILE_LI_AT` overrides the stored file. Repeat only when LinkedIn signs you out.
## Use
### Command line
```bash
inprofile check # is the stored session still signed in?
inprofile get some-username # JSON on stdout
inprofile get https://www.linkedin.com/in/some-username/ --out one.json
inprofile get first-username second-username # several, one session
```
A target is a username or any `linkedin.com/in/...` URL. The names in these examples are invented, as is the sample output above and every test fixture in the repository. `--out` takes a single target.
### Web page
```bash
inprofile ui # serves http://127.0.0.1:8765/ and opens it
```
Enter a target, press Fetch, read or download the JSON. The page is served from localhost only.
### From an AI assistant (MCP)
```bash
uv tool install ".[mcp]" # from the cloned repository
```
```json
{
"mcpServers": {
"inprofile": { "command": "inprofile-mcp" }
}
}
```
Two tools: `get_profile(target)` returns the JSON below, `check_session()` reports whether the session is still valid.
### From Python
```python
import asyncio
from inprofile import Session, scrape_profile
async def main():
async with Session() as session:
profile = await scrape_profile("some-username", session)
print(profile.identity.headline)
for job in profile.experiences:
print(job.title, job.company, job.start, job.end)
asyncio.run(main())
```
`Profile` and everything inside it are Pydantic models, so `profile.model_dump()` and `model_dump_json()` work as expected.
## Output
| Field | Contents |
| --- | --- |
| `source_input`, `target_url` | what you asked for, and the URL it resolved to |
| `identity` | name, pronouns, headline, location, about, photo URL, profile URL, vanity name, followers, connections |
| `experiences` | title, company, company URL, employment type, start, end, duration, location, workplace type, description |
| `education` | school, school URL, degree, field, start, end, description |
| `skills` | list of names |
| `languages` | name, proficiency |
| `certifications` | name, issuer, issuer URL, issued, expires, credential ID, credential URL |
| `unavailable_sections` | names of any sections that failed to load on this read |
Dates are `YYYY` or `YYYY-MM`. An `end` of `null` on an experience means the role is current. Fields LinkedIn does not show are `null`, never guessed. URLs that are not `http` or `https` are dropped rather than passed through.
## Exit codes
| Code | Name | What to do |
| --- | --- | --- |
| 0 | success | |
| 1 | `auth_expired` | run `inprofile auth` with a fresh cookie |
| 2 | `checkpoint` | LinkedIn wants a security challenge solved; sign in with your browser, then run `inprofile auth` |
| 3 | `not_found` | check the username or URL |
| 4 | `network` | retry |
| 5 | `parse_failed` | LinkedIn changed its payload; the message names what could not be read |
| 7 | `rate_limited` | wait for the cap to clear |
## Rate limits
LinkedIn restricts accounts that browse at machine speed, and the account at risk is yours. By default inprofile allows 20 profiles per hour and 100 per day, counted in `~/.inprofile/history.json` and enforced across runs. An attempt counts whether or not it returned a profile, because the requests were made either way. `--no-limit` skips the check; the caps exist to protect your account, so raising them is a decision, not a default.
## How it works
LinkedIn renders profiles with a server-driven UI. The page arrives as a React Flight payload in `window.__como_rehydration__`, which the browser turns into the DOM and then largely discards on hydration — the hydrated DOM is a fraction of what the server sent. inprofile reads that payload directly, so it needs no browser and sees more than the rendered page does. CSS class names are hashed and change on every deploy, so nothing is selected by class: the parser keys on the stable component ids and on document structure.
Experience ships with its page. The remaining sections load through one SDUI pagination endpoint, which inprofile calls the same way the page would, asking for 50 rows rather than the 10 the browser requests first. `flight.py` turns either response back into the HTML shape the parser reads.
A profile is the profile page, `details/experience`, and four pagination calls, spaced by a short random pause. Measured end to end through the CLI, that is roughly 15 seconds.
If a section fails to load, it is left empty and named in `unavailable_sections` rather than failing the whole read. Only the profile page itself is fatal.
```
src/inprofile/
cli.py auth, check, get, ui
client.py the signed-in HTTP session and the SDUI requests
cookies.py the stored li_at cookie
flight.py React Flight payload to HTML
extract.py HTML to Profile
dom.py minimal HTML DOM over html.parser
models.py Pydantic output types
ratelimit.py per-account fetch caps
ui.py, ui.html the local web page
mcp_server.py MCP server (optional extra)
```
## Alternatives
Two larger projects cover this ground, and both do more than inprofile does:
| | inprofile | [linkedin-mcp-server](https://github.com/stickerdaniel/linkedin-mcp-server) | [linkedin_scraper](https://github.com/joeyism/linkedin_scraper) |
|---|---|---|---|
| Stars | new | ~3.3k | ~4.5k |
| Licence | MIT | Apache-2.0 | GPL-3.0 |
| Covers | profiles | profiles, companies, jobs, messaging | profiles, companies, jobs, posts |
| Drives a browser | no | yes (patchright) | yes |
| Can act on your account | no | yes (`send_message`, `connect_with`) | no |
If you need companies, jobs, or messaging, use one of those instead. inprofile is narrower on
purpose, and differs in two ways that are easy to check:
- **No browser.** `httpx` and `pydantic` are the only dependencies. Nothing to install, nothing
to keep up to date with Chrome, and it runs where a browser will not.
- **Read-only by construction.** There is no code path that posts, connects, or messages. The
worst a bug can do to your account is read a profile you did not mean to read.
Reading the server payload rather than the rendered page is also why sections come back complete:
the browser discards most of what the server sent, so a profile with 36 skills returns 36.
## Limitations
Worth knowing before you rely on it:
- **It will break.** LinkedIn changes its payload without notice. When that happens `get` exits 5 and names what it could not read.
- **English only.** The parser reads LinkedIn's own words: month names, `Present`, `Issued`, `followers`. A profile viewed in another interface language will not parse.
- **Tested against a modest set of profiles.** Individual roles, roles grouped under one company, employers without a company page, missing sections, and unknown usernames all have fixtures.
- **50 rows per section.** Each lazy section asks for 50 entries in one call and does not page beyond that, so a profile with more than 50 skills is truncated.
- **Sections not read:** recommendations, volunteering, publications, projects, honours, courses, contact details behind the overlay.
- **Signing in is yours to do.** inprofile never handles your password; it reads the cookie your own browser already holds.
## Development
```bash
uv sync --all-extras
uv run pytest -q
uv run ruff check . && uv run mypy src tests
./run.sh <username> # the above, plus one live read
```
Tests run offline against fixtures in `tests/fixtures/`. Those keep the page structure of real captures, but every name, employer, school, identifier, and image URL in them is invented, so the repository carries nobody's profile. When LinkedIn changes something, `render()` in `flight.py` and the section ids in `extract.py` are where it lands. A page you capture while debugging holds real personal data, so describe the change in an issue rather than attaching it.
## Legal
Read this before you run it. It is not legal advice.
**LinkedIn's User Agreement prohibits what this tool does.** Section 8.2 ("Don'ts"), effective
3 November 2025, says you will not "[d]evelop, 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."
Using this is covered. So is publishing it, and so is contributing to it.
**Do not cite hiQ v. LinkedIn as cover.** That case concerned public profiles visible without
signing in, and it turned on the Computer Fraud and Abuse Act. It did not bless authenticated
automated access, and LinkedIn went on to prevail on its contract claims against hiQ. This tool
runs as a signed-in member, which is the other side of that line.
**The realistic risks are not criminal ones.** After *Van Buren v. United States*, a CFAA claim
over reading pages your own account may already open is unlikely. What actually happens is an
account restriction or termination, a takedown of a repository like this one, or a civil claim
for breach of contract.
**If you keep or forward the output, you take on data-protection duties.** The JSON is another
person's personal data. Under the GDPR that makes you a controller: you need a lawful basis, and
Article 14 requires you to notify the person unless an exception applies. Other jurisdictions
have their own rules. Reading a profile and closing the terminal is not the same as building a
dataset.
**The MCP server sends that personal data to a model.** Whatever provider the calling client
uses receives it. Do not wire this into recruitment screening, candidate ranking, or any other
decision about a person without a lawful basis and a look at your obligations — the EU AI Act
treats employment-related systems as high-risk.
**Use your own account, and only your own.** Section 8.2 also prohibits using "another's account
(such as sharing log-in credentials or copying cookies)". `inprofile auth` is for the cookie of
the account you are signed in to yourself.
**The MIT licence covers the code, not your conduct.** It grants you no permission to breach
LinkedIn's terms. This project is not affiliated with, authorised by, or endorsed by LinkedIn.
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues