From 2ad3954e3adbcae04552486ecce08d1fc58b5613 Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Fri, 7 Aug 2026 13:30:44 +0100 Subject: [PATCH] add linkedin connector + gitignore .claude + regenerate index --- .gitignore | 1 + connectors/connectors.json | 146 +++++++++++++++++++++++++++ connectors/index.json | 2 +- connectors/linkedin/connector.json | 134 ++++++++++++++++++++++++ connectors/linkedin/fragment.json | 52 ++++++++++ connectors/linkedin/icon_lg.png | Bin 0 -> 2840 bytes connectors/linkedin/icon_sm.png | Bin 0 -> 1597 bytes connectors/linkedin/requirements.txt | 1 + connectors/linkedin/server.py | 114 +++++++++++++++++++++ connectors/linkedin/session.py | 130 ++++++++++++++++++++++++ connectors/linkedin/verify.py | 125 +++++++++++++++++++++++ 11 files changed, 704 insertions(+), 1 deletion(-) create mode 100644 connectors/linkedin/connector.json create mode 100644 connectors/linkedin/fragment.json create mode 100644 connectors/linkedin/icon_lg.png create mode 100644 connectors/linkedin/icon_sm.png create mode 100644 connectors/linkedin/requirements.txt create mode 100644 connectors/linkedin/server.py create mode 100644 connectors/linkedin/session.py create mode 100644 connectors/linkedin/verify.py diff --git a/.gitignore b/.gitignore index c4c33b1..e21ac5f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ .env secrets/ *.egg-info/ +.claude/ \ No newline at end of file diff --git a/connectors/connectors.json b/connectors/connectors.json index 6100f2f..c672ba8 100644 --- a/connectors/connectors.json +++ b/connectors/connectors.json @@ -1069,6 +1069,152 @@ "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 + } + ] } ] } diff --git a/connectors/index.json b/connectors/index.json index 9f06ed2..65f803d 100644 --- a/connectors/index.json +++ b/connectors/index.json @@ -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"] diff --git a/connectors/linkedin/connector.json b/connectors/linkedin/connector.json new file mode 100644 index 0000000..c1074d2 --- /dev/null +++ b/connectors/linkedin/connector.json @@ -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" +} diff --git a/connectors/linkedin/fragment.json b/connectors/linkedin/fragment.json new file mode 100644 index 0000000..8cf3c79 --- /dev/null +++ b/connectors/linkedin/fragment.json @@ -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" } + ] +} diff --git a/connectors/linkedin/icon_lg.png b/connectors/linkedin/icon_lg.png new file mode 100644 index 0000000000000000000000000000000000000000..1c43068fc9c35018cf74f42fda92a4276b53d1b1 GIT binary patch literal 2840 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 literal 0 HcmV?d00001 diff --git a/connectors/linkedin/requirements.txt b/connectors/linkedin/requirements.txt new file mode 100644 index 0000000..d6b9ecd --- /dev/null +++ b/connectors/linkedin/requirements.txt @@ -0,0 +1 @@ +mcp-server-linkedin==4.20.1 diff --git a/connectors/linkedin/server.py b/connectors/linkedin/server.py new file mode 100644 index 0000000..b754da8 --- /dev/null +++ b/connectors/linkedin/server.py @@ -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() diff --git a/connectors/linkedin/session.py b/connectors/linkedin/session.py new file mode 100644 index 0000000..1dc9b88 --- /dev/null +++ b/connectors/linkedin/session.py @@ -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 diff --git a/connectors/linkedin/verify.py b/connectors/linkedin/verify.py new file mode 100644 index 0000000..9ea2e44 --- /dev/null +++ b/connectors/linkedin/verify.py @@ -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()