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,239 @@
|
|
|
1
|
+
"""Check answers against an Easy Apply form and encode the app's `submitApplication` body.
|
|
2
|
+
|
|
3
|
+
Encoding follows the app's FormsResponseBuilderUtils and JobApplyFeature:
|
|
4
|
+
- text and numbers are sent trimmed as `textInputValue`;
|
|
5
|
+
- a choice is an `entityInputValue` with the option's enum string as `inputEntityName`, or
|
|
6
|
+
its label plus `inputEntityUrn` when it has no enum string;
|
|
7
|
+
- each response repeats its values as `formElementInputValues` and
|
|
8
|
+
`formElementInputValuesResolutionResults`;
|
|
9
|
+
- a question left unanswered keeps LinkedIn's prefilled values when they are complete;
|
|
10
|
+
- file slots are answered with `{formElementUrn, inputUrn}` in `fileUploadResponses`.
|
|
11
|
+
The body starts with `followCompany`, `referenceId` and `trackingCode` (empty strings when
|
|
12
|
+
unknown, as the app sends them) and the job's tracking ID as a 16-character byte string.
|
|
13
|
+
Forms that need a data-consent step or repeatable sections are refused.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import binascii
|
|
18
|
+
import math
|
|
19
|
+
import re
|
|
20
|
+
from collections.abc import Mapping
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
from decimal import Decimal
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from ...models.jobs_apply import ApplicationForm
|
|
26
|
+
from ...models.jobs_apply import FileQuestion
|
|
27
|
+
from ...models.jobs_apply import Option
|
|
28
|
+
from ...models.jobs_apply import Question
|
|
29
|
+
from ...models.jobs_apply import QuestionKind
|
|
30
|
+
from ...models.jobs_apply import Resume
|
|
31
|
+
from ...models.jobs_apply import Submission
|
|
32
|
+
from ...models.jobs_apply import UploadedFile
|
|
33
|
+
from .upload import DOCUMENT_TYPES
|
|
34
|
+
|
|
35
|
+
_DECIMAL = re.compile(r"-?[0-9]+(\.[0-9]+)?")
|
|
36
|
+
|
|
37
|
+
Answer = str | int | float | Sequence[str]
|
|
38
|
+
FileAnswer = str | UploadedFile | Resume
|
|
39
|
+
|
|
40
|
+
_TEXT = frozenset({QuestionKind.TEXT, QuestionKind.MULTILINE_TEXT, QuestionKind.NUMBER})
|
|
41
|
+
_CHOICE = frozenset({QuestionKind.SINGLE_CHOICE, QuestionKind.DROPDOWN, QuestionKind.CHECKBOX})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build(
|
|
45
|
+
form: ApplicationForm,
|
|
46
|
+
answers: Mapping[str, Answer],
|
|
47
|
+
files: Mapping[str, FileAnswer] | None = None,
|
|
48
|
+
*,
|
|
49
|
+
follow_company: bool = False,
|
|
50
|
+
reference_id: str | None = None,
|
|
51
|
+
tracking_id: str | None = None,
|
|
52
|
+
) -> Submission:
|
|
53
|
+
if form.needs_data_consent:
|
|
54
|
+
raise ValueError("This form asks for data consent first, which this client does not send.")
|
|
55
|
+
if form.has_repeatable_sections:
|
|
56
|
+
raise ValueError("This form has repeatable sections, which this client does not build.")
|
|
57
|
+
chosen: dict[str, list[dict[str, Any]]] = {}
|
|
58
|
+
for key, value in answers.items():
|
|
59
|
+
question = form.question(key)
|
|
60
|
+
if question.id in chosen:
|
|
61
|
+
raise ValueError(f"{_name(question)} is answered twice.")
|
|
62
|
+
chosen[question.id] = _values(question, value)
|
|
63
|
+
responses: list[dict[str, Any]] = []
|
|
64
|
+
shown: list[tuple[str, tuple[str, ...]]] = []
|
|
65
|
+
missing: list[str] = []
|
|
66
|
+
for question in form.questions:
|
|
67
|
+
values = chosen.get(question.id) or _prefilled(question)
|
|
68
|
+
if values:
|
|
69
|
+
responses.append(_response(question.id, values))
|
|
70
|
+
shown.append((question.label or question.id, tuple(_display(v) for v in values)))
|
|
71
|
+
elif question.required and not question.conditional:
|
|
72
|
+
missing.append(_name(question))
|
|
73
|
+
picked = {}
|
|
74
|
+
for key, attached in (files or {}).items():
|
|
75
|
+
slot = form.file(key)
|
|
76
|
+
picked[slot.id] = _file_urn(slot, attached)
|
|
77
|
+
uploads = []
|
|
78
|
+
for slot in form.files:
|
|
79
|
+
urn = picked.get(slot.id) or slot.prefilled
|
|
80
|
+
if urn:
|
|
81
|
+
uploads.append({"formElementUrn": slot.id, "inputUrn": urn})
|
|
82
|
+
shown.append((slot.label or slot.id, (urn,)))
|
|
83
|
+
elif slot.required:
|
|
84
|
+
missing.append(slot.label or slot.id)
|
|
85
|
+
if missing:
|
|
86
|
+
raise ValueError("Answer the required questions: " + "; ".join(missing))
|
|
87
|
+
body: dict[str, Any] = {
|
|
88
|
+
"followCompany": bool(follow_company),
|
|
89
|
+
"referenceId": reference_id or "",
|
|
90
|
+
# The app's `trk` code from the screen that opened the job; empty when none.
|
|
91
|
+
"trackingCode": "",
|
|
92
|
+
}
|
|
93
|
+
if tracking := tracking_bytes(tracking_id):
|
|
94
|
+
body["trackingId"] = tracking
|
|
95
|
+
body["responses"] = responses
|
|
96
|
+
body["fileUploadResponses"] = uploads
|
|
97
|
+
return Submission(job_id=form.job_id, body=body, answers=tuple(shown))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def tracking_bytes(tracking_id: str | None) -> str | None:
|
|
101
|
+
"""The app's TrackingUtils: a base64 tracking ID as its 16 bytes, one char per byte."""
|
|
102
|
+
if not tracking_id:
|
|
103
|
+
return None
|
|
104
|
+
try:
|
|
105
|
+
raw = base64.b64decode(tracking_id, validate=True)
|
|
106
|
+
except (binascii.Error, ValueError):
|
|
107
|
+
return None
|
|
108
|
+
return raw[:16].decode("latin-1") if len(raw) >= 16 else None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _values(question: Question, value: Answer) -> list[dict[str, Any]]:
|
|
112
|
+
if question.kind is QuestionKind.OTHER:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"{_name(question)} uses {question.component}, which this client cannot answer."
|
|
115
|
+
)
|
|
116
|
+
if question.kind is QuestionKind.NUMBER:
|
|
117
|
+
return [{"textInputValue": _number(question, value)}]
|
|
118
|
+
if question.kind in _TEXT:
|
|
119
|
+
if not isinstance(value, str):
|
|
120
|
+
raise ValueError(f"{_name(question)} takes text.")
|
|
121
|
+
answer = value.strip()
|
|
122
|
+
if not answer:
|
|
123
|
+
raise ValueError(f"{_name(question)} is empty.")
|
|
124
|
+
# Android counts text in UTF-16 units, so an emoji uses two of the limit.
|
|
125
|
+
length = len(answer.encode("utf-16-le")) // 2
|
|
126
|
+
if question.max_length is not None and length > question.max_length:
|
|
127
|
+
raise ValueError(f"{_name(question)} allows at most {question.max_length} characters.")
|
|
128
|
+
if question.min_length is not None and length < question.min_length:
|
|
129
|
+
raise ValueError(f"{_name(question)} needs at least {question.min_length} characters.")
|
|
130
|
+
return [{"textInputValue": answer}]
|
|
131
|
+
if isinstance(value, int | float):
|
|
132
|
+
raise ValueError(f"{_name(question)} takes the label of one of its options.")
|
|
133
|
+
picks = [value] if isinstance(value, str) else list(value)
|
|
134
|
+
if not all(isinstance(pick, str) for pick in picks):
|
|
135
|
+
raise ValueError(f"{_name(question)} takes the label of one of its options.")
|
|
136
|
+
options = list(dict.fromkeys(_option(question, pick) for pick in picks))
|
|
137
|
+
if not options:
|
|
138
|
+
raise ValueError(f"{_name(question)} needs a choice.")
|
|
139
|
+
if question.kind is not QuestionKind.CHECKBOX and len(options) > 1:
|
|
140
|
+
raise ValueError(f"{_name(question)} takes one choice.")
|
|
141
|
+
if question.max_choices is not None and len(options) > question.max_choices:
|
|
142
|
+
raise ValueError(f"{_name(question)} allows at most {question.max_choices} choices.")
|
|
143
|
+
if question.min_choices is not None and len(options) < question.min_choices:
|
|
144
|
+
raise ValueError(f"{_name(question)} needs at least {question.min_choices} choices.")
|
|
145
|
+
return [{"entityInputValue": _entity(option)} for option in options]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _number(question: Question, value: Answer) -> str:
|
|
149
|
+
"""The number as the app sends it; text must be a plain decimal such as -12 or 3.5."""
|
|
150
|
+
if isinstance(value, bool) or not isinstance(value, int | float | str):
|
|
151
|
+
raise ValueError(f"{_name(question)} takes a number.")
|
|
152
|
+
if isinstance(value, str):
|
|
153
|
+
if not _DECIMAL.fullmatch(value.strip()):
|
|
154
|
+
raise ValueError(f"{_name(question)} takes a plain decimal number.")
|
|
155
|
+
number = Decimal(value.strip())
|
|
156
|
+
elif isinstance(value, float) and not math.isfinite(value):
|
|
157
|
+
raise ValueError(f"{_name(question)} takes a finite number.")
|
|
158
|
+
else:
|
|
159
|
+
number = Decimal(repr(value)) if isinstance(value, float) else Decimal(value)
|
|
160
|
+
if question.whole_number and number != number.to_integral_value():
|
|
161
|
+
raise ValueError(f"{_name(question)} takes a whole number.")
|
|
162
|
+
if question.minimum is not None and number < Decimal(repr(question.minimum)):
|
|
163
|
+
raise ValueError(f"{_name(question)} must be at least {question.minimum:g}.")
|
|
164
|
+
if question.maximum is not None and number > Decimal(repr(question.maximum)):
|
|
165
|
+
raise ValueError(f"{_name(question)} must be at most {question.maximum:g}.")
|
|
166
|
+
return format(number.normalize(), "f")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _option(question: Question, pick: str) -> Option:
|
|
170
|
+
# A label wins over an enum string, which wins over a URN.
|
|
171
|
+
for attribute in ("label", "value", "urn"):
|
|
172
|
+
found = [option for option in question.options if getattr(option, attribute) == pick]
|
|
173
|
+
if found:
|
|
174
|
+
return found[0]
|
|
175
|
+
labels = ", ".join(repr(option.label) for option in question.options[:10])
|
|
176
|
+
raise ValueError(f"{_name(question)} has no option {pick!r}; options: {labels}")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _entity(option: Option) -> dict[str, str]:
|
|
180
|
+
if option.value is not None:
|
|
181
|
+
return {"inputEntityName": option.value}
|
|
182
|
+
entity = {}
|
|
183
|
+
if option.label is not None:
|
|
184
|
+
entity["inputEntityName"] = option.label
|
|
185
|
+
if option.urn is not None:
|
|
186
|
+
entity["inputEntityUrn"] = option.urn
|
|
187
|
+
return entity
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _prefilled(question: Question) -> list[dict[str, Any]]:
|
|
191
|
+
"""LinkedIn's prefill when complete enough to send, as the app checks before sending."""
|
|
192
|
+
values = list(question.input_values)
|
|
193
|
+
if not values:
|
|
194
|
+
return []
|
|
195
|
+
if question.kind in _TEXT:
|
|
196
|
+
first = values[0].get("textInputValue")
|
|
197
|
+
if not isinstance(first, str) or not first:
|
|
198
|
+
return []
|
|
199
|
+
if question.max_length is not None and len(first) > question.max_length:
|
|
200
|
+
return []
|
|
201
|
+
elif question.kind in _CHOICE and not all(value.get("entityInputValue") for value in values):
|
|
202
|
+
return []
|
|
203
|
+
return values
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _file_urn(slot: FileQuestion, value: FileAnswer) -> str:
|
|
207
|
+
if isinstance(value, Resume):
|
|
208
|
+
return value.urn
|
|
209
|
+
if isinstance(value, UploadedFile):
|
|
210
|
+
kinds = [kind for kind, mime in DOCUMENT_TYPES.items() if mime == value.content_type]
|
|
211
|
+
if slot.mime_types and not set(kinds) & set(slot.mime_types):
|
|
212
|
+
raise ValueError(f"{slot.label or slot.id} accepts {', '.join(slot.mime_types)} files.")
|
|
213
|
+
if slot.max_size is not None and value.size > slot.max_size:
|
|
214
|
+
raise ValueError(f"{slot.label or slot.id} accepts files up to {slot.max_size} bytes.")
|
|
215
|
+
return value.urn
|
|
216
|
+
if isinstance(value, str) and value.startswith("urn:li:"):
|
|
217
|
+
return value
|
|
218
|
+
raise ValueError("A file slot takes an UploadedFile, a Resume or a resume URN.")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _response(urn: str, values: list[dict[str, Any]]) -> dict[str, Any]:
|
|
222
|
+
return {
|
|
223
|
+
"formElementUrn": urn,
|
|
224
|
+
"formElementInputValues": values,
|
|
225
|
+
"formElementInputValuesResolutionResults": values,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _display(value: dict[str, Any]) -> str:
|
|
230
|
+
if isinstance(value.get("textInputValue"), str):
|
|
231
|
+
return str(value["textInputValue"])
|
|
232
|
+
entity = value.get("entityInputValue")
|
|
233
|
+
if isinstance(entity, dict):
|
|
234
|
+
return str(entity.get("inputEntityName") or entity.get("inputEntityUrn") or "")
|
|
235
|
+
return str(next(iter(value.values()), ""))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _name(question: Question) -> str:
|
|
239
|
+
return repr(question.label) if question.label else question.id
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Job details, saved jobs, the job tracker and Easy Apply through the app's Voyager calls.
|
|
2
|
+
|
|
3
|
+
- `get`: the posting record (`voyagerJobsDashJobPostings/<urn>`) plus the job details top
|
|
4
|
+
card and description (`JobPostingDetailSectionsByCardSectionTypesV2`, cards TOP_CARD_V2
|
|
5
|
+
and JOB_DESCRIPTION_CARD, as the job details screen asks).
|
|
6
|
+
- `save`/`unsave`: a `{"patch": {"$set": {"saved": ...}}}` on the job's save state
|
|
7
|
+
(`voyagerFeedDashSaveStates/urn:li:fsd_saveState:(SAVE,<job urn>)`), as SaveJobManager.
|
|
8
|
+
- `tracked`: the "My jobs" tracker, `SearchClusterCollection` with intent
|
|
9
|
+
SEARCH_MY_ITEMS_JOB_SEEKER and a `cardType` filter.
|
|
10
|
+
- `application`/`upload_file`/`submission`/`submit`: the Easy Apply steps of
|
|
11
|
+
JobApplyRepositoryImpl. Only `submit` applies, and it is sent once.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
|
|
18
|
+
from ...exceptions import ProtocolError
|
|
19
|
+
from ...models._json import dig
|
|
20
|
+
from ...models._json import integer
|
|
21
|
+
from ...models._json import items
|
|
22
|
+
from ...models._json import obj
|
|
23
|
+
from ...models._json import string
|
|
24
|
+
from ...models.jobs import Job
|
|
25
|
+
from ...models.jobs import TrackedJob
|
|
26
|
+
from ...models.jobs import TrackedStatus
|
|
27
|
+
from ...models.jobs_apply import ApplicationForm
|
|
28
|
+
from ...models.jobs_apply import ApplicationStatus
|
|
29
|
+
from ...models.jobs_apply import Submission
|
|
30
|
+
from ...models.jobs_apply import UploadedFile
|
|
31
|
+
from ...models.pagination import Cursor
|
|
32
|
+
from ...models.pagination import Page
|
|
33
|
+
from ...transport.voyager import GraphQLQuery
|
|
34
|
+
from ...transport.voyager import Voyager
|
|
35
|
+
from .. import paging
|
|
36
|
+
from ..search.queries import CLUSTERS
|
|
37
|
+
from . import answers as answer_rules
|
|
38
|
+
from . import upload as uploads
|
|
39
|
+
from .answers import Answer
|
|
40
|
+
from .answers import FileAnswer
|
|
41
|
+
|
|
42
|
+
POSTINGS = "voyagerJobsDashJobPostings"
|
|
43
|
+
SAVE_STATES = "voyagerFeedDashSaveStates"
|
|
44
|
+
ONSITE_APPLY = "voyagerJobsDashOnsiteApplyApplication"
|
|
45
|
+
DETAIL_SECTIONS = GraphQLQuery(
|
|
46
|
+
"voyagerJobsDashJobPostingDetailSections.65c973ecf268b881b304305978de9fed",
|
|
47
|
+
"JobPostingDetailSectionsByCardSectionTypesV2",
|
|
48
|
+
)
|
|
49
|
+
APPLICATION = GraphQLQuery(
|
|
50
|
+
"voyagerJobsDashOnsiteApplyApplication.3a3ede1703d12540cf2e7e4bd04a3bbd",
|
|
51
|
+
"JobsOnsiteApplyApplicationByJobPosting",
|
|
52
|
+
)
|
|
53
|
+
# The cards the job details screen requests first: header, apply state and description.
|
|
54
|
+
DETAIL_CARDS = ("TOP_CARD_V2", "JOB_DESCRIPTION_CARD")
|
|
55
|
+
TRACKER_INTENT = "SEARCH_MY_ITEMS_JOB_SEEKER"
|
|
56
|
+
|
|
57
|
+
_JOB = re.compile(
|
|
58
|
+
r"(?:urn:li:(?:fsd_jobPosting|jobPosting|fs_normalized_jobPosting|fs_jobPosting):)?([0-9]+)"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Jobs:
|
|
63
|
+
"""Read jobs, save them, and apply with Easy Apply in explicit steps."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, voyager: Voyager) -> None:
|
|
66
|
+
self._voyager = voyager
|
|
67
|
+
|
|
68
|
+
async def get(self, job: str) -> Job:
|
|
69
|
+
"""Read a job posting; HTTPError 404 when it does not exist."""
|
|
70
|
+
job_id = job_id_of(job)
|
|
71
|
+
urn = f"urn:li:fsd_jobPosting:{job_id}"
|
|
72
|
+
posting = await self._voyager.get(f"{POSTINGS}/{urn}")
|
|
73
|
+
sections = await self._voyager.query(
|
|
74
|
+
DETAIL_SECTIONS,
|
|
75
|
+
{"cardSectionTypes": list(DETAIL_CARDS), "jobPostingUrn": urn, "includeJobState": True},
|
|
76
|
+
)
|
|
77
|
+
found = obj(sections.get("jobsDashJobPostingDetailSectionsByCardSectionTypes"))
|
|
78
|
+
if string(posting, "entityUrn") != urn or not found:
|
|
79
|
+
raise ProtocolError("LinkedIn returned an unexpected job posting.")
|
|
80
|
+
return Job.from_json(job_id, posting, found)
|
|
81
|
+
|
|
82
|
+
async def save(self, job: str) -> None:
|
|
83
|
+
"""Save a job to the viewer's saved jobs."""
|
|
84
|
+
await self._set_saved(job, True)
|
|
85
|
+
|
|
86
|
+
async def unsave(self, job: str) -> None:
|
|
87
|
+
"""Remove a job from the viewer's saved jobs."""
|
|
88
|
+
await self._set_saved(job, False)
|
|
89
|
+
|
|
90
|
+
async def tracked(
|
|
91
|
+
self,
|
|
92
|
+
status: TrackedStatus = TrackedStatus.SAVED,
|
|
93
|
+
*,
|
|
94
|
+
count: int = 10,
|
|
95
|
+
cursor: Cursor | None = None,
|
|
96
|
+
) -> Page[TrackedJob]:
|
|
97
|
+
"""Read one page of the viewer's saved, in-progress, applied or archived jobs."""
|
|
98
|
+
status = TrackedStatus(status)
|
|
99
|
+
if type(count) is not int or not 1 <= count <= 100:
|
|
100
|
+
raise ValueError("count must be between 1 and 100.")
|
|
101
|
+
scope = status.value
|
|
102
|
+
start, token = paging.resume(cursor, "jobs.tracked", scope, self._voyager.owner)
|
|
103
|
+
variables = {
|
|
104
|
+
"query": {
|
|
105
|
+
"flagshipSearchIntent": TRACKER_INTENT,
|
|
106
|
+
"queryParameters": {"cardType": [status.value]},
|
|
107
|
+
},
|
|
108
|
+
"count": count,
|
|
109
|
+
"paginationToken": token,
|
|
110
|
+
"start": start,
|
|
111
|
+
}
|
|
112
|
+
data = await self._voyager.query(CLUSTERS, variables)
|
|
113
|
+
clusters = paging.collection(data, "searchDashClustersByAll")
|
|
114
|
+
results = [
|
|
115
|
+
obj(dig(entry, "item", "entityResult"))
|
|
116
|
+
for cluster in items(clusters.get("elements"))
|
|
117
|
+
for entry in items(cluster.get("items"))
|
|
118
|
+
]
|
|
119
|
+
total = integer(clusters, "paging", "total")
|
|
120
|
+
if total is None:
|
|
121
|
+
total = integer(clusters, "metadata", "totalResultCount")
|
|
122
|
+
return paging.page(
|
|
123
|
+
clusters,
|
|
124
|
+
TrackedJob.from_result,
|
|
125
|
+
"jobs.tracked",
|
|
126
|
+
scope,
|
|
127
|
+
start,
|
|
128
|
+
count,
|
|
129
|
+
self._voyager.owner,
|
|
130
|
+
elements=results,
|
|
131
|
+
total=total,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
async def application(self, job: str) -> ApplicationForm:
|
|
135
|
+
"""Read a job's Easy Apply form; OperationRejected for jobs without Easy Apply."""
|
|
136
|
+
job_id = job_id_of(job)
|
|
137
|
+
data = await self._voyager.query(
|
|
138
|
+
APPLICATION, {"jobPostingUrn": f"urn:li:fsd_jobPosting:{job_id}"}
|
|
139
|
+
)
|
|
140
|
+
found = items(dig(data, "jobsDashOnsiteApplyApplicationByJobPosting", "elements"))
|
|
141
|
+
if not found:
|
|
142
|
+
raise ProtocolError("LinkedIn returned no Easy Apply form for this job.")
|
|
143
|
+
return ApplicationForm.from_json(job_id, found[0])
|
|
144
|
+
|
|
145
|
+
async def upload_file(
|
|
146
|
+
self,
|
|
147
|
+
file: str | os.PathLike[str] | bytes,
|
|
148
|
+
*,
|
|
149
|
+
filename: str | None = None,
|
|
150
|
+
max_size: int | None = None,
|
|
151
|
+
) -> UploadedFile:
|
|
152
|
+
"""Store a .pdf, .doc or .docx resume or cover letter for a submission to name.
|
|
153
|
+
|
|
154
|
+
The upload applies to nothing by itself and LinkedIn offers no way to delete it, so
|
|
155
|
+
pass the slot's limit (`form.file("Resume").max_size`) to refuse a file before it is
|
|
156
|
+
stored.
|
|
157
|
+
"""
|
|
158
|
+
return await uploads.upload(self._voyager, file, filename, max_size)
|
|
159
|
+
|
|
160
|
+
def submission(
|
|
161
|
+
self,
|
|
162
|
+
form: ApplicationForm,
|
|
163
|
+
answers: Mapping[str, Answer],
|
|
164
|
+
*,
|
|
165
|
+
files: Mapping[str, FileAnswer] | None = None,
|
|
166
|
+
follow_company: bool = False,
|
|
167
|
+
job: Job | None = None,
|
|
168
|
+
) -> Submission:
|
|
169
|
+
"""Check answers against the form and build the submission; nothing is sent.
|
|
170
|
+
|
|
171
|
+
Keys are question URNs or labels. Text questions take strings, number questions
|
|
172
|
+
numbers, choice questions an option label (checkboxes a list of labels). Unanswered
|
|
173
|
+
questions keep LinkedIn's prefill. `files` maps file slots (URN, label or section
|
|
174
|
+
such as RESUME) to an UploadedFile, a Resume or a resume URN. Pass the `Job` from
|
|
175
|
+
`get` to send its tracking values like the app. The app's follow-company box
|
|
176
|
+
starts checked; here following is off unless asked for.
|
|
177
|
+
"""
|
|
178
|
+
if job is not None and job.id != form.job_id:
|
|
179
|
+
raise ValueError("The job and the form belong to different jobs.")
|
|
180
|
+
return answer_rules.build(
|
|
181
|
+
form,
|
|
182
|
+
answers,
|
|
183
|
+
files,
|
|
184
|
+
follow_company=follow_company,
|
|
185
|
+
reference_id=job.reference_id if job else None,
|
|
186
|
+
tracking_id=job.tracking_id if job else None,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
async def submit(self, submission: Submission) -> ApplicationStatus:
|
|
190
|
+
"""Submit the application once; a timeout leaves the outcome unknown."""
|
|
191
|
+
if not isinstance(submission, Submission):
|
|
192
|
+
raise ValueError("Build the submission with Jobs.submission first.")
|
|
193
|
+
job_id_of(submission.job_id)
|
|
194
|
+
reply = await self._voyager.post(
|
|
195
|
+
ONSITE_APPLY, submission.body, {"action": "submitApplication"}
|
|
196
|
+
)
|
|
197
|
+
return ApplicationStatus.from_json(obj(reply.data.get("value")))
|
|
198
|
+
|
|
199
|
+
async def _set_saved(self, job: str, saved: bool) -> None:
|
|
200
|
+
urn = f"urn:li:fsd_jobPosting:{job_id_of(job)}"
|
|
201
|
+
await self._voyager.post(
|
|
202
|
+
f"{SAVE_STATES}/urn:li:fsd_saveState:(SAVE,{urn})",
|
|
203
|
+
{"patch": {"$set": {"saved": saved}}},
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def job_id_of(job: str) -> str:
|
|
208
|
+
"""The numeric job ID from an ID or a job posting URN."""
|
|
209
|
+
found = _JOB.fullmatch(job) if isinstance(job, str) else None
|
|
210
|
+
if not found:
|
|
211
|
+
raise ValueError("A job ID or urn:li:fsd_jobPosting:<id> is required.")
|
|
212
|
+
return found[1]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Store a resume or cover letter for an Easy Apply form, as the app's file picker does.
|
|
2
|
+
|
|
3
|
+
`voyagerJobsDashAmbryUploadUrls?action=requestUrl` returns an upload URL on
|
|
4
|
+
www.linkedin.com (`/ambry/?x-li-ambry-ep=...`); the file bytes are posted there once and
|
|
5
|
+
LinkedIn answers 201 with the stored file's `location`. The app answers the file slot
|
|
6
|
+
with `urn:li:fsd_resume:<location>`. Nothing is attached or applied until a submission
|
|
7
|
+
names that URN.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from ...exceptions import ProtocolError
|
|
15
|
+
from ...exceptions import UnconfirmedWrite
|
|
16
|
+
from ...models.jobs_apply import UploadedFile
|
|
17
|
+
from ...transport import Transport
|
|
18
|
+
from ...transport.voyager import Voyager
|
|
19
|
+
|
|
20
|
+
UPLOAD_URLS = "voyagerJobsDashAmbryUploadUrls"
|
|
21
|
+
# The app's MimeType names for the document types resume slots accept.
|
|
22
|
+
DOCUMENT_TYPES = {
|
|
23
|
+
"PDF": "application/pdf",
|
|
24
|
+
"DOC": "application/msword",
|
|
25
|
+
"DOCX": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
26
|
+
}
|
|
27
|
+
_EXTENSIONS = {".pdf": "PDF", ".doc": "DOC", ".docx": "DOCX"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def document_type(filename: str) -> str:
|
|
31
|
+
"""The app's MimeType name (PDF, DOC or DOCX) for a file name."""
|
|
32
|
+
kind = _EXTENSIONS.get(Path(filename).suffix.lower())
|
|
33
|
+
if kind is None:
|
|
34
|
+
raise ValueError("Resumes and cover letters must be .pdf, .doc or .docx files.")
|
|
35
|
+
return kind
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def upload(
|
|
39
|
+
voyager: Voyager,
|
|
40
|
+
file: str | os.PathLike[str] | bytes,
|
|
41
|
+
filename: str | None,
|
|
42
|
+
max_size: int | None,
|
|
43
|
+
) -> UploadedFile:
|
|
44
|
+
if max_size is not None and (type(max_size) is not int or max_size < 1):
|
|
45
|
+
raise ValueError("max_size must be a positive number of bytes.")
|
|
46
|
+
if isinstance(file, bytes):
|
|
47
|
+
if not filename:
|
|
48
|
+
raise ValueError("A file name is required when uploading bytes.")
|
|
49
|
+
data, name = file, filename
|
|
50
|
+
else:
|
|
51
|
+
path = Path(file)
|
|
52
|
+
if max_size is not None and (await asyncio.to_thread(path.stat)).st_size > max_size:
|
|
53
|
+
raise ValueError(f"The file is larger than {max_size} bytes.")
|
|
54
|
+
data = await asyncio.to_thread(path.read_bytes)
|
|
55
|
+
name = filename or path.name
|
|
56
|
+
if not data:
|
|
57
|
+
raise ValueError("The file is empty.")
|
|
58
|
+
if max_size is not None and len(data) > max_size:
|
|
59
|
+
raise ValueError(f"The file is larger than {max_size} bytes.")
|
|
60
|
+
kind = document_type(name)
|
|
61
|
+
reply = await voyager.post(
|
|
62
|
+
UPLOAD_URLS, {"contentType": kind, "filename": name}, {"action": "requestUrl"}
|
|
63
|
+
)
|
|
64
|
+
target = Transport.upload_path(reply.data.get("value"), "/ambry/")
|
|
65
|
+
if target is None:
|
|
66
|
+
raise ProtocolError("LinkedIn offered an upload location this client does not support.")
|
|
67
|
+
response = await voyager.transport.request(
|
|
68
|
+
"POST", target, content=data, headers={"content-type": DOCUMENT_TYPES[kind]}
|
|
69
|
+
)
|
|
70
|
+
location = response.headers.get("location")
|
|
71
|
+
if response.status_code != 201 or not location:
|
|
72
|
+
raise UnconfirmedWrite("LinkedIn did not confirm the file upload.")
|
|
73
|
+
return UploadedFile(
|
|
74
|
+
urn=f"urn:li:fsd_resume:{location}",
|
|
75
|
+
file_name=name,
|
|
76
|
+
content_type=DOCUMENT_TYPES[kind],
|
|
77
|
+
size=len(data),
|
|
78
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Mentions inside post and comment text, written as the app's text attributes.
|
|
2
|
+
|
|
3
|
+
Offsets count Unicode code points: a live post on 2026-10-31 with an emoji before a mention
|
|
4
|
+
kept the mention only with code-point offsets; UTF-16 offsets were silently dropped.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..models.comments import Mention
|
|
12
|
+
|
|
13
|
+
# Mention URN kinds and the TextAttributeData union member the app writes for each.
|
|
14
|
+
_KINDS = {"fsd_profile": "profileFullName", "fsd_company": "companyName"}
|
|
15
|
+
_URN = re.compile(r"urn:li:(fsd_profile|fsd_company):[A-Za-z0-9_-]+")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def attributes(
|
|
19
|
+
text: str, mentions: Sequence[Mention], *, companies: bool = True
|
|
20
|
+
) -> list[dict[str, Any]]:
|
|
21
|
+
"""One attribute per mention, in text order; mentions may not overlap or leave the text.
|
|
22
|
+
|
|
23
|
+
Without `companies`, only members (`urn:li:fsd_profile`) may be mentioned.
|
|
24
|
+
"""
|
|
25
|
+
if isinstance(mentions, str | bytes | Mention) or not isinstance(mentions, Sequence):
|
|
26
|
+
raise ValueError("Mentions must be a sequence of Mention values.")
|
|
27
|
+
checked = [_checked(mention, companies) for mention in mentions]
|
|
28
|
+
written = []
|
|
29
|
+
end = 0
|
|
30
|
+
for mention in sorted(checked, key=lambda item: item.start):
|
|
31
|
+
if mention.start < end or mention.start + mention.length > len(text):
|
|
32
|
+
raise ValueError("Mentions must cover separate parts of the text.")
|
|
33
|
+
kind = _KINDS[mention.urn.split(":")[2]]
|
|
34
|
+
written.append(
|
|
35
|
+
{
|
|
36
|
+
"start": mention.start,
|
|
37
|
+
"length": mention.length,
|
|
38
|
+
"detailDataUnion": {kind: mention.urn},
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
end = mention.start + mention.length
|
|
42
|
+
return written
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _checked(mention: object, companies: bool) -> Mention:
|
|
46
|
+
if (
|
|
47
|
+
not isinstance(mention, Mention)
|
|
48
|
+
or not isinstance(mention.urn, str)
|
|
49
|
+
or not _URN.fullmatch(mention.urn)
|
|
50
|
+
or (not companies and not mention.urn.startswith("urn:li:fsd_profile:"))
|
|
51
|
+
or type(mention.start) is not int
|
|
52
|
+
or type(mention.length) is not int
|
|
53
|
+
or mention.start < 0
|
|
54
|
+
or mention.length < 1
|
|
55
|
+
):
|
|
56
|
+
who = "a urn:li:fsd_profile or urn:li:fsd_company" if companies else "a urn:li:fsd_profile"
|
|
57
|
+
raise ValueError(f"A mention needs {who} URN, a start of at least 0 and a positive length.")
|
|
58
|
+
return mention
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Turn Voyager collections into pages, continuing them the way the app's pager does."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
from typing import TypeVar
|
|
6
|
+
|
|
7
|
+
from ..exceptions import ProtocolError
|
|
8
|
+
from ..models._json import integer
|
|
9
|
+
from ..models._json import items
|
|
10
|
+
from ..models._json import obj
|
|
11
|
+
from ..models._json import string
|
|
12
|
+
from ..models.pagination import Cursor
|
|
13
|
+
from ..models.pagination import Page
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def collection(data: dict[str, Any], field: str) -> dict[str, Any]:
|
|
19
|
+
"""The named collection of a GraphQL `data` object; it must carry an elements list."""
|
|
20
|
+
found = data.get(field)
|
|
21
|
+
if not isinstance(found, dict) or not isinstance(found.get("elements"), list):
|
|
22
|
+
raise ProtocolError(f"LinkedIn's reply had no {field} collection.")
|
|
23
|
+
return found
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def resume(
|
|
27
|
+
cursor: Cursor | None, operation: str, scope: str, owner: str = ""
|
|
28
|
+
) -> tuple[int, str | None]:
|
|
29
|
+
"""Where a request starts and its pagination token; the cursor must belong to it."""
|
|
30
|
+
if cursor is None:
|
|
31
|
+
return 0, None
|
|
32
|
+
if not isinstance(cursor, Cursor):
|
|
33
|
+
raise ValueError("cursor must be a Cursor from a previous page.")
|
|
34
|
+
cursor.check(operation, scope, owner)
|
|
35
|
+
return cursor.start, cursor.token
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def page(
|
|
39
|
+
found: dict[str, Any],
|
|
40
|
+
read: Callable[[dict[str, Any]], T | None],
|
|
41
|
+
operation: str,
|
|
42
|
+
scope: str,
|
|
43
|
+
start: int,
|
|
44
|
+
count: int,
|
|
45
|
+
owner: str = "",
|
|
46
|
+
*,
|
|
47
|
+
elements: list[dict[str, Any]] | None = None,
|
|
48
|
+
total: int | None = None,
|
|
49
|
+
) -> Page[T]:
|
|
50
|
+
"""Read each element; elements that `read` turns into None are left out of `items`.
|
|
51
|
+
|
|
52
|
+
`elements` replaces the collection's own when results are nested deeper. `total` is
|
|
53
|
+
shown instead of `paging.total` for finders that report a separate estimate; paging
|
|
54
|
+
still stops at `paging.total`. Paging follows the collection's own elements, so a page
|
|
55
|
+
whose entries are all skipped still continues.
|
|
56
|
+
"""
|
|
57
|
+
own = items(found.get("elements"))
|
|
58
|
+
bound = integer(found, "paging", "total")
|
|
59
|
+
results = tuple(
|
|
60
|
+
result for result in map(read, own if elements is None else elements) if result is not None
|
|
61
|
+
)
|
|
62
|
+
following = _next(found, bool(own), bound, operation, scope, start, count, owner)
|
|
63
|
+
return Page(results, following, bound if total is None else total, found)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _next(
|
|
67
|
+
found: dict[str, Any],
|
|
68
|
+
any_elements: bool,
|
|
69
|
+
total: int | None,
|
|
70
|
+
operation: str,
|
|
71
|
+
scope: str,
|
|
72
|
+
start: int,
|
|
73
|
+
count: int,
|
|
74
|
+
owner: str,
|
|
75
|
+
) -> Cursor | None:
|
|
76
|
+
# The app continues at paging.start + paging.count while the total allows it, or while
|
|
77
|
+
# pages come back non-empty when there is no total. An empty page always ends here.
|
|
78
|
+
if not any_elements:
|
|
79
|
+
return None
|
|
80
|
+
paging = obj(found.get("paging"))
|
|
81
|
+
first = integer(paging, "start")
|
|
82
|
+
size = integer(paging, "count")
|
|
83
|
+
step = size if size is not None and size > 0 else count
|
|
84
|
+
next_start = (start if first is None else first) + step
|
|
85
|
+
if total is not None and next_start >= total:
|
|
86
|
+
return None
|
|
87
|
+
token = string(found, "metadata", "paginationToken") or None
|
|
88
|
+
return Cursor(operation, scope, next_start, token, owner)
|