126 lines
4.9 KiB
Python
126 lines
4.9 KiB
Python
#!/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()
|