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,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