add linkedin connector + gitignore .claude + regenerate index
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user