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