async_linkedinlib 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- async_linkedinlib/__init__.py +187 -0
- async_linkedinlib/__main__.py +3 -0
- async_linkedinlib/auth/__init__.py +5 -0
- async_linkedinlib/auth/service.py +77 -0
- async_linkedinlib/cli.py +37 -0
- async_linkedinlib/client.py +93 -0
- async_linkedinlib/config/__init__.py +6 -0
- async_linkedinlib/config/options.py +15 -0
- async_linkedinlib/config/profile.py +56 -0
- async_linkedinlib/exceptions.py +75 -0
- async_linkedinlib/models/__init__.py +155 -0
- async_linkedinlib/models/_json.py +65 -0
- async_linkedinlib/models/comments.py +145 -0
- async_linkedinlib/models/content.py +207 -0
- async_linkedinlib/models/feed.py +124 -0
- async_linkedinlib/models/jobs.py +215 -0
- async_linkedinlib/models/jobs_apply.py +424 -0
- async_linkedinlib/models/member.py +14 -0
- async_linkedinlib/models/pagination.py +52 -0
- async_linkedinlib/models/posts.py +141 -0
- async_linkedinlib/models/posts_results.py +120 -0
- async_linkedinlib/models/search.py +307 -0
- async_linkedinlib/models/search_filters.py +339 -0
- async_linkedinlib/models/search_jobs.py +213 -0
- async_linkedinlib/py.typed +0 -0
- async_linkedinlib/resources/__init__.py +12 -0
- async_linkedinlib/resources/account.py +35 -0
- async_linkedinlib/resources/comments/__init__.py +5 -0
- async_linkedinlib/resources/comments/resource.py +227 -0
- async_linkedinlib/resources/comments/urns.py +43 -0
- async_linkedinlib/resources/feed/__init__.py +5 -0
- async_linkedinlib/resources/feed/queries.py +57 -0
- async_linkedinlib/resources/feed/resource.py +225 -0
- async_linkedinlib/resources/feed/saved.py +49 -0
- async_linkedinlib/resources/feed/urns.py +68 -0
- async_linkedinlib/resources/jobs/__init__.py +5 -0
- async_linkedinlib/resources/jobs/answers.py +239 -0
- async_linkedinlib/resources/jobs/resource.py +212 -0
- async_linkedinlib/resources/jobs/upload.py +78 -0
- async_linkedinlib/resources/mentions.py +58 -0
- async_linkedinlib/resources/paging.py +88 -0
- async_linkedinlib/resources/posts/__init__.py +6 -0
- async_linkedinlib/resources/posts/media.py +173 -0
- async_linkedinlib/resources/posts/payloads.py +98 -0
- async_linkedinlib/resources/posts/resource.py +254 -0
- async_linkedinlib/resources/reactions.py +89 -0
- async_linkedinlib/resources/search/__init__.py +5 -0
- async_linkedinlib/resources/search/queries.py +79 -0
- async_linkedinlib/resources/search/results.py +85 -0
- async_linkedinlib/resources/search/search.py +284 -0
- async_linkedinlib/resources/threads.py +30 -0
- async_linkedinlib/session/__init__.py +5 -0
- async_linkedinlib/session/cookies.py +52 -0
- async_linkedinlib/session/lease.py +30 -0
- async_linkedinlib/session/state.py +129 -0
- async_linkedinlib/transport/__init__.py +6 -0
- async_linkedinlib/transport/http.py +183 -0
- async_linkedinlib/transport/restli.py +46 -0
- async_linkedinlib/transport/voyager.py +151 -0
- async_linkedinlib-0.2.0.dist-info/METADATA +478 -0
- async_linkedinlib-0.2.0.dist-info/RECORD +64 -0
- async_linkedinlib-0.2.0.dist-info/WHEEL +4 -0
- async_linkedinlib-0.2.0.dist-info/entry_points.txt +2 -0
- async_linkedinlib-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Scoped cookies and atomic, private session persistence."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import asdict
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from dataclasses import field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from ..config import ClientProfile
|
|
16
|
+
from ..exceptions import SessionError
|
|
17
|
+
from .cookies import SessionCookie
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True, repr=False)
|
|
21
|
+
class Session:
|
|
22
|
+
"""Local credential presence is not proof that LinkedIn still accepts the session."""
|
|
23
|
+
|
|
24
|
+
device_id: str = field(default_factory=lambda: str(uuid4()))
|
|
25
|
+
profile: ClientProfile = field(default_factory=ClientProfile)
|
|
26
|
+
cookies: list[SessionCookie] = field(default_factory=list)
|
|
27
|
+
member_id: str | None = None
|
|
28
|
+
rate_limit_until: float = 0.0
|
|
29
|
+
|
|
30
|
+
def __repr__(self) -> str:
|
|
31
|
+
return f"Session(authenticated={self.authenticated})"
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def authenticated(self) -> bool:
|
|
35
|
+
return bool(self.cookie_value("li_at") and self.cookie_value("JSESSIONID"))
|
|
36
|
+
|
|
37
|
+
def cookie_value(self, name: str, path: str = "/") -> str | None:
|
|
38
|
+
valid = [
|
|
39
|
+
cookie
|
|
40
|
+
for cookie in self.cookies
|
|
41
|
+
if cookie.name == name
|
|
42
|
+
and (cookie.expires is None or cookie.expires > time.time())
|
|
43
|
+
and (path == cookie.path or path.startswith(cookie.path.rstrip("/") + "/"))
|
|
44
|
+
and (not cookie.host_only or cookie.domain == "www.linkedin.com")
|
|
45
|
+
]
|
|
46
|
+
if not valid:
|
|
47
|
+
return None
|
|
48
|
+
# Prefer the most specific matching cookie, including a host-specific cookie.
|
|
49
|
+
cookie = max(valid, key=lambda c: (len(c.path), len(c.domain.lstrip("."))))
|
|
50
|
+
return cookie.value
|
|
51
|
+
|
|
52
|
+
def clear_authentication(self) -> None:
|
|
53
|
+
self.cookies = [c for c in self.cookies if c.name != "li_at"]
|
|
54
|
+
self.member_id = None
|
|
55
|
+
|
|
56
|
+
def to_httpx(self) -> httpx.Cookies:
|
|
57
|
+
jar = httpx.Cookies()
|
|
58
|
+
for cookie in self.cookies:
|
|
59
|
+
jar.jar.set_cookie(cookie.to_cookie())
|
|
60
|
+
return jar
|
|
61
|
+
|
|
62
|
+
def update_cookies(self, jar: httpx.Cookies) -> None:
|
|
63
|
+
self.cookies = [
|
|
64
|
+
SessionCookie(
|
|
65
|
+
name=c.name,
|
|
66
|
+
value=c.value or "",
|
|
67
|
+
domain=c.domain,
|
|
68
|
+
path=c.path,
|
|
69
|
+
secure=c.secure,
|
|
70
|
+
expires=c.expires,
|
|
71
|
+
host_only=not c.domain_specified,
|
|
72
|
+
)
|
|
73
|
+
for c in jar.jar
|
|
74
|
+
if c.domain.lstrip(".") in {"linkedin.com", "www.linkedin.com"}
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def from_cookies(cls, li_at: str, jsessionid: str) -> "Session":
|
|
79
|
+
"""Import your own session cookie pair; accepts JSESSIONID with or without quotes."""
|
|
80
|
+
if not li_at.strip() or not jsessionid.strip().strip('"'):
|
|
81
|
+
raise SessionError("Both li_at and JSESSIONID are required.")
|
|
82
|
+
csrf = jsessionid.strip().strip('"')
|
|
83
|
+
return cls(
|
|
84
|
+
cookies=[
|
|
85
|
+
SessionCookie("li_at", li_at.strip(), ".www.linkedin.com"),
|
|
86
|
+
SessionCookie("JSESSIONID", f'"{csrf}"', ".www.linkedin.com"),
|
|
87
|
+
]
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def save(self, path: str | Path) -> None:
|
|
91
|
+
"""Atomically write mode 0600; credentials are never printed or put in errors."""
|
|
92
|
+
destination = Path(path).expanduser()
|
|
93
|
+
destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
94
|
+
temporary = None
|
|
95
|
+
try:
|
|
96
|
+
with tempfile.NamedTemporaryFile(
|
|
97
|
+
mode="w", encoding="utf-8", dir=destination.parent, delete=False
|
|
98
|
+
) as stream:
|
|
99
|
+
temporary = stream.name
|
|
100
|
+
os.fchmod(stream.fileno(), 0o600)
|
|
101
|
+
json.dump({"schema_version": 1, **asdict(self)}, stream, indent=2)
|
|
102
|
+
stream.flush()
|
|
103
|
+
os.fsync(stream.fileno())
|
|
104
|
+
os.replace(temporary, destination)
|
|
105
|
+
finally:
|
|
106
|
+
if temporary and os.path.exists(temporary):
|
|
107
|
+
os.unlink(temporary)
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def load(cls, path: str | Path) -> "Session":
|
|
111
|
+
"""Load supported state without silently replacing corrupt files or expired tokens."""
|
|
112
|
+
try:
|
|
113
|
+
data = json.loads(Path(path).expanduser().read_text())
|
|
114
|
+
if data.pop("schema_version") != 1:
|
|
115
|
+
raise ValueError
|
|
116
|
+
profile = ClientProfile(**data.pop("profile"))
|
|
117
|
+
cookies = [SessionCookie(**cookie) for cookie in data.pop("cookies")]
|
|
118
|
+
state = cls(profile=profile, cookies=cookies, **data)
|
|
119
|
+
if (
|
|
120
|
+
not isinstance(state.device_id, str)
|
|
121
|
+
or not state.device_id
|
|
122
|
+
or any(ch in state.device_id for ch in "\r\n")
|
|
123
|
+
or not isinstance(state.rate_limit_until, (float, int))
|
|
124
|
+
or (state.member_id is not None and not isinstance(state.member_id, str))
|
|
125
|
+
):
|
|
126
|
+
raise ValueError
|
|
127
|
+
return state
|
|
128
|
+
except (ValueError, TypeError, KeyError, AttributeError, SessionError):
|
|
129
|
+
raise SessionError("Invalid or unsupported LinkedIn session file.") from None
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Origin-bound HTTP exchanges with cookie rotation, cooldowns, and no automatic retries."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from datetime import UTC
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from email.utils import parsedate_to_datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import unquote
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from ..config import ClientOptions
|
|
17
|
+
from ..exceptions import AuthenticationError
|
|
18
|
+
from ..exceptions import ChallengeRequired
|
|
19
|
+
from ..exceptions import HTTPError
|
|
20
|
+
from ..exceptions import NetworkError
|
|
21
|
+
from ..exceptions import ProtocolError
|
|
22
|
+
from ..exceptions import RateLimited
|
|
23
|
+
from ..session import Session
|
|
24
|
+
|
|
25
|
+
ORIGIN = "https://www.linkedin.com"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Transport:
|
|
29
|
+
"""Own the connection pool and serialize updates to one account's cookie jar."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
session: Session,
|
|
34
|
+
options: ClientOptions,
|
|
35
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self.session = session
|
|
38
|
+
self.session_file: Path | None = None
|
|
39
|
+
self._lock = asyncio.Lock()
|
|
40
|
+
self._closed = False
|
|
41
|
+
self._client = httpx.AsyncClient(
|
|
42
|
+
timeout=options.timeout,
|
|
43
|
+
http2=True,
|
|
44
|
+
trust_env=False,
|
|
45
|
+
follow_redirects=False,
|
|
46
|
+
limits=httpx.Limits(max_connections=options.max_connections),
|
|
47
|
+
transport=transport,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
async def request(
|
|
51
|
+
self,
|
|
52
|
+
method: str,
|
|
53
|
+
path: str,
|
|
54
|
+
data: Mapping[str, str] | None = None,
|
|
55
|
+
*,
|
|
56
|
+
json: object | None = None,
|
|
57
|
+
content: bytes | None = None,
|
|
58
|
+
headers: Mapping[str, str] | None = None,
|
|
59
|
+
) -> httpx.Response:
|
|
60
|
+
"""Send one exchange.
|
|
61
|
+
|
|
62
|
+
`json` bodies and extra headers serve the Voyager API; `content` sends raw upload
|
|
63
|
+
bytes (set its content-type in `headers`).
|
|
64
|
+
"""
|
|
65
|
+
if not path.startswith("/") or path.startswith("//") or "#" in path:
|
|
66
|
+
raise ValueError("A LinkedIn origin-relative path is required.")
|
|
67
|
+
if sum(body is not None for body in (data, json, content)) > 1:
|
|
68
|
+
raise ValueError("Send one body: form data, JSON or raw content.")
|
|
69
|
+
async with self._lock:
|
|
70
|
+
if self._closed:
|
|
71
|
+
raise RuntimeError("LinkedIn client is closed.")
|
|
72
|
+
remaining = self.session.rate_limit_until - time.time()
|
|
73
|
+
if remaining > 0:
|
|
74
|
+
raise RateLimited(remaining)
|
|
75
|
+
sent = self.session.profile.auth_headers(self.session.device_id)
|
|
76
|
+
csrf = self.session.cookie_value("JSESSIONID", path)
|
|
77
|
+
if csrf:
|
|
78
|
+
sent["csrf-token"] = csrf.strip('"')
|
|
79
|
+
if path == "/mob/tracking":
|
|
80
|
+
sent = {"user-agent": self.session.profile.user_agent}
|
|
81
|
+
sent.update(headers or {})
|
|
82
|
+
self._client.cookies = self.session.to_httpx()
|
|
83
|
+
try:
|
|
84
|
+
response = await self._client.request(
|
|
85
|
+
method,
|
|
86
|
+
ORIGIN + path,
|
|
87
|
+
headers=sent,
|
|
88
|
+
data=data,
|
|
89
|
+
json=json,
|
|
90
|
+
content=content,
|
|
91
|
+
)
|
|
92
|
+
except httpx.RequestError:
|
|
93
|
+
raise NetworkError("LinkedIn request failed and was not retried.") from None
|
|
94
|
+
finally:
|
|
95
|
+
self.session.update_cookies(self._client.cookies)
|
|
96
|
+
self.checkpoint()
|
|
97
|
+
if response.status_code == 429:
|
|
98
|
+
delay = self._retry_after(response.headers.get("retry-after"))
|
|
99
|
+
self.session.rate_limit_until = time.time() + delay
|
|
100
|
+
self.checkpoint()
|
|
101
|
+
raise RateLimited(delay)
|
|
102
|
+
if response.status_code == 401:
|
|
103
|
+
self.session.clear_authentication()
|
|
104
|
+
self.checkpoint()
|
|
105
|
+
raise AuthenticationError("LinkedIn rejected the session or credentials.")
|
|
106
|
+
if response.status_code == 403:
|
|
107
|
+
raise ChallengeRequired()
|
|
108
|
+
if 300 <= response.status_code < 400:
|
|
109
|
+
raise ProtocolError("LinkedIn returned a redirect; it was not followed.")
|
|
110
|
+
if response.status_code >= 400:
|
|
111
|
+
raise HTTPError(response.status_code, self._service_message(response))
|
|
112
|
+
return response
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def upload_path(url: object, prefix: str) -> str | None:
|
|
116
|
+
"""The origin path of an upload URL LinkedIn handed out, or None if it is not ours.
|
|
117
|
+
|
|
118
|
+
Only https://www.linkedin.com URLs under `prefix` qualify; dot segments (also
|
|
119
|
+
percent-encoded) are refused because the HTTP client would resolve them elsewhere.
|
|
120
|
+
"""
|
|
121
|
+
if not isinstance(url, str):
|
|
122
|
+
return None
|
|
123
|
+
parts = urlsplit(url)
|
|
124
|
+
segments = unquote(parts.path).split("/")
|
|
125
|
+
if (
|
|
126
|
+
parts.scheme != "https"
|
|
127
|
+
or parts.netloc != "www.linkedin.com"
|
|
128
|
+
or not parts.path.startswith(prefix)
|
|
129
|
+
or "." in segments
|
|
130
|
+
or ".." in segments
|
|
131
|
+
or "\\" in parts.path
|
|
132
|
+
):
|
|
133
|
+
return None
|
|
134
|
+
return parts.path + (f"?{parts.query}" if parts.query else "")
|
|
135
|
+
|
|
136
|
+
def checkpoint(self) -> None:
|
|
137
|
+
if self.session_file is not None:
|
|
138
|
+
self.session.save(self.session_file)
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def json_object(response: httpx.Response) -> dict[str, Any]:
|
|
142
|
+
try:
|
|
143
|
+
data = response.json()
|
|
144
|
+
except ValueError:
|
|
145
|
+
raise ProtocolError("LinkedIn returned an unsupported response format.") from None
|
|
146
|
+
if not isinstance(data, dict):
|
|
147
|
+
raise ProtocolError("LinkedIn returned an unsupported response shape.")
|
|
148
|
+
return data
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _service_message(response: httpx.Response) -> str | None:
|
|
152
|
+
"""Rest.li's short client-error reason; server traces and bodies are never kept."""
|
|
153
|
+
if not 400 <= response.status_code < 500:
|
|
154
|
+
return None
|
|
155
|
+
try:
|
|
156
|
+
data = response.json()
|
|
157
|
+
except ValueError:
|
|
158
|
+
return None
|
|
159
|
+
message = data.get("message") if isinstance(data, dict) else None
|
|
160
|
+
if isinstance(message, str) and message and "\n" not in message:
|
|
161
|
+
return message[:300]
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def _retry_after(value: str | None) -> float:
|
|
166
|
+
if value:
|
|
167
|
+
try:
|
|
168
|
+
return max(0.0, float(int(value)))
|
|
169
|
+
except ValueError:
|
|
170
|
+
try:
|
|
171
|
+
date = parsedate_to_datetime(value)
|
|
172
|
+
if date.tzinfo is None:
|
|
173
|
+
date = date.replace(tzinfo=UTC)
|
|
174
|
+
return max(0.0, (date - datetime.now(UTC)).total_seconds())
|
|
175
|
+
except (ValueError, TypeError, OverflowError):
|
|
176
|
+
pass
|
|
177
|
+
return 60.0
|
|
178
|
+
|
|
179
|
+
async def aclose(self) -> None:
|
|
180
|
+
async with self._lock:
|
|
181
|
+
if not self._closed:
|
|
182
|
+
self._closed = True
|
|
183
|
+
await self._client.aclose()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Rest.li 2.0 URL encoding, as the app's DataEncoder writes GraphQL variables."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
# Rest.li reserves these characters inside values; they and '%' become %XX first.
|
|
10
|
+
_RESERVED = frozenset("(:),'%")
|
|
11
|
+
# Android's Uri.encode keeps these (plus '%', so the rest.li escapes survive).
|
|
12
|
+
_URI_SAFE = "-_.!~*'()%"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def encode(value: object) -> str:
|
|
16
|
+
"""Encode records as `(k:v)`, lists as `List(a,b)`, strings escaped and percent-encoded."""
|
|
17
|
+
if isinstance(value, bool):
|
|
18
|
+
return "true" if value else "false"
|
|
19
|
+
if isinstance(value, int):
|
|
20
|
+
return str(value)
|
|
21
|
+
if isinstance(value, float):
|
|
22
|
+
if value != value or value in (float("inf"), float("-inf")):
|
|
23
|
+
raise ValueError("Rest.li values must be finite numbers.")
|
|
24
|
+
# Plain decimal notation: an exponent's '+' would read as a space in a URL.
|
|
25
|
+
return format(Decimal(repr(value)), "f")
|
|
26
|
+
if isinstance(value, str):
|
|
27
|
+
return _string(value)
|
|
28
|
+
if isinstance(value, Mapping):
|
|
29
|
+
parts = []
|
|
30
|
+
for key, item in value.items():
|
|
31
|
+
# Keys are written verbatim, as the app does, so they must be plain identifiers.
|
|
32
|
+
if not isinstance(key, str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_$]*", key):
|
|
33
|
+
raise ValueError("Rest.li record keys must be identifiers.")
|
|
34
|
+
if item is not None:
|
|
35
|
+
parts.append(f"{key}:{encode(item)}")
|
|
36
|
+
return "(" + ",".join(parts) + ")"
|
|
37
|
+
if isinstance(value, Sequence) and not isinstance(value, bytes | bytearray):
|
|
38
|
+
return "List(" + ",".join(encode(item) for item in value) + ")"
|
|
39
|
+
raise ValueError(f"Rest.li cannot encode {type(value).__name__} values.")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _string(value: str) -> str:
|
|
43
|
+
if not value:
|
|
44
|
+
return "''"
|
|
45
|
+
escaped = "".join(f"%{ord(ch):02X}" if ch in _RESERVED else ch for ch in value)
|
|
46
|
+
return quote(escaped, safe=_URI_SAFE)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""The Voyager JSON API: persisted GraphQL operations and rest.li resources.
|
|
2
|
+
|
|
3
|
+
The Android app ships these operations and asks for protobuf; the same routes answer plain
|
|
4
|
+
JSON when asked, so requests carry `accept: application/json` and JSON bodies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ..exceptions import OperationRejected
|
|
13
|
+
from ..exceptions import ProtocolError
|
|
14
|
+
from . import restli
|
|
15
|
+
from .http import Transport
|
|
16
|
+
|
|
17
|
+
_JSON = {"accept": "application/json"}
|
|
18
|
+
_GRAPHQL = {"accept": "application/json", "x-li-graphql-pegasus-client": "true"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class GraphQLQuery:
|
|
23
|
+
"""One persisted GraphQL operation from the app: query ID plus operation name."""
|
|
24
|
+
|
|
25
|
+
id: str
|
|
26
|
+
name: str
|
|
27
|
+
|
|
28
|
+
def __post_init__(self) -> None:
|
|
29
|
+
if not re.fullmatch(r"voyager[A-Za-z]+\.[0-9a-f]{32}", self.id):
|
|
30
|
+
raise ValueError("GraphQL query IDs look like voyagerName.<32 hex digits>.")
|
|
31
|
+
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9]*", self.name):
|
|
32
|
+
raise ValueError("GraphQL operation names are identifiers.")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class Reply:
|
|
37
|
+
"""A rest.li reply: its JSON object (empty when none) and the created entity's ID."""
|
|
38
|
+
|
|
39
|
+
data: dict[str, Any]
|
|
40
|
+
created_id: str | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Voyager:
|
|
44
|
+
"""Voyager calls over the client's one transport; no retries, no hidden paging."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, transport: Transport) -> None:
|
|
47
|
+
self.transport = transport
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def owner(self) -> str:
|
|
51
|
+
"""The session's device ID, which ties page cursors to this session."""
|
|
52
|
+
return self.transport.session.device_id
|
|
53
|
+
|
|
54
|
+
async def query(
|
|
55
|
+
self,
|
|
56
|
+
query: GraphQLQuery,
|
|
57
|
+
variables: Mapping[str, object] | None = None,
|
|
58
|
+
*,
|
|
59
|
+
ignore_null_field_errors: bool = False,
|
|
60
|
+
) -> dict[str, Any]:
|
|
61
|
+
"""Run a read and return its `data` object.
|
|
62
|
+
|
|
63
|
+
Some finders report a null in a field they declare non-null next to complete data.
|
|
64
|
+
`ignore_null_field_errors` accepts those replies; any other error still raises.
|
|
65
|
+
"""
|
|
66
|
+
params = f"variables={restli.encode(variables)}&" if variables else ""
|
|
67
|
+
path = f"/voyager/api/graphql?{params}queryId={query.id}&queryName={query.name}"
|
|
68
|
+
response = await self.transport.request("GET", path, headers=_GRAPHQL)
|
|
69
|
+
envelope = self.transport.json_object(response)
|
|
70
|
+
if ignore_null_field_errors and isinstance(envelope.get("data"), dict):
|
|
71
|
+
errors = envelope.get("errors")
|
|
72
|
+
if isinstance(errors, list) and all(map(_null_field_error, errors)):
|
|
73
|
+
envelope = {"data": envelope["data"]}
|
|
74
|
+
return _graphql_data(envelope)
|
|
75
|
+
|
|
76
|
+
async def mutate(self, query: GraphQLQuery, variables: Mapping[str, object]) -> dict[str, Any]:
|
|
77
|
+
"""Run a write once and return its `data` object; a timeout leaves the outcome unknown."""
|
|
78
|
+
path = f"/voyager/api/graphql?action=execute&queryId={query.id}&queryName={query.name}"
|
|
79
|
+
body = {"queryId": query.id, "queryName": query.name, "variables": dict(variables)}
|
|
80
|
+
response = await self.transport.request("POST", path, json=body, headers=_GRAPHQL)
|
|
81
|
+
envelope = self.transport.json_object(response)
|
|
82
|
+
value = envelope.get("value")
|
|
83
|
+
if not isinstance(value, dict):
|
|
84
|
+
raise ProtocolError("GraphQL action reply had no value object.")
|
|
85
|
+
return _graphql_data(value)
|
|
86
|
+
|
|
87
|
+
async def get(
|
|
88
|
+
self, resource: str, params: Mapping[str, object] | None = None
|
|
89
|
+
) -> dict[str, Any]:
|
|
90
|
+
"""Read a rest.li resource such as `voyagerJobsDashJobPostings/<id>`."""
|
|
91
|
+
response = await self.transport.request(
|
|
92
|
+
"GET", _resource_path(resource, params), headers=_JSON
|
|
93
|
+
)
|
|
94
|
+
return self.transport.json_object(response)
|
|
95
|
+
|
|
96
|
+
async def post(
|
|
97
|
+
self,
|
|
98
|
+
resource: str,
|
|
99
|
+
body: Mapping[str, object],
|
|
100
|
+
params: Mapping[str, object] | None = None,
|
|
101
|
+
) -> Reply:
|
|
102
|
+
"""Create or run a rest.li action once; the server may reply with no body."""
|
|
103
|
+
response = await self.transport.request(
|
|
104
|
+
"POST", _resource_path(resource, params), json=dict(body), headers=_JSON
|
|
105
|
+
)
|
|
106
|
+
data = self.transport.json_object(response) if response.content else {}
|
|
107
|
+
created = response.headers.get("x-restli-id") or response.headers.get("x-linkedin-id")
|
|
108
|
+
return Reply(data, created)
|
|
109
|
+
|
|
110
|
+
async def delete(self, resource: str, params: Mapping[str, object] | None = None) -> None:
|
|
111
|
+
"""Delete a rest.li entity once."""
|
|
112
|
+
await self.transport.request("DELETE", _resource_path(resource, params), headers=_JSON)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _resource_path(resource: str, params: Mapping[str, object] | None) -> str:
|
|
116
|
+
if not re.fullmatch(r"voyager[A-Za-z]+(/[A-Za-z0-9%()_:.,'\-~!*]+)*", resource):
|
|
117
|
+
raise ValueError("A Voyager resource name such as voyagerJobsDashJobPostings is required.")
|
|
118
|
+
path = "/voyager/api/" + resource
|
|
119
|
+
if params:
|
|
120
|
+
query = "&".join(
|
|
121
|
+
f"{key}={value if isinstance(value, str) and key in _RAW else restli.encode(value)}"
|
|
122
|
+
for key, value in params.items()
|
|
123
|
+
if value is not None
|
|
124
|
+
)
|
|
125
|
+
path += "?" + query
|
|
126
|
+
return path
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# Plain query parameters the app writes without rest.li value encoding.
|
|
130
|
+
_RAW = frozenset({"q", "action", "decorationId"})
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _null_field_error(error: object) -> bool:
|
|
134
|
+
if not isinstance(error, dict) or not isinstance(error.get("extensions"), dict):
|
|
135
|
+
return False
|
|
136
|
+
return bool(error["extensions"].get("classification") == "NullValueInNonNullableField")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _graphql_data(envelope: dict[str, Any]) -> dict[str, Any]:
|
|
140
|
+
errors = envelope.get("errors")
|
|
141
|
+
if errors:
|
|
142
|
+
reasons = tuple(
|
|
143
|
+
str(error.get("message"))[:300]
|
|
144
|
+
for error in errors
|
|
145
|
+
if isinstance(error, dict) and error.get("message")
|
|
146
|
+
)
|
|
147
|
+
raise OperationRejected(reasons)
|
|
148
|
+
data = envelope.get("data")
|
|
149
|
+
if not isinstance(data, dict):
|
|
150
|
+
raise ProtocolError("GraphQL reply had no data object.")
|
|
151
|
+
return data
|