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.
Files changed (64) hide show
  1. async_linkedinlib/__init__.py +187 -0
  2. async_linkedinlib/__main__.py +3 -0
  3. async_linkedinlib/auth/__init__.py +5 -0
  4. async_linkedinlib/auth/service.py +77 -0
  5. async_linkedinlib/cli.py +37 -0
  6. async_linkedinlib/client.py +93 -0
  7. async_linkedinlib/config/__init__.py +6 -0
  8. async_linkedinlib/config/options.py +15 -0
  9. async_linkedinlib/config/profile.py +56 -0
  10. async_linkedinlib/exceptions.py +75 -0
  11. async_linkedinlib/models/__init__.py +155 -0
  12. async_linkedinlib/models/_json.py +65 -0
  13. async_linkedinlib/models/comments.py +145 -0
  14. async_linkedinlib/models/content.py +207 -0
  15. async_linkedinlib/models/feed.py +124 -0
  16. async_linkedinlib/models/jobs.py +215 -0
  17. async_linkedinlib/models/jobs_apply.py +424 -0
  18. async_linkedinlib/models/member.py +14 -0
  19. async_linkedinlib/models/pagination.py +52 -0
  20. async_linkedinlib/models/posts.py +141 -0
  21. async_linkedinlib/models/posts_results.py +120 -0
  22. async_linkedinlib/models/search.py +307 -0
  23. async_linkedinlib/models/search_filters.py +339 -0
  24. async_linkedinlib/models/search_jobs.py +213 -0
  25. async_linkedinlib/py.typed +0 -0
  26. async_linkedinlib/resources/__init__.py +12 -0
  27. async_linkedinlib/resources/account.py +35 -0
  28. async_linkedinlib/resources/comments/__init__.py +5 -0
  29. async_linkedinlib/resources/comments/resource.py +227 -0
  30. async_linkedinlib/resources/comments/urns.py +43 -0
  31. async_linkedinlib/resources/feed/__init__.py +5 -0
  32. async_linkedinlib/resources/feed/queries.py +57 -0
  33. async_linkedinlib/resources/feed/resource.py +225 -0
  34. async_linkedinlib/resources/feed/saved.py +49 -0
  35. async_linkedinlib/resources/feed/urns.py +68 -0
  36. async_linkedinlib/resources/jobs/__init__.py +5 -0
  37. async_linkedinlib/resources/jobs/answers.py +239 -0
  38. async_linkedinlib/resources/jobs/resource.py +212 -0
  39. async_linkedinlib/resources/jobs/upload.py +78 -0
  40. async_linkedinlib/resources/mentions.py +58 -0
  41. async_linkedinlib/resources/paging.py +88 -0
  42. async_linkedinlib/resources/posts/__init__.py +6 -0
  43. async_linkedinlib/resources/posts/media.py +173 -0
  44. async_linkedinlib/resources/posts/payloads.py +98 -0
  45. async_linkedinlib/resources/posts/resource.py +254 -0
  46. async_linkedinlib/resources/reactions.py +89 -0
  47. async_linkedinlib/resources/search/__init__.py +5 -0
  48. async_linkedinlib/resources/search/queries.py +79 -0
  49. async_linkedinlib/resources/search/results.py +85 -0
  50. async_linkedinlib/resources/search/search.py +284 -0
  51. async_linkedinlib/resources/threads.py +30 -0
  52. async_linkedinlib/session/__init__.py +5 -0
  53. async_linkedinlib/session/cookies.py +52 -0
  54. async_linkedinlib/session/lease.py +30 -0
  55. async_linkedinlib/session/state.py +129 -0
  56. async_linkedinlib/transport/__init__.py +6 -0
  57. async_linkedinlib/transport/http.py +183 -0
  58. async_linkedinlib/transport/restli.py +46 -0
  59. async_linkedinlib/transport/voyager.py +151 -0
  60. async_linkedinlib-0.2.0.dist-info/METADATA +478 -0
  61. async_linkedinlib-0.2.0.dist-info/RECORD +64 -0
  62. async_linkedinlib-0.2.0.dist-info/WHEEL +4 -0
  63. async_linkedinlib-0.2.0.dist-info/entry_points.txt +2 -0
  64. 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
+ )
@@ -0,0 +1,5 @@
1
+ """Comments resource exports."""
2
+
3
+ from .resource import Comments # Comment reads, writes and reactions.
4
+
5
+ __all__ = ["Comments"]