add linkedin connector + gitignore .claude + regenerate index

This commit is contained in:
2026-08-07 13:30:44 +01:00
parent c778450812
commit 2ad3954e3a
11 changed files with 704 additions and 1 deletions
+1
View File
@@ -4,3 +4,4 @@ __pycache__/
.env .env
secrets/ secrets/
*.egg-info/ *.egg-info/
.claude/
+146
View File
@@ -1069,6 +1069,152 @@
"size": 831 "size": 831
} }
] ]
},
{
"id": "linkedin",
"name": "LinkedIn",
"type": "mcp_local",
"scope": "user",
"icon_small": "linkedin/icon_sm.png",
"icon_large": "linkedin/icon_lg.png",
"user_description": "Read and act on LinkedIn through your own session \u2014 people, companies, jobs, feed and messages. Authenticates from a session cookie you paste into the settings; no API key and no browser window on the server.",
"requires": [
"PYTHON",
"ENV"
],
"tags": [
"linkedin",
"mcp",
"local",
"social",
"jobs",
"messaging",
"browser"
],
"auth": {
"type": "api_key",
"delivery": "env",
"param": "LINKEDIN_LI_AT"
},
"folder": "linkedin",
"version": 1,
"version_string": "1.0.0",
"version_release_date": "2026-08-07",
"tools": [
{
"name": "get_person_profile",
"display_name": "Get Person Profile"
},
{
"name": "get_my_profile",
"display_name": "Get My Profile"
},
{
"name": "search_people",
"display_name": "Search People"
},
{
"name": "get_sidebar_profiles",
"display_name": "Get Suggested Profiles"
},
{
"name": "connect_with_person",
"display_name": "Send Connection Request"
},
{
"name": "get_company_profile",
"display_name": "Get Company Profile"
},
{
"name": "search_companies",
"display_name": "Search Companies"
},
{
"name": "get_company_posts",
"display_name": "Get Company Posts"
},
{
"name": "get_company_employees",
"display_name": "List Company Employees"
},
{
"name": "search_jobs",
"display_name": "Search Jobs"
},
{
"name": "get_job_details",
"display_name": "Get Job Details"
},
{
"name": "get_saved_jobs",
"display_name": "Get Saved Jobs"
},
{
"name": "get_feed",
"display_name": "Get Feed"
},
{
"name": "search_posts",
"display_name": "Search Posts"
},
{
"name": "get_inbox",
"display_name": "Get Inbox"
},
{
"name": "get_conversation",
"display_name": "Read Conversation"
},
{
"name": "search_conversations",
"display_name": "Search Messages"
},
{
"name": "send_message",
"display_name": "Send Message"
},
{
"name": "close_session",
"display_name": "Close Browser Session"
}
],
"files": [
{
"path": "connector.json",
"sha256": "2fa9fb40b15864dd1dc4ecf75ca650a59cc4a808731d914ffb2d37278e52213e",
"size": 6485
},
{
"path": "icon_lg.png",
"sha256": "54246135fc267167d018a9f81b4e1da012b149cb9b05562bf8eb447fb2b620e0",
"size": 2840
},
{
"path": "icon_sm.png",
"sha256": "aa5dca3099fb21b9384c73acc62fd8e54205de4ecb84d18affaff4884e7f768b",
"size": 1597
},
{
"path": "requirements.txt",
"sha256": "a38767c14838a847e6e960287f00646cd6e4cbe3eac2313ea7112491f5c0747b",
"size": 28
},
{
"path": "server.py",
"sha256": "94586e678b5c7dcd100d51b857ebe61e68c8958b640108b693b06a4a7dceab36",
"size": 4590
},
{
"path": "session.py",
"sha256": "26ebd102183b788b1317df470a16c849e2f7a2596d685307853c7caa9c1d6fca",
"size": 5280
},
{
"path": "verify.py",
"sha256": "4c0969bfda28754eec8954ef85fd860a56a5bb9a063b2e31e535a4f0b7e5cb6c",
"size": 5021
}
]
} }
] ]
} }
+1 -1
View File
@@ -1 +1 @@
["gmail", "gcal", "drive", "email", "exa", "firecrawl", "http-fetch", "serpapi-flights", "ssh", "tavily", "weather", "whatsapp", "wikipedia", "context7", "gmaps", "google-trends"] ["gmail", "gcal", "drive", "email", "exa", "firecrawl", "http-fetch", "serpapi-flights", "ssh", "tavily", "weather", "whatsapp", "wikipedia", "context7", "gmaps", "google-trends", "linkedin"]
+134
View File
@@ -0,0 +1,134 @@
{
"id": "linkedin",
"name": "LinkedIn",
"type": "mcp_local",
"scope": "user",
"launch_command": "python3 server.py",
"transport": "stdio",
"requires": [
"PYTHON",
"ENV"
],
"dependencies": [
"mcp-server-linkedin==4.20.1"
],
"env": [
{
"name": "LINKEDIN_LI_AT",
"label": "LinkedIn session cookie (li_at)",
"description": "Sign in to LinkedIn in your browser, then open DevTools > Application > Cookies > https://www.linkedin.com and copy the VALUE of the li_at cookie. This is a full credential for your account: anyone holding it is signed in as you. Signing out of that browser invalidates it.",
"required": true,
"secret": true,
"example": "AQEDAT...."
},
{
"name": "LINKEDIN_USER_AGENT",
"label": "Browser user agent (recommended)",
"description": "The user agent of the browser you copied the cookie from — paste what navigator.userAgent prints in that browser's console. LinkedIn ties a session to the browser fingerprint it was issued to, so matching it makes the session far less likely to be challenged.",
"required": false,
"secret": false,
"example": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
},
{
"name": "LINKEDIN_JSESSIONID",
"label": "JSESSIONID cookie (optional)",
"description": "The JSESSIONID cookie from the same browser. Not required — supply it only if some actions are refused without it.",
"required": false,
"secret": true,
"example": "ajax:1234567890123456789"
},
{
"name": "LINKEDIN_TOOL_TIMEOUT",
"label": "Tool timeout (seconds)",
"description": "Max time one LinkedIn tool call may run before being aborted. Page scrapes are slower than API calls; raise this if searches time out. Default: 180.",
"required": false,
"secret": false,
"default": "180",
"example": "180"
},
{
"name": "LINKEDIN_LOG_LEVEL",
"label": "Log level",
"description": "Verbosity of the server's own diagnostics on stderr. Raise to DEBUG when troubleshooting a session that will not authenticate. Default: WARNING.",
"required": false,
"secret": false,
"default": "WARNING",
"example": "WARNING"
}
],
"verify": {
"command": "python3 verify.py",
"timeout_secs": 30
},
"setup_instructions": [
"Skald installs the dependencies automatically (pip install -r requirements.txt).",
"Sign in to LinkedIn in your normal browser.",
"Open DevTools (F12) > Application > Cookies > https://www.linkedin.com, find li_at and copy its value into the form. Copy the value only, without the 'li_at=' prefix.",
"Recommended: in the same browser's console run navigator.userAgent and paste the result into the user agent field, so the session is replayed under the fingerprint it was issued to.",
"Skald checks the cookie against LinkedIn before saving; an expired or mistyped cookie is rejected while the form is still open.",
"On the first tool call the connector downloads its own Chromium (~350 MB) into browsers/ and replays the session into a browser profile derived for this machine.",
"The cookie is invalidated when you sign out of the browser you copied it from, and expires on its own after some months — paste a fresh one when tools start failing to authenticate."
],
"docs": [
{
"lang": "en",
"description": "Read and act on LinkedIn through your own logged-in session: people and company profiles, job search and details, saved jobs, the feed, post search, and the full messaging inbox (read, search and send). Runs a real headless Chromium against linkedin.com rather than an API, so it sees exactly what you see, and authenticates from a session cookie you paste into the settings — no browser window ever opens on the server. Note that automating LinkedIn is against its Terms of Service and carries a real risk of account restriction; use an account you are willing to lose.",
"llm_short_description": "LinkedIn — search and read people, companies, jobs and posts, browse the feed, and read, search and send messages, all through the user's own LinkedIn session."
}
],
"tools": [
{ "name": "get_person_profile", "display_name": "Get Person Profile" },
{ "name": "get_my_profile", "display_name": "Get My Profile" },
{ "name": "search_people", "display_name": "Search People" },
{ "name": "get_sidebar_profiles", "display_name": "Get Suggested Profiles" },
{ "name": "connect_with_person", "display_name": "Send Connection Request" },
{ "name": "get_company_profile", "display_name": "Get Company Profile" },
{ "name": "search_companies", "display_name": "Search Companies" },
{ "name": "get_company_posts", "display_name": "Get Company Posts" },
{ "name": "get_company_employees", "display_name": "List Company Employees" },
{ "name": "search_jobs", "display_name": "Search Jobs" },
{ "name": "get_job_details", "display_name": "Get Job Details" },
{ "name": "get_saved_jobs", "display_name": "Get Saved Jobs" },
{ "name": "get_feed", "display_name": "Get Feed" },
{ "name": "search_posts", "display_name": "Search Posts" },
{ "name": "get_inbox", "display_name": "Get Inbox" },
{ "name": "get_conversation", "display_name": "Read Conversation" },
{ "name": "search_conversations", "display_name": "Search Messages" },
{ "name": "send_message", "display_name": "Send Message" },
{ "name": "close_session", "display_name": "Close Browser Session" }
],
"mcp_config": {
"command": "python3",
"args": [
"server.py"
],
"transport": "stdio",
"env": {
"LINKEDIN_LI_AT": "{SECRET:LINKEDIN_LI_AT}",
"LINKEDIN_JSESSIONID": "{SECRET:LINKEDIN_JSESSIONID}",
"LINKEDIN_USER_AGENT": "{ENV:LINKEDIN_USER_AGENT}",
"TOOL_TIMEOUT": "{ENV:LINKEDIN_TOOL_TIMEOUT}",
"LOG_LEVEL": "{ENV:LINKEDIN_LOG_LEVEL}"
}
},
"auth": {
"type": "api_key",
"delivery": "env",
"param": "LINKEDIN_LI_AT"
},
"homepage": "https://github.com/stickerdaniel/linkedin-mcp-server",
"icon_small": "icon_sm.png",
"icon_large": "icon_lg.png",
"tags": [
"linkedin",
"mcp",
"local",
"social",
"jobs",
"messaging",
"browser"
],
"version": 1,
"version_string": "1.0.0",
"version_release_date": "2026-08-07"
}
+52
View File
@@ -0,0 +1,52 @@
{
"id": "linkedin",
"name": "LinkedIn",
"type": "mcp_local",
"scope": "user",
"icon_small": "linkedin/icon_sm.png",
"icon_large": "linkedin/icon_lg.png",
"user_description": "Read and act on LinkedIn through your own session — people, companies, jobs, feed and messages. Authenticates from a session cookie you paste into the settings; no API key and no browser window on the server.",
"requires": [
"PYTHON",
"ENV"
],
"tags": [
"linkedin",
"mcp",
"local",
"social",
"jobs",
"messaging",
"browser"
],
"auth": {
"type": "api_key",
"delivery": "env",
"param": "LINKEDIN_LI_AT"
},
"folder": "linkedin",
"version": 1,
"version_string": "1.0.0",
"version_release_date": "2026-08-07",
"tools": [
{ "name": "get_person_profile", "display_name": "Get Person Profile" },
{ "name": "get_my_profile", "display_name": "Get My Profile" },
{ "name": "search_people", "display_name": "Search People" },
{ "name": "get_sidebar_profiles", "display_name": "Get Suggested Profiles" },
{ "name": "connect_with_person", "display_name": "Send Connection Request" },
{ "name": "get_company_profile", "display_name": "Get Company Profile" },
{ "name": "search_companies", "display_name": "Search Companies" },
{ "name": "get_company_posts", "display_name": "Get Company Posts" },
{ "name": "get_company_employees", "display_name": "List Company Employees" },
{ "name": "search_jobs", "display_name": "Search Jobs" },
{ "name": "get_job_details", "display_name": "Get Job Details" },
{ "name": "get_saved_jobs", "display_name": "Get Saved Jobs" },
{ "name": "get_feed", "display_name": "Get Feed" },
{ "name": "search_posts", "display_name": "Search Posts" },
{ "name": "get_inbox", "display_name": "Get Inbox" },
{ "name": "get_conversation", "display_name": "Read Conversation" },
{ "name": "search_conversations", "display_name": "Search Messages" },
{ "name": "send_message", "display_name": "Send Message" },
{ "name": "close_session", "display_name": "Close Browser Session" }
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

+1
View File
@@ -0,0 +1 @@
mcp-server-linkedin==4.20.1
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Skald launcher for the LinkedIn MCP connector.
The server itself is the upstream `mcp-server-linkedin` package (pinned in
requirements.txt); this file exists because a marketplace connector must ship an
entry file for `mcp_config.args[0]` to name. It does two things the package
cannot do for itself on a headless box:
1. Pins the paths that must live inside the connector directory. Skald re-copies
a connector's shipped files on every update but never deletes anything else,
so state written here survives both updates and container recreates — which
the upstream defaults (`~/.linkedin-mcp`, `~/.cache/ms-playwright`) do not.
2. Materializes the session bundle from the cookie the user pasted into the
configuration form (see session.py). Upstream expects that bundle to be
produced by signing in through a visible browser and copied over by hand;
there is no display here, so we build it from the form instead.
stdout belongs to JSON-RPC. Everything this script says goes to stderr.
"""
import os
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
# The package derives its auth root from the *parent* of USER_DATA_DIR, so
# pointing the profile at `auth/profile` puts `cookies.json`, `source-state.json`
# and the derived `runtime-profiles/` together under `auth/`.
AUTH_DIR = HERE / "auth"
# Chromium is ~350 MB and is NOT shipped: patchright downloads its own pinned
# build on first use. Keeping it here means it survives updates instead of being
# re-fetched into every fresh container.
BROWSERS_DIR = HERE / "browsers"
# Records which credential the current bundle was built from, so a browser
# session is only rebuilt when the user actually pastes a new cookie.
STAMP = AUTH_DIR / ".credential"
def log(message: str) -> None:
print(f"[linkedin] {message}", file=sys.stderr, flush=True)
def sync_session() -> None:
"""Rebuild the session bundle when the configured cookie has changed.
Rewriting on every start would be wasteful but harmless; the reason to guard
it is that a rewrite mints a new `login_generation`, which discards the
runtime profile the package built from the previous one — throwing away a
warmed-up, LinkedIn-accepted session for no reason.
"""
from session import fingerprint, read_config, write_bundle
li_at, jsessionid, user_agent = read_config()
if not li_at:
log("no li_at cookie configured — set it in the connector's settings")
log("(copy it from your browser: DevTools > Application > Cookies > linkedin.com)")
return
current = fingerprint(li_at, jsessionid, user_agent)
previous = STAMP.read_text().strip() if STAMP.is_file() else ""
if current == previous:
return
write_bundle(AUTH_DIR, li_at, jsessionid, user_agent)
STAMP.write_text(current + "\n")
os.chmod(STAMP, 0o600)
log("session bundle written from the configured cookie" if not previous
else "cookie changed — session bundle rebuilt")
def main() -> None:
# Skald puts `.pydeps` on PYTHONPATH when it launches this connector; adding
# it here too keeps the script runnable by hand for debugging.
pydeps = HERE / ".pydeps"
if pydeps.is_dir() and str(pydeps) not in sys.path:
sys.path.insert(0, str(pydeps))
if str(HERE) not in sys.path:
sys.path.insert(0, str(HERE))
# setdefault throughout: an operator overriding any of these wins.
os.environ.setdefault("USER_DATA_DIR", str(AUTH_DIR / "profile"))
os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", str(BROWSERS_DIR))
# No display here. Headless also keeps the download to the headless shell
# alone; full Chrome for Testing is only fetched for a headed run.
os.environ.setdefault("HEADLESS", "true")
os.environ.setdefault("TRANSPORT", "stdio")
# Replay the pasted cookie under the user agent it was minted with, when we
# know it — LinkedIn associates a session with its browser fingerprint.
_, _, user_agent = __import__("session").read_config()
if user_agent:
os.environ.setdefault("USER_AGENT", user_agent)
try:
sync_session()
except Exception as exc: # noqa: BLE001 - never block startup on this
log(f"could not write the session bundle: {exc}")
try:
from linkedin_mcp_server.cli_main import main as server_main
except ImportError as exc:
log(f"dependencies missing ({exc}); expected mcp-server-linkedin in .pydeps")
raise
server_main()
if __name__ == "__main__":
main()
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Turn a pasted LinkedIn cookie into the session bundle the MCP server expects.
The upstream `mcp-server-linkedin` package authenticates from a browser session,
normally created by running it on a machine with a display. That is not an option
on a headless box, so this connector collects the session cookie through Skald's
configuration form instead and synthesizes the same bundle here.
The bundle is exactly what the package calls a *source session*, the one-time
bridge it uses to carry a session onto a machine that cannot log in for itself:
auth/cookies.json the LinkedIn cookies, in Playwright's cookie shape
auth/source-state.json which runtime minted them, and under which UA
auth/profile/ the source profile directory (only its existence
is checked; the real browser profile is derived
per-runtime by the package on first use)
Writing files is all this does — no browser is launched here, so the connector
starts instantly and the (large) Chromium download stays on the first tool call.
Shared by server.py (at startup) and verify.py (at activation).
"""
from __future__ import annotations
import hashlib
import json
import os
import time
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
# LinkedIn's session cookie. Everything else in the jar is optional: the package
# proves the session against /feed/ and lets the browser rebuild the rest.
LI_AT = "li_at"
# `source_runtime_id` marks which machine minted the session. It must NOT match
# the runtime that reads it, or the package assumes the profile is native and
# skips the replay that actually installs these cookies. A cookie pasted from a
# desktop browser is, by definition, foreign to this container.
FOREIGN_RUNTIME_ID = "browser-import-host"
def _utcnow_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def cookie_jar(li_at: str, jsessionid: str = "") -> list[dict]:
"""Build the cookie list. Mirrors what the package exports from a real login."""
# A year out: LinkedIn's own li_at lifetime. The value is advisory — LinkedIn
# decides what is still valid — but an already-expired cookie is dropped by
# the browser before it is ever sent.
expires = time.time() + 365 * 24 * 3600
jar = [{
"name": LI_AT,
"value": li_at,
"domain": ".www.linkedin.com",
"path": "/",
"expires": expires,
"httpOnly": True,
"secure": True,
"sameSite": "None",
}]
if jsessionid:
# LinkedIn quotes this one in the header; keep the quotes if present.
value = jsessionid if jsessionid.startswith('"') else f'"{jsessionid}"'
jar.append({
"name": "JSESSIONID",
"value": value,
"domain": ".www.linkedin.com",
"path": "/",
"expires": expires,
"httpOnly": False,
"secure": True,
"sameSite": "None",
})
return jar
def fingerprint(li_at: str, jsessionid: str, user_agent: str) -> str:
"""Identify the credential, so a rewrite happens only when it really changed.
Hashed rather than stored: this lands in a file inside the connector
directory, and the cookie is as good as a password.
"""
h = hashlib.sha256()
for part in (li_at, jsessionid, user_agent):
h.update(part.encode())
h.update(b"\0")
return h.hexdigest()
def write_bundle(auth_dir: Path, li_at: str, jsessionid: str = "",
user_agent: str = "") -> None:
"""Write the session bundle into `auth_dir`, replacing any previous one."""
profile_dir = auth_dir / "profile"
profile_dir.mkdir(parents=True, exist_ok=True)
# `profile_exists` treats an empty directory as absent, so leave a marker.
(profile_dir / ".skald-import").write_text(_utcnow_iso() + "\n")
cookies_path = auth_dir / "cookies.json"
cookies_path.write_text(json.dumps(cookie_jar(li_at, jsessionid), indent=2))
state = {
"version": 1,
"source_runtime_id": FOREIGN_RUNTIME_ID,
"login_generation": str(uuid4()),
"created_at": _utcnow_iso(),
"profile_path": str(profile_dir),
"cookies_path": str(cookies_path),
# LinkedIn ties a session token to the fingerprint it was minted under,
# so the runtime browser replays it under the source browser's UA when
# we know it. None lets the package keep its own default.
"user_agent": user_agent or None,
}
(auth_dir / "source-state.json").write_text(json.dumps(state, indent=2))
# The cookie is a bearer credential for the whole account.
for p in (cookies_path, auth_dir / "source-state.json"):
os.chmod(p, 0o600)
def read_config(env: dict | None = None) -> tuple[str, str, str]:
"""Pull the form values out of the environment. Returns (li_at, jsessionid, ua)."""
e = env if env is not None else os.environ
li_at = (e.get("LINKEDIN_LI_AT") or "").strip().strip('"')
jsessionid = (e.get("LINKEDIN_JSESSIONID") or "").strip()
user_agent = (e.get("LINKEDIN_USER_AGENT") or "").strip()
return li_at, jsessionid, user_agent
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Verify-before-save probe for the LinkedIn connector.
Skald runs this after the user fills the form and before the activation is
persisted, so a cookie that was copied wrong is rejected while the form is still
on screen rather than failing later inside a browser session.
Deliberately stdlib-only and browser-free: the connector's Chromium is ~350 MB
and is not downloaded until the first tool call, which no verify timeout would
survive. A plain authenticated GET answers the only question that matters here —
does LinkedIn still accept this cookie.
LinkedIn answers that question with three distinguishable behaviours on /feed/,
measured rather than assumed:
no cookie at all 302 chain ending on /uas/login
cookie rejected 302 from /feed/ to /feed/ — an endless self-redirect, which
is LinkedIn trying and failing to re-establish the session
cookie accepted 200 with the feed
Prints one JSON object on stdout, nothing else. Never echoes the cookie.
"""
from __future__ import annotations
import http.cookiejar
import json
import sys
import urllib.error
import urllib.request
from session import read_config
PROBE_URL = "https://www.linkedin.com/feed/"
# Substrings that mean LinkedIn served a signed-out page instead of the feed.
SIGNED_OUT_MARKERS = ("/login", "/uas/login", "/authwall", "/checkpoint", "signup")
DEFAULT_UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
)
def out(ok: bool, message: str, **details: object) -> None:
payload: dict = {"ok": ok, "message": message}
if details:
payload["details"] = details
print(json.dumps(payload))
sys.exit(0 if ok else 1)
def build_opener(li_at: str, jsessionid: str) -> urllib.request.OpenerDirector:
"""An opener carrying the session cookies, behaving like a browser."""
jar = http.cookiejar.CookieJar()
def add(name: str, value: str) -> None:
jar.set_cookie(http.cookiejar.Cookie(
version=0, name=name, value=value, port=None, port_specified=False,
domain=".linkedin.com", domain_specified=True, domain_initial_dot=True,
path="/", path_specified=True, secure=True, expires=None,
discard=False, comment=None, comment_url=None, rest={}, rfc2109=False,
))
add("li_at", li_at)
if jsessionid:
add("JSESSIONID", jsessionid if jsessionid.startswith('"') else f'"{jsessionid}"')
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
def main() -> None:
li_at, jsessionid, user_agent = read_config()
if not li_at:
out(False, "No li_at cookie provided.")
# A real li_at is a long opaque token. Catching the obvious paste mistakes
# here gives a far better message than LinkedIn's redirect would.
if li_at.lower().startswith("li_at="):
out(False, "Paste only the cookie value, not the 'li_at=' prefix.")
if len(li_at) < 20:
out(False, f"That li_at value looks too short ({len(li_at)} chars) to be a session cookie.")
request = urllib.request.Request(PROBE_URL, headers={
"User-Agent": user_agent or DEFAULT_UA,
"Accept": "text/html,application/xhtml+xml",
"Accept-Language": "en-US,en;q=0.9",
})
try:
with build_opener(li_at, jsessionid).open(request, timeout=20) as response:
status = response.status
final_url = response.geturl()
except urllib.error.HTTPError as exc:
# urllib raises here when a redirect chain does not terminate. LinkedIn
# sends /feed/ back to itself for a session it will not accept, so this
# is the invalid-cookie case rather than a transport failure.
if exc.code in (301, 302, 303, 307, 308):
out(False, "LinkedIn rejected this cookie — it is expired, or was "
"invalidated by signing out of the browser it came from.")
if exc.code == 999:
# LinkedIn's anti-automation status. It says nothing about the cookie.
out(False, "LinkedIn refused the probe (HTTP 999) without checking the "
"cookie. Wait a moment and try again.")
out(False, f"LinkedIn returned HTTP {exc.code}.")
except urllib.error.URLError as exc:
out(False, f"Could not reach LinkedIn: {exc.reason}")
except Exception as exc: # noqa: BLE001 - the probe must never crash the form
out(False, f"Probe failed: {exc}")
if any(marker in final_url for marker in SIGNED_OUT_MARKERS):
out(False, "LinkedIn served the sign-in page — the cookie was not accepted.")
if status == 200:
out(
True,
"LinkedIn session cookie is valid.",
user_agent_pinned=bool(user_agent),
jsessionid_provided=bool(jsessionid),
)
out(False, f"LinkedIn returned HTTP {status} instead of the feed.")
if __name__ == "__main__":
main()