#!/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()