add linkedin connector + gitignore .claude + regenerate index
This commit is contained in:
@@ -4,3 +4,4 @@ __pycache__/
|
||||
.env
|
||||
secrets/
|
||||
*.egg-info/
|
||||
.claude/
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 |
@@ -0,0 +1 @@
|
||||
mcp-server-linkedin==4.20.1
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user