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,124 @@
|
|
|
1
|
+
"""Feed options and the feed results that are not posts: reactors and saved posts."""
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from dataclasses import field
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ._json import dig
|
|
10
|
+
from ._json import items
|
|
11
|
+
from ._json import obj
|
|
12
|
+
from ._json import string
|
|
13
|
+
from ._json import text
|
|
14
|
+
from .content import Author
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FeedSort(StrEnum):
|
|
18
|
+
"""The main feed's orders (pegasus `dash/feed/SortOrder`); UI labels are in comments."""
|
|
19
|
+
|
|
20
|
+
RELEVANCE = "RELEVANCE" # Top
|
|
21
|
+
RECENT = "REV_CHRON" # Recent
|
|
22
|
+
MEMBER_SETTING = "MEMBER_SETTING" # the member's saved preference
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ProfileActivity(StrEnum):
|
|
26
|
+
"""The activity tabs of a member's profile."""
|
|
27
|
+
|
|
28
|
+
POSTS = "POSTS"
|
|
29
|
+
COMMENTS = "COMMENTS"
|
|
30
|
+
REACTIONS = "REACTIONS"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CompanyContent(StrEnum):
|
|
34
|
+
"""Which of a company page's posts to list (pegasus `OrganizationalPageFeedFilterType`)."""
|
|
35
|
+
|
|
36
|
+
ALL = "ALL"
|
|
37
|
+
IMAGES = "IMAGES"
|
|
38
|
+
VIDEOS = "VIDEOS"
|
|
39
|
+
DOCUMENTS = "DOCUMENTS"
|
|
40
|
+
ARTICLES = "ARTICLES"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ReactionSort(StrEnum):
|
|
44
|
+
"""The reactions list orders (pegasus `ReactionSortOrder`); UI labels are in comments."""
|
|
45
|
+
|
|
46
|
+
RELEVANCE = "RELEVANCE" # Most relevant
|
|
47
|
+
NEWEST = "REVERSE_CHRONOLOGICAL" # Newest first
|
|
48
|
+
OLDEST = "CHRONOLOGICAL" # Oldest first
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class Reactor:
|
|
53
|
+
"""Someone who reacted to a post or comment.
|
|
54
|
+
|
|
55
|
+
`urn` is the reactor's profile (`urn:li:fsd_profile:<id>`) or page URN; `reaction` is the
|
|
56
|
+
reaction name, such as `LIKE` or `PRAISE`; `degree` is the network label, such as `2nd`.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
urn: str | None
|
|
60
|
+
reaction: str | None
|
|
61
|
+
name: str | None
|
|
62
|
+
headline: str | None
|
|
63
|
+
degree: str | None
|
|
64
|
+
url: str | None
|
|
65
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_reaction(cls, reaction: dict[str, Any]) -> "Reactor | None":
|
|
69
|
+
"""Read a dash `Reaction` with its `reactorLockup` card; None without a reactor."""
|
|
70
|
+
if not string(reaction, "actorUrn"):
|
|
71
|
+
return None
|
|
72
|
+
lockup = obj(reaction.get("reactorLockup"))
|
|
73
|
+
url = string(lockup, "navigationUrl")
|
|
74
|
+
return cls(
|
|
75
|
+
urn=string(reaction, "actorUrn"),
|
|
76
|
+
reaction=string(reaction, "reactionType"),
|
|
77
|
+
name=text(lockup, "title"),
|
|
78
|
+
headline=text(lockup, "subtitle"),
|
|
79
|
+
degree=text(lockup, "label"),
|
|
80
|
+
url=url.split("?", 1)[0] if url else None,
|
|
81
|
+
raw=deepcopy(reaction),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True, slots=True)
|
|
86
|
+
class SavedPost:
|
|
87
|
+
"""A post in the viewer's saved items.
|
|
88
|
+
|
|
89
|
+
`urn` is the post's activity URN, which `Feed.unsave` takes. The list shows a summary
|
|
90
|
+
card, so `text` may be shortened; `Feed.get(urn)` reads the full post.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
urn: str
|
|
94
|
+
author: Author | None
|
|
95
|
+
text: str | None
|
|
96
|
+
age: str | None
|
|
97
|
+
url: str | None
|
|
98
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_result(cls, result: dict[str, Any]) -> "SavedPost | None":
|
|
102
|
+
"""Read a search `EntityResultViewModel` from the saved-posts list."""
|
|
103
|
+
urn = string(result, "trackingUrn")
|
|
104
|
+
if not urn:
|
|
105
|
+
return None
|
|
106
|
+
attributes = items(dig(result, "image", "attributes"))
|
|
107
|
+
detail = obj(attributes[0].get("detailData")) if attributes else {}
|
|
108
|
+
author_url = string(result, "actorNavigationUrl")
|
|
109
|
+
age = text(result, "secondarySubtitle")
|
|
110
|
+
url = string(result, "navigationUrl")
|
|
111
|
+
return cls(
|
|
112
|
+
urn=urn,
|
|
113
|
+
author=Author(
|
|
114
|
+
urn=string(result, "actorTrackingUrn"),
|
|
115
|
+
name=text(result, "title"),
|
|
116
|
+
headline=text(result, "primarySubtitle"),
|
|
117
|
+
profile_urn=string(detail, "nonEntityProfilePicture", "profile", "entityUrn"),
|
|
118
|
+
url=author_url.split("?", 1)[0] if author_url else None,
|
|
119
|
+
),
|
|
120
|
+
text=text(result, "summary"),
|
|
121
|
+
age=(age.split("•", 1)[0].strip() or None) if age else None,
|
|
122
|
+
url=url.split("?", 1)[0] if url else None,
|
|
123
|
+
raw=deepcopy(result),
|
|
124
|
+
)
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Job postings and the viewer's job tracker, as the Android job details screen reads them."""
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from dataclasses import field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ._json import boolean
|
|
11
|
+
from ._json import dig
|
|
12
|
+
from ._json import epoch_ms
|
|
13
|
+
from ._json import integer
|
|
14
|
+
from ._json import items
|
|
15
|
+
from ._json import obj
|
|
16
|
+
from ._json import string
|
|
17
|
+
from ._json import text
|
|
18
|
+
from ._json import urn_id
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class WorkplaceType(StrEnum):
|
|
22
|
+
"""Where the work happens (the app's `WorkplaceTypeEnum`)."""
|
|
23
|
+
|
|
24
|
+
ON_SITE = "ON_SITE"
|
|
25
|
+
REMOTE = "REMOTE"
|
|
26
|
+
HYBRID = "HYBRID"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# `voyagerJobsDashWorkplaceTypes` lists these IDs for the three workplace types; job search
|
|
30
|
+
# filters take the same IDs.
|
|
31
|
+
WORKPLACE_IDS = {
|
|
32
|
+
WorkplaceType.ON_SITE: "1",
|
|
33
|
+
WorkplaceType.REMOTE: "2",
|
|
34
|
+
WorkplaceType.HYBRID: "3",
|
|
35
|
+
}
|
|
36
|
+
_WORKPLACE_BY_ID = {number: kind for kind, number in WORKPLACE_IDS.items()}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TrackedStatus(StrEnum):
|
|
40
|
+
"""The job tracker's lists (the "My jobs" `cardType` filter values)."""
|
|
41
|
+
|
|
42
|
+
SAVED = "SAVED"
|
|
43
|
+
IN_PROGRESS = "IN_PROGRESS"
|
|
44
|
+
APPLIED = "APPLIED"
|
|
45
|
+
ARCHIVED = "ARCHIVED"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class Job:
|
|
50
|
+
"""One job posting with the viewer's apply and save state.
|
|
51
|
+
|
|
52
|
+
`location` and `applicants_text` are the text LinkedIn shows; `applicants` is the
|
|
53
|
+
posting's apply count (for external jobs, the number of apply clicks). `apply_url` is
|
|
54
|
+
the company's site, or LinkedIn's own apply page for Easy Apply jobs.
|
|
55
|
+
`reference_id` and `tracking_id` are the job details tracking values the app sends
|
|
56
|
+
back with an Easy Apply submission.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
id: str
|
|
60
|
+
urn: str
|
|
61
|
+
title: str | None
|
|
62
|
+
company: str | None
|
|
63
|
+
company_urn: str | None
|
|
64
|
+
location: str | None
|
|
65
|
+
location_urn: str | None
|
|
66
|
+
workplace_types: tuple[WorkplaceType, ...]
|
|
67
|
+
employment_type: str | None
|
|
68
|
+
description: str | None
|
|
69
|
+
listed_at: datetime | None
|
|
70
|
+
applicants: int | None
|
|
71
|
+
applicants_text: str | None
|
|
72
|
+
state: str | None
|
|
73
|
+
easy_apply: bool | None
|
|
74
|
+
apply_url: str | None
|
|
75
|
+
applied: bool | None
|
|
76
|
+
applied_at: datetime | None
|
|
77
|
+
saved: bool | None
|
|
78
|
+
url: str | None
|
|
79
|
+
reference_id: str | None = field(default=None, repr=False)
|
|
80
|
+
tracking_id: str | None = field(default=None, repr=False)
|
|
81
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_json(cls, job_id: str, posting: dict[str, Any], sections: dict[str, Any]) -> "Job":
|
|
85
|
+
"""Combine the posting record with the job details top card and description."""
|
|
86
|
+
top = obj(_section(sections, "topCardV2"))
|
|
87
|
+
card = obj(top.get("jobPostingCard"))
|
|
88
|
+
apply = obj(
|
|
89
|
+
dig(card, "primaryActionV2", "applyJobAction", "applyJobActionResolutionResult")
|
|
90
|
+
)
|
|
91
|
+
location, shown_at, applicants_text = _subtitle_parts(obj(card.get("tertiaryDescription")))
|
|
92
|
+
workplace = [
|
|
93
|
+
_WORKPLACE_BY_ID.get(urn_id(urn) or "")
|
|
94
|
+
for urn in posting.get("workplaceTypes") or []
|
|
95
|
+
if isinstance(urn, str)
|
|
96
|
+
]
|
|
97
|
+
listed = integer(posting, "listedAt")
|
|
98
|
+
return cls(
|
|
99
|
+
id=job_id,
|
|
100
|
+
urn=f"urn:li:fsd_jobPosting:{job_id}",
|
|
101
|
+
title=string(posting, "title") or string(card, "jobPostingTitle"),
|
|
102
|
+
company=string(posting, "companyDetails", "name") or text(card, "primaryDescription"),
|
|
103
|
+
company_urn=string(posting, "companyDetails", "companyUrn")
|
|
104
|
+
or string(card, "jobPosting", "companyDetails", "jobCompany", "company", "entityUrn"),
|
|
105
|
+
location=location,
|
|
106
|
+
location_urn=string(posting, "locationUrn"),
|
|
107
|
+
workplace_types=tuple(kind for kind in workplace if kind is not None),
|
|
108
|
+
employment_type=urn_id(string(posting, "employmentStatusUrn")),
|
|
109
|
+
description=text(_section(sections, "jobDescription"), "jobPosting", "description")
|
|
110
|
+
or text(posting, "description"),
|
|
111
|
+
listed_at=epoch_ms(listed) if listed is not None else shown_at,
|
|
112
|
+
applicants=integer(posting, "numApplies"),
|
|
113
|
+
applicants_text=applicants_text,
|
|
114
|
+
state=string(posting, "jobState"),
|
|
115
|
+
easy_apply=boolean(apply, "onsiteApply"),
|
|
116
|
+
apply_url=string(apply, "companyApplyUrl") or string(posting, "companyApplyUrl"),
|
|
117
|
+
applied=boolean(apply, "applied"),
|
|
118
|
+
applied_at=epoch_ms(integer(apply, "appliedAt")),
|
|
119
|
+
saved=_saved(card),
|
|
120
|
+
url=string(posting, "jobPostingUrl"),
|
|
121
|
+
reference_id=string(top, "jobTrackingData", "referenceId"),
|
|
122
|
+
tracking_id=string(top, "jobTrackingData", "trackingId"),
|
|
123
|
+
raw={"posting": deepcopy(posting), "sections": deepcopy(sections)},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True, slots=True)
|
|
128
|
+
class TrackedJob:
|
|
129
|
+
"""A job in one of the viewer's tracker lists (saved, in progress, applied, archived)."""
|
|
130
|
+
|
|
131
|
+
id: str
|
|
132
|
+
title: str | None
|
|
133
|
+
company: str | None
|
|
134
|
+
location: str | None
|
|
135
|
+
insights: tuple[str, ...]
|
|
136
|
+
easy_apply: bool
|
|
137
|
+
saved: bool | None
|
|
138
|
+
saved_at: datetime | None
|
|
139
|
+
url: str | None
|
|
140
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def from_result(cls, result: dict[str, Any]) -> "TrackedJob | None":
|
|
144
|
+
"""Read a tracker `entityResult`; None when it is not a job posting."""
|
|
145
|
+
tracking = string(result, "trackingUrn") or ""
|
|
146
|
+
if not tracking.startswith("urn:li:fsd_jobPosting:"):
|
|
147
|
+
return None
|
|
148
|
+
actions = items(result.get("overflowActions"))
|
|
149
|
+
saves = [obj(dig(a, "actionDetails", "saveAction")) for a in actions]
|
|
150
|
+
save = next((found for found in saves if found), {})
|
|
151
|
+
insights = [
|
|
152
|
+
title.strip()
|
|
153
|
+
for insight in items(result.get("insightsResolutionResults"))
|
|
154
|
+
if (title := text(insight, "simpleInsight", "title"))
|
|
155
|
+
]
|
|
156
|
+
url = string(result, "navigationUrl")
|
|
157
|
+
return cls(
|
|
158
|
+
id=tracking.rsplit(":", 1)[-1],
|
|
159
|
+
title=text(result, "title"),
|
|
160
|
+
company=text(result, "primarySubtitle"),
|
|
161
|
+
location=text(result, "secondarySubtitle"),
|
|
162
|
+
insights=tuple(insights),
|
|
163
|
+
easy_apply=any(a.get("searchActionType") == "EASY_APPLY_TO_JOB" for a in actions),
|
|
164
|
+
saved=boolean(save, "saved"),
|
|
165
|
+
saved_at=epoch_ms(integer(save, "savedAt")),
|
|
166
|
+
url=url.split("?", 1)[0] if url else None,
|
|
167
|
+
raw=deepcopy(result),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _section(sections: dict[str, Any], name: str) -> Any:
|
|
172
|
+
"""The first detail section card with this name from the sections finder reply."""
|
|
173
|
+
for element in items(sections.get("elements")):
|
|
174
|
+
for section in items(element.get("jobPostingDetailSection")):
|
|
175
|
+
if isinstance(section.get(name), dict):
|
|
176
|
+
return section[name]
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _saved(card: dict[str, Any]) -> bool | None:
|
|
181
|
+
for action in items(card.get("secondaryActionsV2ResolutionResults")):
|
|
182
|
+
saved = boolean(action, "saveJobAction", "saveStateResolutionResult", "saved")
|
|
183
|
+
if saved is not None:
|
|
184
|
+
return saved
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _subtitle_parts(
|
|
189
|
+
description: dict[str, Any],
|
|
190
|
+
) -> tuple[str | None, datetime | None, str | None]:
|
|
191
|
+
"""Split "Location · 15 hours ago · 1 applicant" around its time-ago span.
|
|
192
|
+
|
|
193
|
+
The span carrying the posting time is marked by an `epoch` attribute; the location is
|
|
194
|
+
the text before it and the applicant count the text after it, up to the next separator
|
|
195
|
+
or the paragraph that follows on its own line.
|
|
196
|
+
"""
|
|
197
|
+
value = string(description, "text")
|
|
198
|
+
if not value:
|
|
199
|
+
return None, None, None
|
|
200
|
+
attributes = items(description.get("attributesV2"))
|
|
201
|
+
epoch = next((a for a in attributes if dig(a, "detailData", "epoch")), None)
|
|
202
|
+
if epoch is None:
|
|
203
|
+
return value.split(" · ", 1)[0].strip() or None, None, None
|
|
204
|
+
start, length = integer(epoch, "start") or 0, integer(epoch, "length") or 0
|
|
205
|
+
paragraph = min(
|
|
206
|
+
(
|
|
207
|
+
integer(a, "start") or len(value)
|
|
208
|
+
for a in attributes
|
|
209
|
+
if dig(a, "detailData", "style") == "PARAGRAPH"
|
|
210
|
+
),
|
|
211
|
+
default=len(value),
|
|
212
|
+
)
|
|
213
|
+
before = value[:start].rstrip(" ·").strip()
|
|
214
|
+
after = value[start + length : paragraph].strip(" ·").split(" · ", 1)[0].strip()
|
|
215
|
+
return before or None, epoch_ms(integer(epoch, "detailData", "epoch", "epochAt")), after or None
|