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,339 @@
|
|
|
1
|
+
"""Typed search filters per vertical, mapped to the query keys the app's filter sheets use.
|
|
2
|
+
|
|
3
|
+
ID fields take IDs or simple URNs (`"1035"`, `"urn:li:fsd_company:1035"`); typeahead
|
|
4
|
+
`Suggestion.id` values fit directly. `extra` adds raw query keys from `Search.filters()` that
|
|
5
|
+
have no typed field, for example `{"seniorityV2": ["120"]}`; it cannot repeat a typed key.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from dataclasses import field
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
Params = dict[str, list[str]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Network(StrEnum):
|
|
20
|
+
"""Connection degree."""
|
|
21
|
+
|
|
22
|
+
FIRST = "F"
|
|
23
|
+
SECOND = "S"
|
|
24
|
+
THIRD_PLUS = "O"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PostSort(StrEnum):
|
|
28
|
+
RELEVANCE = "relevance" # Top match
|
|
29
|
+
LATEST = "date_posted"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DatePosted(StrEnum):
|
|
33
|
+
PAST_24_HOURS = "past-24h"
|
|
34
|
+
PAST_WEEK = "past-week"
|
|
35
|
+
PAST_MONTH = "past-month"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ContentType(StrEnum):
|
|
39
|
+
VIDEOS = "videos"
|
|
40
|
+
IMAGES = "photos"
|
|
41
|
+
JOB_POSTS = "jobs"
|
|
42
|
+
LIVE_VIDEOS = "liveVideos"
|
|
43
|
+
DOCUMENTS = "documents"
|
|
44
|
+
COLLABORATIVE_ARTICLES = "collaborativeArticles"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PostedBy(StrEnum):
|
|
48
|
+
FIRST_CONNECTIONS = "first"
|
|
49
|
+
ME = "me"
|
|
50
|
+
PEOPLE_YOU_FOLLOW = "following"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CompanySize(StrEnum):
|
|
54
|
+
SIZE_1_10 = "B"
|
|
55
|
+
SIZE_11_50 = "C"
|
|
56
|
+
SIZE_51_200 = "D"
|
|
57
|
+
SIZE_201_500 = "E"
|
|
58
|
+
SIZE_501_1000 = "F"
|
|
59
|
+
SIZE_1001_5000 = "G"
|
|
60
|
+
SIZE_5001_10000 = "H"
|
|
61
|
+
SIZE_10001_PLUS = "I"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class CourseLevel(StrEnum):
|
|
65
|
+
BEGINNER = "Beginner"
|
|
66
|
+
INTERMEDIATE = "Intermediate"
|
|
67
|
+
ADVANCED = "Advanced"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class CourseLength(StrEnum):
|
|
71
|
+
UNDER_10_MINUTES = "< 10 mins"
|
|
72
|
+
MINUTES_10_30 = "10 - 30 mins"
|
|
73
|
+
MINUTES_30_60 = "30 - 60 mins"
|
|
74
|
+
HOURS_1_2 = "1 - 2 hours"
|
|
75
|
+
HOURS_2_3 = "2 - 3 hours"
|
|
76
|
+
OVER_3_HOURS = "3+ hours"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class PeopleFilters:
|
|
81
|
+
"""People search filters; `first_name` to `school` are the free-text keyword filters."""
|
|
82
|
+
|
|
83
|
+
network: Sequence[Network] = ()
|
|
84
|
+
locations: Sequence[str] = ()
|
|
85
|
+
current_companies: Sequence[str] = ()
|
|
86
|
+
past_companies: Sequence[str] = ()
|
|
87
|
+
schools: Sequence[str] = ()
|
|
88
|
+
industries: Sequence[str] = ()
|
|
89
|
+
profile_languages: Sequence[str] = () # language codes: en, fr, ... ("_o" = others)
|
|
90
|
+
service_categories: Sequence[str] = ()
|
|
91
|
+
connection_of: str | None = None # profile ID or URN
|
|
92
|
+
follower_of: str | None = None # profile ID or URN
|
|
93
|
+
open_to_volunteer: bool = False
|
|
94
|
+
first_name: str | None = None
|
|
95
|
+
last_name: str | None = None
|
|
96
|
+
title: str | None = None
|
|
97
|
+
company: str | None = None
|
|
98
|
+
school: str | None = None
|
|
99
|
+
extra: Mapping[str, Sequence[str]] = field(default_factory=dict)
|
|
100
|
+
|
|
101
|
+
def __post_init__(self) -> None:
|
|
102
|
+
self.params()
|
|
103
|
+
|
|
104
|
+
def params(self) -> Params:
|
|
105
|
+
return merge(
|
|
106
|
+
{
|
|
107
|
+
"network": choices(Network, "network", self.network),
|
|
108
|
+
"geoUrn": ids("locations", self.locations),
|
|
109
|
+
"currentCompany": ids("current_companies", self.current_companies),
|
|
110
|
+
"pastCompany": ids("past_companies", self.past_companies),
|
|
111
|
+
"schoolFilter": ids("schools", self.schools),
|
|
112
|
+
"industry": ids("industries", self.industries),
|
|
113
|
+
"profileLanguage": ids("profile_languages", self.profile_languages),
|
|
114
|
+
"serviceCategory": ids("service_categories", self.service_categories),
|
|
115
|
+
"connectionOf": one_id("connection_of", self.connection_of),
|
|
116
|
+
"followerOf": one_id("follower_of", self.follower_of),
|
|
117
|
+
"openToVolunteer": flag("open_to_volunteer", self.open_to_volunteer),
|
|
118
|
+
"firstName": phrase("first_name", self.first_name),
|
|
119
|
+
"lastName": phrase("last_name", self.last_name),
|
|
120
|
+
"title": phrase("title", self.title),
|
|
121
|
+
"company": phrase("company", self.company),
|
|
122
|
+
"schoolFreetext": phrase("school", self.school),
|
|
123
|
+
},
|
|
124
|
+
self.extra,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True, slots=True)
|
|
129
|
+
class PostFilters:
|
|
130
|
+
"""Post (content) search filters; member fields take profile IDs, company fields IDs."""
|
|
131
|
+
|
|
132
|
+
sort_by: PostSort | None = None
|
|
133
|
+
date_posted: DatePosted | None = None
|
|
134
|
+
content_type: ContentType | None = None
|
|
135
|
+
posted_by: Sequence[PostedBy] = ()
|
|
136
|
+
from_members: Sequence[str] = ()
|
|
137
|
+
from_companies: Sequence[str] = ()
|
|
138
|
+
mentions_members: Sequence[str] = ()
|
|
139
|
+
mentions_companies: Sequence[str] = ()
|
|
140
|
+
author_industries: Sequence[str] = ()
|
|
141
|
+
author_companies: Sequence[str] = ()
|
|
142
|
+
author_title: str | None = None
|
|
143
|
+
extra: Mapping[str, Sequence[str]] = field(default_factory=dict)
|
|
144
|
+
|
|
145
|
+
def __post_init__(self) -> None:
|
|
146
|
+
self.params()
|
|
147
|
+
|
|
148
|
+
def params(self) -> Params:
|
|
149
|
+
return merge(
|
|
150
|
+
{
|
|
151
|
+
"sortBy": choices(PostSort, "sort_by", optional(self.sort_by)),
|
|
152
|
+
"datePosted": choices(DatePosted, "date_posted", optional(self.date_posted)),
|
|
153
|
+
"contentType": choices(ContentType, "content_type", optional(self.content_type)),
|
|
154
|
+
"postedBy": choices(PostedBy, "posted_by", self.posted_by),
|
|
155
|
+
"fromMember": ids("from_members", self.from_members),
|
|
156
|
+
"fromOrganization": ids("from_companies", self.from_companies),
|
|
157
|
+
"mentionsMember": ids("mentions_members", self.mentions_members),
|
|
158
|
+
"mentionsOrganization": ids("mentions_companies", self.mentions_companies),
|
|
159
|
+
"authorIndustry": ids("author_industries", self.author_industries),
|
|
160
|
+
"authorCompany": ids("author_companies", self.author_companies),
|
|
161
|
+
"authorJobTitle": phrase("author_title", self.author_title),
|
|
162
|
+
},
|
|
163
|
+
self.extra,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True, slots=True)
|
|
168
|
+
class CompanyFilters:
|
|
169
|
+
"""Company search filters; `locations` are headquarters geo IDs."""
|
|
170
|
+
|
|
171
|
+
locations: Sequence[str] = ()
|
|
172
|
+
industries: Sequence[str] = ()
|
|
173
|
+
sizes: Sequence[CompanySize] = ()
|
|
174
|
+
has_jobs: bool = False
|
|
175
|
+
network: Sequence[Network] = ()
|
|
176
|
+
extra: Mapping[str, Sequence[str]] = field(default_factory=dict)
|
|
177
|
+
|
|
178
|
+
def __post_init__(self) -> None:
|
|
179
|
+
self.params()
|
|
180
|
+
|
|
181
|
+
def params(self) -> Params:
|
|
182
|
+
return merge(
|
|
183
|
+
{
|
|
184
|
+
"companyHqGeo": ids("locations", self.locations),
|
|
185
|
+
"industryCompanyVertical": ids("industries", self.industries),
|
|
186
|
+
"companySize": choices(CompanySize, "sizes", self.sizes),
|
|
187
|
+
"hasJobs": ["1"] if flag("has_jobs", self.has_jobs) else [],
|
|
188
|
+
"network": choices(Network, "network", self.network),
|
|
189
|
+
},
|
|
190
|
+
self.extra,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass(frozen=True, slots=True)
|
|
195
|
+
class ServiceFilters:
|
|
196
|
+
"""Service provider search filters."""
|
|
197
|
+
|
|
198
|
+
service_categories: Sequence[str] = ()
|
|
199
|
+
locations: Sequence[str] = ()
|
|
200
|
+
network: Sequence[Network] = ()
|
|
201
|
+
profile_languages: Sequence[str] = ()
|
|
202
|
+
extra: Mapping[str, Sequence[str]] = field(default_factory=dict)
|
|
203
|
+
|
|
204
|
+
def __post_init__(self) -> None:
|
|
205
|
+
self.params()
|
|
206
|
+
|
|
207
|
+
def params(self) -> Params:
|
|
208
|
+
return merge(
|
|
209
|
+
{
|
|
210
|
+
"serviceCategory": ids("service_categories", self.service_categories),
|
|
211
|
+
"geoUrn": ids("locations", self.locations),
|
|
212
|
+
"network": choices(Network, "network", self.network),
|
|
213
|
+
"profileLanguage": ids("profile_languages", self.profile_languages),
|
|
214
|
+
},
|
|
215
|
+
self.extra,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclass(frozen=True, slots=True)
|
|
220
|
+
class ProductFilters:
|
|
221
|
+
"""Product search filters; categories come from the PRODUCT_CATEGORY typeahead."""
|
|
222
|
+
|
|
223
|
+
categories: Sequence[str] = ()
|
|
224
|
+
companies: Sequence[str] = ()
|
|
225
|
+
free_plan: bool = False
|
|
226
|
+
extra: Mapping[str, Sequence[str]] = field(default_factory=dict)
|
|
227
|
+
|
|
228
|
+
def __post_init__(self) -> None:
|
|
229
|
+
self.params()
|
|
230
|
+
|
|
231
|
+
def params(self) -> Params:
|
|
232
|
+
return merge(
|
|
233
|
+
{
|
|
234
|
+
"productCategory": ids("categories", self.categories),
|
|
235
|
+
"productCompany": ids("companies", self.companies),
|
|
236
|
+
"productFreePlan": flag("free_plan", self.free_plan),
|
|
237
|
+
},
|
|
238
|
+
self.extra,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclass(frozen=True, slots=True)
|
|
243
|
+
class CourseFilters:
|
|
244
|
+
"""LinkedIn Learning course filters; `software`, `topics` and `education_units` take the
|
|
245
|
+
values `Search.filters(Vertical.COURSES, ...)` lists (software names, topic IDs, CPE...)."""
|
|
246
|
+
|
|
247
|
+
levels: Sequence[CourseLevel] = ()
|
|
248
|
+
lengths: Sequence[CourseLength] = ()
|
|
249
|
+
software: Sequence[str] = ()
|
|
250
|
+
topics: Sequence[str] = ()
|
|
251
|
+
education_units: Sequence[str] = ()
|
|
252
|
+
extra: Mapping[str, Sequence[str]] = field(default_factory=dict)
|
|
253
|
+
|
|
254
|
+
def __post_init__(self) -> None:
|
|
255
|
+
self.params()
|
|
256
|
+
|
|
257
|
+
def params(self) -> Params:
|
|
258
|
+
return merge(
|
|
259
|
+
{
|
|
260
|
+
"difficultyLevel": choices(CourseLevel, "levels", self.levels),
|
|
261
|
+
"timeToComplete": choices(CourseLength, "lengths", self.lengths),
|
|
262
|
+
"softwareNames": phrases("software", self.software),
|
|
263
|
+
"categoryIds": ids("topics", self.topics),
|
|
264
|
+
"continuingEducationUnits": phrases("education_units", self.education_units),
|
|
265
|
+
},
|
|
266
|
+
self.extra,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def merge(typed: Params, extra: Mapping[str, Sequence[str]]) -> Params:
|
|
271
|
+
"""Drop empty filters and add validated raw keys."""
|
|
272
|
+
found = {key: values for key, values in typed.items() if values}
|
|
273
|
+
if not isinstance(extra, Mapping):
|
|
274
|
+
raise ValueError("extra maps query keys to lists of values.")
|
|
275
|
+
for key, values in extra.items():
|
|
276
|
+
if not isinstance(key, str) or not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", key):
|
|
277
|
+
raise ValueError("extra keys are query parameter names such as seniorityV2.")
|
|
278
|
+
if key in typed or key in ("resultType", "keywords"):
|
|
279
|
+
raise ValueError(f"extra cannot set {key}; use its typed field or argument.")
|
|
280
|
+
found[key] = phrases(f"extra[{key}]", values)
|
|
281
|
+
return {key: values for key, values in found.items() if values}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def ids(name: str, values: Sequence[str]) -> list[str]:
|
|
285
|
+
return [identifier(name, value) for value in sequence(name, values)]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def one_id(name: str, value: str | None) -> list[str]:
|
|
289
|
+
return [] if value is None else [identifier(name, value)]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def identifier(name: str, value: object) -> str:
|
|
293
|
+
"""An ID, or the last part of a simple URN such as urn:li:fsd_geo:103644278."""
|
|
294
|
+
if not isinstance(value, str) or not value.strip():
|
|
295
|
+
raise ValueError(f"{name} takes IDs or URNs as nonempty strings.")
|
|
296
|
+
value = value.strip()
|
|
297
|
+
if value.startswith("urn:li:"):
|
|
298
|
+
if "(" in value:
|
|
299
|
+
raise ValueError(f"{name} takes simple URNs, not compound ones.")
|
|
300
|
+
value = value.rsplit(":", 1)[1]
|
|
301
|
+
if not value or any(char.isspace() for char in value):
|
|
302
|
+
raise ValueError(f"{name} takes IDs without spaces.")
|
|
303
|
+
return value
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def choices(kind: type[StrEnum], name: str, values: Sequence[StrEnum | str]) -> list[str]:
|
|
307
|
+
try:
|
|
308
|
+
return [kind(value).value for value in sequence(name, values)]
|
|
309
|
+
except ValueError:
|
|
310
|
+
options = ", ".join(member.name for member in kind)
|
|
311
|
+
raise ValueError(f"{name} takes {kind.__name__} values: {options}.") from None
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def optional(value: StrEnum | str | None) -> tuple[StrEnum | str, ...]:
|
|
315
|
+
return () if value is None else (value,)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def phrase(name: str, value: object) -> list[str]:
|
|
319
|
+
if value is None:
|
|
320
|
+
return []
|
|
321
|
+
if not isinstance(value, str) or not value.strip():
|
|
322
|
+
raise ValueError(f"{name} must be nonempty text.")
|
|
323
|
+
return [value.strip()]
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def phrases(name: str, values: Sequence[str]) -> list[str]:
|
|
327
|
+
return [item for value in sequence(name, values) for item in phrase(name, value)]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def flag(name: str, value: bool) -> list[str]:
|
|
331
|
+
if not isinstance(value, bool):
|
|
332
|
+
raise ValueError(f"{name} must be True or False.")
|
|
333
|
+
return ["true"] if value else []
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def sequence(name: str, values: object) -> Sequence[Any]:
|
|
337
|
+
if isinstance(values, (str, bytes)) or not isinstance(values, Sequence):
|
|
338
|
+
raise ValueError(f"{name} takes a list of values, not a single string.")
|
|
339
|
+
return values
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Job search filters and job cards from the app's job search results.
|
|
2
|
+
|
|
3
|
+
Job details and applying belong to the jobs feature: pass `JobResult.id` to it.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from copy import deepcopy
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from dataclasses import field
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ._json import boolean
|
|
16
|
+
from ._json import dig
|
|
17
|
+
from ._json import epoch_ms
|
|
18
|
+
from ._json import integer
|
|
19
|
+
from ._json import items
|
|
20
|
+
from ._json import obj
|
|
21
|
+
from ._json import string
|
|
22
|
+
from ._json import text
|
|
23
|
+
from ._json import urn_id
|
|
24
|
+
from .jobs import WORKPLACE_IDS
|
|
25
|
+
from .jobs import WorkplaceType
|
|
26
|
+
from .search_filters import Params
|
|
27
|
+
from .search_filters import choices
|
|
28
|
+
from .search_filters import flag
|
|
29
|
+
from .search_filters import identifier
|
|
30
|
+
from .search_filters import ids
|
|
31
|
+
from .search_filters import merge
|
|
32
|
+
from .search_filters import optional
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class JobSort(StrEnum):
|
|
36
|
+
MOST_RELEVANT = "R"
|
|
37
|
+
MOST_RECENT = "DD"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class JobDatePosted(StrEnum):
|
|
41
|
+
PAST_24_HOURS = "r86400"
|
|
42
|
+
PAST_WEEK = "r604800"
|
|
43
|
+
PAST_MONTH = "r2592000"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ExperienceLevel(StrEnum):
|
|
47
|
+
INTERNSHIP = "1"
|
|
48
|
+
ENTRY = "2"
|
|
49
|
+
ASSOCIATE = "3"
|
|
50
|
+
MID_SENIOR = "4"
|
|
51
|
+
DIRECTOR = "5"
|
|
52
|
+
EXECUTIVE = "6"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class JobType(StrEnum):
|
|
56
|
+
FULL_TIME = "F"
|
|
57
|
+
PART_TIME = "P"
|
|
58
|
+
CONTRACT = "C"
|
|
59
|
+
TEMPORARY = "T"
|
|
60
|
+
VOLUNTEER = "V"
|
|
61
|
+
INTERNSHIP = "I"
|
|
62
|
+
OTHER = "O"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Commitment(StrEnum):
|
|
66
|
+
"""Employer commitments the app lets job seekers filter by."""
|
|
67
|
+
|
|
68
|
+
DIVERSITY = "1"
|
|
69
|
+
SUSTAINABILITY = "2"
|
|
70
|
+
WORK_LIFE_BALANCE = "3"
|
|
71
|
+
SOCIAL_IMPACT = "4"
|
|
72
|
+
CAREER_GROWTH = "5"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
DISTANCES = (0, 5, 10, 25, 50, 100)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class JobFilters:
|
|
80
|
+
"""Job search filters.
|
|
81
|
+
|
|
82
|
+
`location` is a geo ID or URN (GEO typeahead); `distance` is miles from it (0, 5, 10,
|
|
83
|
+
25, 50 or 100) and `places` narrows to populated places inside it. `salary` is the
|
|
84
|
+
app's bucket index from `Search.job_filters()` (in the US 1 = $40,000+ ... 9 = $200,000+).
|
|
85
|
+
`functions` take codes such as "eng"; `titles` and `companies` take IDs.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
location: str | None = None
|
|
89
|
+
sort_by: JobSort | None = None
|
|
90
|
+
date_posted: JobDatePosted | None = None
|
|
91
|
+
easy_apply: bool = False
|
|
92
|
+
under_10_applicants: bool = False
|
|
93
|
+
in_my_network: bool = False
|
|
94
|
+
verified: bool = False
|
|
95
|
+
workplace: Sequence[WorkplaceType] = ()
|
|
96
|
+
experience: Sequence[ExperienceLevel] = ()
|
|
97
|
+
job_types: Sequence[JobType] = ()
|
|
98
|
+
companies: Sequence[str] = ()
|
|
99
|
+
places: Sequence[str] = ()
|
|
100
|
+
distance: int | None = None
|
|
101
|
+
salary: int | None = None
|
|
102
|
+
industries: Sequence[str] = ()
|
|
103
|
+
functions: Sequence[str] = ()
|
|
104
|
+
titles: Sequence[str] = ()
|
|
105
|
+
commitments: Sequence[Commitment] = ()
|
|
106
|
+
extra: Mapping[str, Sequence[str]] = field(default_factory=dict)
|
|
107
|
+
|
|
108
|
+
def __post_init__(self) -> None:
|
|
109
|
+
self.params()
|
|
110
|
+
self.geo_urn()
|
|
111
|
+
|
|
112
|
+
def geo_urn(self) -> str | None:
|
|
113
|
+
"""The location as the `urn:li:fsd_geo:<id>` the job query takes."""
|
|
114
|
+
if self.location is None:
|
|
115
|
+
return None
|
|
116
|
+
return "urn:li:fsd_geo:" + identifier("location", self.location)
|
|
117
|
+
|
|
118
|
+
def params(self) -> Params:
|
|
119
|
+
if self.distance is not None and (
|
|
120
|
+
type(self.distance) is not int or self.distance not in DISTANCES
|
|
121
|
+
):
|
|
122
|
+
raise ValueError("distance must be one of 0, 5, 10, 25, 50 or 100 miles.")
|
|
123
|
+
if self.salary is not None and (type(self.salary) is not int or not 1 <= self.salary <= 9):
|
|
124
|
+
raise ValueError("salary must be a bucket index from 1 to 9.")
|
|
125
|
+
return merge(
|
|
126
|
+
{
|
|
127
|
+
"sortBy": choices(JobSort, "sort_by", optional(self.sort_by)),
|
|
128
|
+
"timePostedRange": choices(
|
|
129
|
+
JobDatePosted, "date_posted", optional(self.date_posted)
|
|
130
|
+
),
|
|
131
|
+
"applyWithLinkedin": flag("easy_apply", self.easy_apply),
|
|
132
|
+
"earlyApplicant": flag("under_10_applicants", self.under_10_applicants),
|
|
133
|
+
"jobInYourNetwork": flag("in_my_network", self.in_my_network),
|
|
134
|
+
"verifiedJob": flag("verified", self.verified),
|
|
135
|
+
"workplaceType": [
|
|
136
|
+
WORKPLACE_IDS[WorkplaceType(kind)]
|
|
137
|
+
for kind in choices(WorkplaceType, "workplace", self.workplace)
|
|
138
|
+
],
|
|
139
|
+
"experience": choices(ExperienceLevel, "experience", self.experience),
|
|
140
|
+
"jobType": choices(JobType, "job_types", self.job_types),
|
|
141
|
+
"company": ids("companies", self.companies),
|
|
142
|
+
"populatedPlace": ids("places", self.places),
|
|
143
|
+
"distance": [] if self.distance is None else [str(self.distance)],
|
|
144
|
+
"salaryBucketV2": [] if self.salary is None else [str(self.salary)],
|
|
145
|
+
"industry": ids("industries", self.industries),
|
|
146
|
+
"function": ids("functions", self.functions),
|
|
147
|
+
"title": ids("titles", self.titles),
|
|
148
|
+
"commitments": choices(Commitment, "commitments", self.commitments),
|
|
149
|
+
},
|
|
150
|
+
self.extra,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True, slots=True)
|
|
155
|
+
class JobResult:
|
|
156
|
+
"""One job card from job search.
|
|
157
|
+
|
|
158
|
+
`id` is the job posting ID for the jobs feature; `urn` is `urn:li:fsd_jobPosting:<id>`.
|
|
159
|
+
`easy_apply`, `promoted` and `verified` mirror the labels the card shows. `states` are
|
|
160
|
+
the viewer's states for the job (SAVED, APPLIED, VIEWED, APPLICATION_IN_PROGRESS).
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
id: str
|
|
164
|
+
urn: str
|
|
165
|
+
title: str | None
|
|
166
|
+
company: str | None
|
|
167
|
+
company_urn: str | None
|
|
168
|
+
location: str | None
|
|
169
|
+
listed_at: datetime | None
|
|
170
|
+
easy_apply: bool
|
|
171
|
+
promoted: bool
|
|
172
|
+
verified: bool
|
|
173
|
+
reposted: bool | None = None
|
|
174
|
+
applicants: str | None = None
|
|
175
|
+
insight: str | None = None
|
|
176
|
+
states: tuple[str, ...] = ()
|
|
177
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def from_card(cls, card: dict[str, Any]) -> "JobResult | None":
|
|
181
|
+
"""Build from a `JobCard`; None for cards that are not job postings."""
|
|
182
|
+
posting_card = obj(card.get("jobPostingCard"))
|
|
183
|
+
posting = obj(posting_card.get("jobPosting"))
|
|
184
|
+
urn = string(posting, "entityUrn")
|
|
185
|
+
job_id = urn_id(urn)
|
|
186
|
+
if not urn or not job_id:
|
|
187
|
+
return None
|
|
188
|
+
footers = {
|
|
189
|
+
string(footer, "type"): footer for footer in items(posting_card.get("footerItems"))
|
|
190
|
+
}
|
|
191
|
+
listed = integer(footers.get("LISTED_DATE"), "timeAt")
|
|
192
|
+
logo = items(dig(posting_card, "logo", "attributes"))
|
|
193
|
+
states = (
|
|
194
|
+
string(action, "jobSeekerJobStateEnums")
|
|
195
|
+
for action in items(dig(posting_card, "jobSeekerJobState", "jobSeekerJobStateActions"))
|
|
196
|
+
)
|
|
197
|
+
return cls(
|
|
198
|
+
id=job_id,
|
|
199
|
+
urn=urn,
|
|
200
|
+
title=string(posting_card, "jobPostingTitle") or string(posting, "title"),
|
|
201
|
+
company=text(posting_card, "primaryDescription"),
|
|
202
|
+
company_urn=string(logo[0], "detailData", "companyLogo", "entityUrn") if logo else None,
|
|
203
|
+
location=text(posting_card, "secondaryDescription"),
|
|
204
|
+
listed_at=epoch_ms(listed),
|
|
205
|
+
easy_apply="EASY_APPLY_TEXT" in footers,
|
|
206
|
+
promoted="PROMOTED" in footers,
|
|
207
|
+
verified=bool(dig(posting_card, "jobPostingVerification", "badge")),
|
|
208
|
+
reposted=boolean(posting, "repostedJob"),
|
|
209
|
+
applicants=text(footers.get("APPLICANT_COUNT_TEXT"), "text"),
|
|
210
|
+
insight=text(posting_card, "relevanceInsight", "text"),
|
|
211
|
+
states=tuple(state for state in states if state),
|
|
212
|
+
raw=deepcopy(card),
|
|
213
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Resources public exports."""
|
|
2
|
+
|
|
3
|
+
from .account import Account # Read and verify the current member.
|
|
4
|
+
from .comments import Comments # Comment reads, writes and reactions.
|
|
5
|
+
from .feed import Feed # Feed reads and explicit post actions.
|
|
6
|
+
from .jobs import Jobs # Job details, saved jobs, the tracker and Easy Apply.
|
|
7
|
+
from .posts import PostMedia # Uploads, processing status and link previews.
|
|
8
|
+
from .posts import Posts # Create, edit, delete, schedule and repost own posts.
|
|
9
|
+
from .reactions import Reactions # Reactions on posts and comments.
|
|
10
|
+
from .search import Search # Search verticals, filters and typeahead.
|
|
11
|
+
|
|
12
|
+
__all__ = ["Account", "Comments", "Feed", "Jobs", "PostMedia", "Posts", "Reactions", "Search"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Account verification through the mobile SSO endpoint observed after login."""
|
|
2
|
+
|
|
3
|
+
from ..exceptions import AuthenticationError
|
|
4
|
+
from ..exceptions import ProtocolError
|
|
5
|
+
from ..models import Member
|
|
6
|
+
from ..transport import Transport
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Account:
|
|
10
|
+
"""Read and verify the current account without changing server-side state."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, transport: Transport) -> None:
|
|
13
|
+
self._transport = transport
|
|
14
|
+
|
|
15
|
+
async def me(self) -> Member:
|
|
16
|
+
if not self._transport.session.authenticated:
|
|
17
|
+
raise AuthenticationError("A li_at and JSESSIONID session is required.")
|
|
18
|
+
response = await self._transport.request("GET", "/mob/sso/you")
|
|
19
|
+
data = self._transport.json_object(response)
|
|
20
|
+
member_id = data.get("memberID")
|
|
21
|
+
if type(member_id) not in (int, str) or not str(member_id).isdigit():
|
|
22
|
+
raise ProtocolError("Account response did not contain a valid member identity.")
|
|
23
|
+
fields = [data.get(k) for k in ("firstName", "lastName", "fullName", "headline")]
|
|
24
|
+
if not all(isinstance(v, str) for v in fields):
|
|
25
|
+
raise ProtocolError("Account response did not match the captured member schema.")
|
|
26
|
+
state = self._transport.session
|
|
27
|
+
if state.member_id is not None and state.member_id != str(member_id):
|
|
28
|
+
state.clear_authentication()
|
|
29
|
+
self._transport.checkpoint()
|
|
30
|
+
raise AuthenticationError("The session resolved to a different account.")
|
|
31
|
+
state.member_id = str(member_id)
|
|
32
|
+
self._transport.checkpoint()
|
|
33
|
+
return Member(
|
|
34
|
+
str(member_id), data["firstName"], data["lastName"], data["fullName"], data["headline"]
|
|
35
|
+
)
|