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,187 @@
|
|
|
1
|
+
"""Async LinkedIn client: sessions, feed, posts, comments, search and jobs (Voyager API)."""
|
|
2
|
+
|
|
3
|
+
from .client import LinkedIn # Async client and resource lifecycle.
|
|
4
|
+
from .config import ClientOptions # Connection settings.
|
|
5
|
+
from .config import ClientProfile # Versioned app metadata.
|
|
6
|
+
from .exceptions import AuthenticationError # Rejected or expired credentials.
|
|
7
|
+
from .exceptions import ChallengeRequired # Verification required in the official app.
|
|
8
|
+
from .exceptions import HTTPError # Non-authentication HTTP failures.
|
|
9
|
+
from .exceptions import LinkedInError # Base SDK failure.
|
|
10
|
+
from .exceptions import NetworkError # Network failure with no automatic retry.
|
|
11
|
+
from .exceptions import OperationRejected # LinkedIn refused the operation and said why.
|
|
12
|
+
from .exceptions import ProtocolError # Unsupported response shape or redirect.
|
|
13
|
+
from .exceptions import RateLimited # Server or persisted cooldown.
|
|
14
|
+
from .exceptions import SessionError # Invalid persisted state.
|
|
15
|
+
from .exceptions import SessionInUse # Exclusive session ownership conflict.
|
|
16
|
+
from .exceptions import UnconfirmedWrite # A write was accepted but its reply unreadable.
|
|
17
|
+
from .models import ApplicationForm # An Easy Apply form: pages, questions, resumes.
|
|
18
|
+
from .models import ApplicationStatus # Result of a submitted application.
|
|
19
|
+
from .models import Author # Member or company shown on a post or comment.
|
|
20
|
+
from .models import Comment # One comment or reply.
|
|
21
|
+
from .models import CommentScope # Who may comment on a post.
|
|
22
|
+
from .models import CommentSort # Most relevant or most recent comments.
|
|
23
|
+
from .models import Commitment # Employer commitments filter.
|
|
24
|
+
from .models import CompanyContent # Which company page posts to list.
|
|
25
|
+
from .models import CompanyFilters # Company search filters.
|
|
26
|
+
from .models import CompanySize # Company size buckets.
|
|
27
|
+
from .models import ContentType # Post search content types.
|
|
28
|
+
from .models import CourseFilters # Course search filters.
|
|
29
|
+
from .models import CourseLength # Course length buckets.
|
|
30
|
+
from .models import CourseLevel # Course levels.
|
|
31
|
+
from .models import Cursor # Continues the request that produced a page.
|
|
32
|
+
from .models import DatePosted # Post search date ranges.
|
|
33
|
+
from .models import Document # A document attachment.
|
|
34
|
+
from .models import ExperienceLevel # Job seniority filter.
|
|
35
|
+
from .models import FeedSort # Top or recent main feed.
|
|
36
|
+
from .models import FileQuestion # A file slot such as the resume.
|
|
37
|
+
from .models import Filter # One available search filter.
|
|
38
|
+
from .models import FilterOption # One suggested filter value.
|
|
39
|
+
from .models import FormPage # One page of an Easy Apply form.
|
|
40
|
+
from .models import Image # An image attachment.
|
|
41
|
+
from .models import Job # One job posting as a seeker sees it.
|
|
42
|
+
from .models import JobDatePosted # Job posting date ranges.
|
|
43
|
+
from .models import JobFilters # Job search filters.
|
|
44
|
+
from .models import JobResult # One job card in search results.
|
|
45
|
+
from .models import JobSort # Job search order.
|
|
46
|
+
from .models import JobType # Full-time, part-time, contract and more.
|
|
47
|
+
from .models import LinkPreview # A link attachment.
|
|
48
|
+
from .models import MediaKind # Image, video or document upload.
|
|
49
|
+
from .models import MediaStatus # Processing state of an upload.
|
|
50
|
+
from .models import Member # Verified member identity.
|
|
51
|
+
from .models import Mention # A tagged member inside comment text.
|
|
52
|
+
from .models import Network # 1st, 2nd and 3rd+ connections.
|
|
53
|
+
from .models import NewPoll # A poll to publish.
|
|
54
|
+
from .models import Option # One choice of a question.
|
|
55
|
+
from .models import Page # One page of results.
|
|
56
|
+
from .models import PeopleFilters # People search filters.
|
|
57
|
+
from .models import PersonResult # A person in search results.
|
|
58
|
+
from .models import Poll # A poll attached to a post.
|
|
59
|
+
from .models import PollDuration # How long a new poll runs.
|
|
60
|
+
from .models import PollOption # One poll choice and its vote state.
|
|
61
|
+
from .models import Post # One feed update.
|
|
62
|
+
from .models import PostedBy # Post search author scope.
|
|
63
|
+
from .models import PostFilters # Post search filters.
|
|
64
|
+
from .models import PostSort # Post search order.
|
|
65
|
+
from .models import ProductFilters # Product search filters.
|
|
66
|
+
from .models import ProfileActivity # A member's posts, comments or reactions.
|
|
67
|
+
from .models import Question # One Easy Apply question.
|
|
68
|
+
from .models import QuestionKind # Text, number, single choice, dropdown, checkbox.
|
|
69
|
+
from .models import ReactionSort # Order of a reactions list.
|
|
70
|
+
from .models import ReactionType # Like, Celebrate, Support, Love, Insightful, Funny.
|
|
71
|
+
from .models import Reactor # Who reacted to a post, and how.
|
|
72
|
+
from .models import Repost # An instant repost.
|
|
73
|
+
from .models import ResultGroup # One block of blended results.
|
|
74
|
+
from .models import Resume # A resume offered from earlier applications.
|
|
75
|
+
from .models import SavedPost # One entry of the saved-posts list.
|
|
76
|
+
from .models import ScheduledPost # One scheduled post.
|
|
77
|
+
from .models import SearchResult # A company, group, school, event, course or product.
|
|
78
|
+
from .models import ServiceFilters # Service search filters.
|
|
79
|
+
from .models import SharedPost # A published or scheduled post.
|
|
80
|
+
from .models import SocialCounts # Reaction, comment and repost totals.
|
|
81
|
+
from .models import Submission # A checked application body, not yet sent.
|
|
82
|
+
from .models import Suggestion # A typeahead match; its id is a filter value.
|
|
83
|
+
from .models import TrackedJob # One entry of the My jobs lists.
|
|
84
|
+
from .models import TrackedStatus # Saved, in progress, applied or archived.
|
|
85
|
+
from .models import TypeaheadKind # What a typeahead looks up.
|
|
86
|
+
from .models import UploadedFile # A stored file, ready to attach.
|
|
87
|
+
from .models import UploadedMedia # An uploaded file, ready to attach.
|
|
88
|
+
from .models import Vertical # Which kind of result a search returns.
|
|
89
|
+
from .models import Video # A video attachment.
|
|
90
|
+
from .models import Visibility # Anyone or connections only.
|
|
91
|
+
from .models import WorkplaceType # On-site, remote or hybrid.
|
|
92
|
+
from .session import Session # Private cookie state and persistence.
|
|
93
|
+
|
|
94
|
+
__all__ = [
|
|
95
|
+
"LinkedIn",
|
|
96
|
+
"Session",
|
|
97
|
+
"ClientOptions",
|
|
98
|
+
"ClientProfile",
|
|
99
|
+
# Failures
|
|
100
|
+
"AuthenticationError",
|
|
101
|
+
"ChallengeRequired",
|
|
102
|
+
"HTTPError",
|
|
103
|
+
"LinkedInError",
|
|
104
|
+
"NetworkError",
|
|
105
|
+
"OperationRejected",
|
|
106
|
+
"ProtocolError",
|
|
107
|
+
"RateLimited",
|
|
108
|
+
"SessionError",
|
|
109
|
+
"SessionInUse",
|
|
110
|
+
"UnconfirmedWrite",
|
|
111
|
+
# Models
|
|
112
|
+
"ApplicationForm",
|
|
113
|
+
"ApplicationStatus",
|
|
114
|
+
"Author",
|
|
115
|
+
"Comment",
|
|
116
|
+
"CommentScope",
|
|
117
|
+
"CommentSort",
|
|
118
|
+
"Commitment",
|
|
119
|
+
"CompanyContent",
|
|
120
|
+
"CompanyFilters",
|
|
121
|
+
"CompanySize",
|
|
122
|
+
"ContentType",
|
|
123
|
+
"CourseFilters",
|
|
124
|
+
"CourseLength",
|
|
125
|
+
"CourseLevel",
|
|
126
|
+
"Cursor",
|
|
127
|
+
"DatePosted",
|
|
128
|
+
"Document",
|
|
129
|
+
"ExperienceLevel",
|
|
130
|
+
"FeedSort",
|
|
131
|
+
"FileQuestion",
|
|
132
|
+
"Filter",
|
|
133
|
+
"FilterOption",
|
|
134
|
+
"FormPage",
|
|
135
|
+
"Image",
|
|
136
|
+
"Job",
|
|
137
|
+
"JobDatePosted",
|
|
138
|
+
"JobFilters",
|
|
139
|
+
"JobResult",
|
|
140
|
+
"JobSort",
|
|
141
|
+
"JobType",
|
|
142
|
+
"LinkPreview",
|
|
143
|
+
"MediaKind",
|
|
144
|
+
"MediaStatus",
|
|
145
|
+
"Member",
|
|
146
|
+
"Mention",
|
|
147
|
+
"Network",
|
|
148
|
+
"NewPoll",
|
|
149
|
+
"Option",
|
|
150
|
+
"Page",
|
|
151
|
+
"PeopleFilters",
|
|
152
|
+
"PersonResult",
|
|
153
|
+
"Poll",
|
|
154
|
+
"PollDuration",
|
|
155
|
+
"PollOption",
|
|
156
|
+
"Post",
|
|
157
|
+
"PostFilters",
|
|
158
|
+
"PostSort",
|
|
159
|
+
"PostedBy",
|
|
160
|
+
"ProductFilters",
|
|
161
|
+
"ProfileActivity",
|
|
162
|
+
"Question",
|
|
163
|
+
"QuestionKind",
|
|
164
|
+
"ReactionSort",
|
|
165
|
+
"ReactionType",
|
|
166
|
+
"Reactor",
|
|
167
|
+
"Repost",
|
|
168
|
+
"ResultGroup",
|
|
169
|
+
"Resume",
|
|
170
|
+
"SavedPost",
|
|
171
|
+
"ScheduledPost",
|
|
172
|
+
"SearchResult",
|
|
173
|
+
"ServiceFilters",
|
|
174
|
+
"SharedPost",
|
|
175
|
+
"SocialCounts",
|
|
176
|
+
"Submission",
|
|
177
|
+
"Suggestion",
|
|
178
|
+
"TrackedJob",
|
|
179
|
+
"TrackedStatus",
|
|
180
|
+
"TypeaheadKind",
|
|
181
|
+
"UploadedFile",
|
|
182
|
+
"UploadedMedia",
|
|
183
|
+
"Vertical",
|
|
184
|
+
"Video",
|
|
185
|
+
"Visibility",
|
|
186
|
+
"WorkplaceType",
|
|
187
|
+
]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""The recorded Google ID-token exchange; Google token acquisition is caller-owned."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import secrets
|
|
5
|
+
|
|
6
|
+
from ..exceptions import AuthenticationError
|
|
7
|
+
from ..exceptions import ChallengeRequired
|
|
8
|
+
from ..exceptions import ProtocolError
|
|
9
|
+
from ..models import Member
|
|
10
|
+
from ..resources import Account
|
|
11
|
+
from ..session.cookies import SessionCookie
|
|
12
|
+
from ..transport import Transport
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Auth:
|
|
16
|
+
"""Bootstrap fresh browser cookies and exchange one caller-supplied Google ID token."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, transport: Transport) -> None:
|
|
19
|
+
self._transport = transport
|
|
20
|
+
self._lock = asyncio.Lock()
|
|
21
|
+
|
|
22
|
+
async def prepare(self) -> None:
|
|
23
|
+
"""Fetch current server cookies; create the mobile CSRF cookie if none was issued."""
|
|
24
|
+
await self._transport.request("GET", "/mob/tracking")
|
|
25
|
+
state = self._transport.session
|
|
26
|
+
if not state.cookie_value("JSESSIONID"):
|
|
27
|
+
state.cookies.append(
|
|
28
|
+
SessionCookie(
|
|
29
|
+
"JSESSIONID",
|
|
30
|
+
f'"ajax:{secrets.randbelow(9 * 10**18) + 10**18}"',
|
|
31
|
+
".www.linkedin.com",
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
self._transport.checkpoint()
|
|
35
|
+
|
|
36
|
+
async def login_google(self, id_token: str) -> Member:
|
|
37
|
+
"""Exchange a fresh token for the LinkedIn Android audience, then verify the member.
|
|
38
|
+
|
|
39
|
+
Tokens are not persisted or automatically retried. Challenges must be completed
|
|
40
|
+
in the official app. This does not obtain a Google token or accept passwords.
|
|
41
|
+
"""
|
|
42
|
+
if not isinstance(id_token, str) or not id_token.strip():
|
|
43
|
+
raise ValueError("A fresh Google ID token is required.")
|
|
44
|
+
async with self._lock:
|
|
45
|
+
state = self._transport.session
|
|
46
|
+
if state.authenticated:
|
|
47
|
+
raise AuthenticationError(
|
|
48
|
+
"A session already exists; use account.me() to verify it."
|
|
49
|
+
)
|
|
50
|
+
await self.prepare()
|
|
51
|
+
form = {
|
|
52
|
+
"soogleLoginRequestType": "ONE_TAP_AUTO_LOGIN",
|
|
53
|
+
"googleIdToken": id_token,
|
|
54
|
+
"client_enabled_features": "ANDROID_NATIVE_CAPTCHA",
|
|
55
|
+
}
|
|
56
|
+
for name in ("JSESSIONID", "bcookie", "bscookie", "lang", "lidc"):
|
|
57
|
+
value = state.cookie_value(name)
|
|
58
|
+
if value is not None:
|
|
59
|
+
form[name] = value.strip('"')
|
|
60
|
+
try:
|
|
61
|
+
response = await self._transport.request("POST", "/uas/authenticate", form)
|
|
62
|
+
result = self._transport.json_object(response)
|
|
63
|
+
challenge = result.get("challenge_url")
|
|
64
|
+
if isinstance(challenge, str) and challenge:
|
|
65
|
+
raise ChallengeRequired(challenge)
|
|
66
|
+
if result.get("login_result") != "PASS":
|
|
67
|
+
raise AuthenticationError("LinkedIn did not accept the Google sign-in.")
|
|
68
|
+
if not state.authenticated:
|
|
69
|
+
raise ProtocolError("Login succeeded without the required session cookies.")
|
|
70
|
+
except BaseException:
|
|
71
|
+
state.clear_authentication()
|
|
72
|
+
self._transport.checkpoint()
|
|
73
|
+
raise
|
|
74
|
+
finally:
|
|
75
|
+
form.clear()
|
|
76
|
+
# A temporary verification failure must not discard a successfully issued session.
|
|
77
|
+
return await Account(self._transport).me()
|
async_linkedinlib/cli.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Interactive credentials; never put tokens in command arguments or shell history."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
from getpass import getpass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .client import LinkedIn
|
|
9
|
+
from .exceptions import LinkedInError
|
|
10
|
+
from .session import Session
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def run(command: str, path: Path) -> None:
|
|
14
|
+
async with LinkedIn.from_session_file(path) as client:
|
|
15
|
+
if command == "import-session":
|
|
16
|
+
if client.session.cookies:
|
|
17
|
+
raise LinkedInError("Session file already contains cookies; choose a new file.")
|
|
18
|
+
imported = Session.from_cookies(getpass("li_at: "), getpass("JSESSIONID: "))
|
|
19
|
+
client.session.cookies = imported.cookies
|
|
20
|
+
member = await client.account.me()
|
|
21
|
+
elif command == "login-google" and not client.session.authenticated:
|
|
22
|
+
member = await client.auth.login_google(getpass("Fresh Google ID token: "))
|
|
23
|
+
else:
|
|
24
|
+
member = await client.account.me()
|
|
25
|
+
print(f"Verified {member.full_name} (member {member.id}).")
|
|
26
|
+
print(f"Session saved to {path}.")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main() -> None:
|
|
30
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
31
|
+
parser.add_argument("command", choices=("import-session", "login-google", "me"))
|
|
32
|
+
parser.add_argument("--session", type=Path, required=True)
|
|
33
|
+
args = parser.parse_args()
|
|
34
|
+
try:
|
|
35
|
+
asyncio.run(run(args.command, args.session.expanduser()))
|
|
36
|
+
except (LinkedInError, OSError, ValueError) as error:
|
|
37
|
+
parser.exit(1, f"{error}\n")
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Public client lifecycle and exclusive session-file ownership."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from types import TracebackType
|
|
6
|
+
from typing import Self
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .auth import Auth
|
|
11
|
+
from .config import ClientOptions
|
|
12
|
+
from .resources import Account
|
|
13
|
+
from .resources import Comments
|
|
14
|
+
from .resources import Feed
|
|
15
|
+
from .resources import Jobs
|
|
16
|
+
from .resources import Posts
|
|
17
|
+
from .resources import Search
|
|
18
|
+
from .session import Session
|
|
19
|
+
from .session.lease import SessionLease
|
|
20
|
+
from .transport import Transport
|
|
21
|
+
from .transport import Voyager
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LinkedIn:
|
|
25
|
+
"""One account per async context; closing saves state without logging out."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
session: Session | None = None,
|
|
30
|
+
options: ClientOptions | None = None,
|
|
31
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
self.session = session if session is not None else Session()
|
|
34
|
+
self._transport = Transport(self.session, options or ClientOptions(), transport)
|
|
35
|
+
self.auth = Auth(self._transport)
|
|
36
|
+
self.account = Account(self._transport)
|
|
37
|
+
voyager = Voyager(self._transport)
|
|
38
|
+
self.feed = Feed(voyager)
|
|
39
|
+
self.posts = Posts(voyager)
|
|
40
|
+
self.comments = Comments(voyager)
|
|
41
|
+
self.search = Search(voyager)
|
|
42
|
+
self.jobs = Jobs(voyager)
|
|
43
|
+
self._lease: SessionLease | None = None
|
|
44
|
+
self._closed = False
|
|
45
|
+
self._close_lock = asyncio.Lock()
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_session_file(
|
|
49
|
+
cls,
|
|
50
|
+
path: str | Path,
|
|
51
|
+
options: ClientOptions | None = None,
|
|
52
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
53
|
+
) -> Self:
|
|
54
|
+
destination = Path(path).expanduser().resolve()
|
|
55
|
+
lease = SessionLease(destination)
|
|
56
|
+
try:
|
|
57
|
+
session = Session.load(destination) if destination.exists() else Session()
|
|
58
|
+
if not destination.exists():
|
|
59
|
+
session.save(destination)
|
|
60
|
+
client = cls(session, options, transport)
|
|
61
|
+
client._lease = lease
|
|
62
|
+
client._transport.session_file = destination
|
|
63
|
+
return client
|
|
64
|
+
except BaseException:
|
|
65
|
+
lease.close()
|
|
66
|
+
raise
|
|
67
|
+
|
|
68
|
+
async def __aenter__(self) -> Self:
|
|
69
|
+
if self._closed:
|
|
70
|
+
raise RuntimeError("LinkedIn client is closed.")
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
async def __aexit__(
|
|
74
|
+
self,
|
|
75
|
+
exc_type: type[BaseException] | None,
|
|
76
|
+
exc: BaseException | None,
|
|
77
|
+
traceback: TracebackType | None,
|
|
78
|
+
) -> None:
|
|
79
|
+
await self.aclose()
|
|
80
|
+
|
|
81
|
+
async def aclose(self) -> None:
|
|
82
|
+
async with self._close_lock:
|
|
83
|
+
if self._closed:
|
|
84
|
+
return
|
|
85
|
+
try:
|
|
86
|
+
await self._transport.aclose()
|
|
87
|
+
finally:
|
|
88
|
+
try:
|
|
89
|
+
self._transport.checkpoint()
|
|
90
|
+
finally:
|
|
91
|
+
self._closed = True
|
|
92
|
+
if self._lease is not None:
|
|
93
|
+
self._lease.close()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Caller-controlled connection settings."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True, slots=True)
|
|
7
|
+
class ClientOptions:
|
|
8
|
+
"""Connection settings; one client owns one connection pool."""
|
|
9
|
+
|
|
10
|
+
timeout: float = 30.0
|
|
11
|
+
max_connections: int = 1
|
|
12
|
+
|
|
13
|
+
def __post_init__(self) -> None:
|
|
14
|
+
if self.timeout <= 0 or self.max_connections < 1:
|
|
15
|
+
raise ValueError("Timeout and connection count must be positive.")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Versioned Android client metadata observed in the local login capture."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class ClientProfile:
|
|
9
|
+
"""App metadata, separate from per-account cookies and device identity."""
|
|
10
|
+
|
|
11
|
+
app_version: str = "4.1.1267"
|
|
12
|
+
version_code: int = 217500
|
|
13
|
+
locale: str = "en_US"
|
|
14
|
+
timezone: str = "Asia/Kolkata"
|
|
15
|
+
timezone_offset: float = 5.5
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def user_agent(self) -> str:
|
|
19
|
+
return (
|
|
20
|
+
f"com.linkedin.android/{self.version_code} (Linux; U; Android 13; {self.locale}; "
|
|
21
|
+
"sdk_gphone64_arm64; Build/TE1A.240213.009; Cronet/151.0.7922.47)"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def auth_headers(self, device_id: str) -> dict[str, str]:
|
|
25
|
+
track = {
|
|
26
|
+
"osName": "Android OS",
|
|
27
|
+
"osVersion": "33",
|
|
28
|
+
"clientVersion": self.app_version,
|
|
29
|
+
"clientMinorVersion": self.version_code,
|
|
30
|
+
"model": "Google_sdk_gphone64_arm64",
|
|
31
|
+
"displayDensity": 2.625,
|
|
32
|
+
"displayWidth": 1080,
|
|
33
|
+
"displayHeight": 2209,
|
|
34
|
+
"dpi": "xhdpi",
|
|
35
|
+
"deviceType": "android",
|
|
36
|
+
"appId": "com.linkedin.android",
|
|
37
|
+
"deviceId": device_id,
|
|
38
|
+
"timezoneOffset": self.timezone_offset,
|
|
39
|
+
"timezone": self.timezone,
|
|
40
|
+
"storeId": "us_googleplay",
|
|
41
|
+
"isAdTrackingLimited": False,
|
|
42
|
+
"mpName": "voyager-android",
|
|
43
|
+
"mpVersion": "2.344.49",
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
"user-agent": "ANDROID OS",
|
|
47
|
+
"x-udid": device_id,
|
|
48
|
+
"x-restli-protocol-version": "2.0.0",
|
|
49
|
+
"accept-language": self.locale.replace("_", "-"),
|
|
50
|
+
"x-li-lang": self.locale,
|
|
51
|
+
"x-li-track": json.dumps(track, separators=(",", ":")),
|
|
52
|
+
"x-li-user-agent": (
|
|
53
|
+
f"LIAuthLibrary:0.0.3 com.linkedin.android:{self.app_version} "
|
|
54
|
+
"Google_sdk_gphone64_arm64:android_13"
|
|
55
|
+
),
|
|
56
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Public failures with messages that never include credentials or response bodies."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LinkedInError(Exception):
|
|
5
|
+
"""Base class for expected client failures."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SessionError(LinkedInError):
|
|
9
|
+
"""The session file is invalid or cannot be used."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SessionInUse(SessionError):
|
|
13
|
+
"""Another client owns this session file."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthenticationError(LinkedInError):
|
|
17
|
+
"""Credentials were rejected or the session expired."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ChallengeRequired(AuthenticationError):
|
|
21
|
+
"""Complete verification in the official app; no automatic continuation occurs."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, challenge_url: str | None = None) -> None:
|
|
24
|
+
super().__init__("LinkedIn requires verification in the official app.")
|
|
25
|
+
# The URL can contain private state, so it is never part of the message or repr.
|
|
26
|
+
self.challenge_url = challenge_url
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RateLimited(LinkedInError):
|
|
30
|
+
"""No retry occurs; retry_after is the remaining cooldown in seconds."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, retry_after: float) -> None:
|
|
33
|
+
super().__init__("LinkedIn rate limit reached; wait before another request.")
|
|
34
|
+
self.retry_after = retry_after
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ProtocolError(LinkedInError):
|
|
38
|
+
"""The response does not match the supported protocol."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class UnconfirmedWrite(ProtocolError):
|
|
42
|
+
"""LinkedIn accepted a write, but its reply could not be read.
|
|
43
|
+
|
|
44
|
+
The change may have been applied. Read the state back before sending it again; the
|
|
45
|
+
client never retries a write.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class HTTPError(LinkedInError):
|
|
50
|
+
"""An HTTP failure, without attaching the credential-bearing HTTPX response.
|
|
51
|
+
|
|
52
|
+
`reason` is rest.li's one-line client-error message when the reply carried one.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, status_code: int, reason: str | None = None) -> None:
|
|
56
|
+
detail = f": {reason}" if reason else "."
|
|
57
|
+
super().__init__(f"LinkedIn returned HTTP {status_code}{detail}")
|
|
58
|
+
self.status_code = status_code
|
|
59
|
+
self.reason = reason
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class OperationRejected(LinkedInError):
|
|
63
|
+
"""LinkedIn answered but refused the operation, for example a GraphQL validation error.
|
|
64
|
+
|
|
65
|
+
`reasons` holds the server's messages; nothing was retried.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, reasons: tuple[str, ...]) -> None:
|
|
69
|
+
summary = reasons[0] if reasons else "no reason given"
|
|
70
|
+
super().__init__(f"LinkedIn rejected the operation: {summary}")
|
|
71
|
+
self.reasons = reasons
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class NetworkError(LinkedInError):
|
|
75
|
+
"""The exchange failed; its outcome may be unknown and it was not retried."""
|