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,424 @@
|
|
|
1
|
+
"""Easy Apply forms, their questions and file slots, uploads and the submission to send.
|
|
2
|
+
|
|
3
|
+
The form is the app's `OnsiteApplyApplication`: pages (question groupings) of form elements
|
|
4
|
+
whose `formComponent` decides how they are answered, plus file slots for a resume or cover
|
|
5
|
+
letter. Prefilled answers keep the server's own input values, which the app sends back.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from copy import deepcopy
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from dataclasses import field
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
from typing import Any
|
|
15
|
+
from typing import TypeVar
|
|
16
|
+
|
|
17
|
+
from ._json import boolean
|
|
18
|
+
from ._json import dig
|
|
19
|
+
from ._json import epoch_ms
|
|
20
|
+
from ._json import integer
|
|
21
|
+
from ._json import items
|
|
22
|
+
from ._json import obj
|
|
23
|
+
from ._json import string
|
|
24
|
+
from ._json import text
|
|
25
|
+
|
|
26
|
+
T = TypeVar("T")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class QuestionKind(StrEnum):
|
|
30
|
+
"""How a question is answered; OTHER covers components this client cannot answer."""
|
|
31
|
+
|
|
32
|
+
TEXT = "TEXT"
|
|
33
|
+
MULTILINE_TEXT = "MULTILINE_TEXT"
|
|
34
|
+
NUMBER = "NUMBER"
|
|
35
|
+
SINGLE_CHOICE = "SINGLE_CHOICE"
|
|
36
|
+
DROPDOWN = "DROPDOWN"
|
|
37
|
+
CHECKBOX = "CHECKBOX"
|
|
38
|
+
OTHER = "OTHER"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_KINDS = {
|
|
42
|
+
"singleLineTextFormComponent": QuestionKind.TEXT,
|
|
43
|
+
"multilineTextFormComponent": QuestionKind.MULTILINE_TEXT,
|
|
44
|
+
"numberInputFormComponent": QuestionKind.NUMBER,
|
|
45
|
+
"radioButtonFormComponent": QuestionKind.SINGLE_CHOICE,
|
|
46
|
+
"dropdownFormComponent": QuestionKind.DROPDOWN,
|
|
47
|
+
"textEntityListFormComponent": QuestionKind.DROPDOWN,
|
|
48
|
+
"checkboxFormComponent": QuestionKind.CHECKBOX,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class Option:
|
|
54
|
+
"""One choice: its shown label and the enum string or URN the app answers with."""
|
|
55
|
+
|
|
56
|
+
label: str | None
|
|
57
|
+
value: str | None = None
|
|
58
|
+
urn: str | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class Question:
|
|
63
|
+
"""One form element.
|
|
64
|
+
|
|
65
|
+
`id` is the form element URN that answers address. `prefilled` shows the answer LinkedIn
|
|
66
|
+
filled in (text or option names). `min_length`/`max_length` bound text, `minimum`/
|
|
67
|
+
`maximum` bound numbers and `min_choices`/`max_choices` bound checkbox selections when
|
|
68
|
+
the form says so. `whole_number` marks numbers without decimals. A `conditional`
|
|
69
|
+
question is only shown after certain answers elsewhere; it is never treated as required.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
id: str
|
|
73
|
+
label: str | None
|
|
74
|
+
kind: QuestionKind
|
|
75
|
+
component: str | None
|
|
76
|
+
required: bool
|
|
77
|
+
section: str | None = None
|
|
78
|
+
options: tuple[Option, ...] = ()
|
|
79
|
+
prefilled: tuple[str, ...] = field(default=(), repr=False)
|
|
80
|
+
min_length: int | None = None
|
|
81
|
+
max_length: int | None = None
|
|
82
|
+
minimum: float | None = None
|
|
83
|
+
maximum: float | None = None
|
|
84
|
+
whole_number: bool = False
|
|
85
|
+
min_choices: int | None = None
|
|
86
|
+
max_choices: int | None = None
|
|
87
|
+
conditional: bool = False
|
|
88
|
+
repeatable: bool = False
|
|
89
|
+
# The server's input values, sent back unchanged when the question keeps its prefill.
|
|
90
|
+
input_values: tuple[dict[str, Any], ...] = field(default=(), repr=False, compare=False)
|
|
91
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def from_element(
|
|
95
|
+
cls, element: dict[str, Any], section: str | None, repeatable: bool
|
|
96
|
+
) -> "Question | None":
|
|
97
|
+
urn = string(element, "urn") or string(element, "entityUrn")
|
|
98
|
+
if not urn:
|
|
99
|
+
return None
|
|
100
|
+
components = obj(element.get("formComponentResolutionResult")) or obj(
|
|
101
|
+
element.get("formComponent")
|
|
102
|
+
)
|
|
103
|
+
name = next((key for key, value in components.items() if isinstance(value, dict)), None)
|
|
104
|
+
component = obj(components.get(name)) if name else {}
|
|
105
|
+
kind = _KINDS.get(name or "", QuestionKind.OTHER)
|
|
106
|
+
validation = obj(component.get("validationMetadataResolutionResult")) or obj(
|
|
107
|
+
component.get("validationMetadata")
|
|
108
|
+
)
|
|
109
|
+
numeric = obj(validation.get("integer")) or obj(validation.get("decimal"))
|
|
110
|
+
if kind is QuestionKind.TEXT and numeric:
|
|
111
|
+
kind = QuestionKind.NUMBER
|
|
112
|
+
characters = obj(dig(validation, "string", "characterCountRangeValidation")) or obj(
|
|
113
|
+
dig(validation, "url", "characterCountRangeValidation")
|
|
114
|
+
)
|
|
115
|
+
number_range = obj(numeric.get("numericValueRangeValidation")) or obj(
|
|
116
|
+
component.get("numericRangeValidation")
|
|
117
|
+
)
|
|
118
|
+
choices = obj(component.get("selectionCountRangeValidation"))
|
|
119
|
+
values = tuple(
|
|
120
|
+
_clean(value)
|
|
121
|
+
for value in items(dig(element, "input", "formElementInputValuesResolutionResults"))
|
|
122
|
+
)
|
|
123
|
+
digits = integer(component, "numFractionalDigits")
|
|
124
|
+
return cls(
|
|
125
|
+
id=urn,
|
|
126
|
+
label=text(element, "title"),
|
|
127
|
+
kind=kind,
|
|
128
|
+
component=name,
|
|
129
|
+
required=boolean(element, "required") is True,
|
|
130
|
+
section=section,
|
|
131
|
+
options=_options(component),
|
|
132
|
+
prefilled=tuple(shown for value in values if (shown := _shown(value))),
|
|
133
|
+
min_length=integer(characters, "validRange", "start"),
|
|
134
|
+
max_length=integer(characters, "validRange", "end"),
|
|
135
|
+
minimum=_number(dig(number_range, "validRange", "start")),
|
|
136
|
+
maximum=_number(dig(number_range, "validRange", "end")),
|
|
137
|
+
whole_number=bool(validation.get("integer"))
|
|
138
|
+
or (name == "numberInputFormComponent" and not digits),
|
|
139
|
+
min_choices=integer(choices, "validRange", "start"),
|
|
140
|
+
max_choices=integer(choices, "validRange", "end"),
|
|
141
|
+
conditional=any(
|
|
142
|
+
element.get(key)
|
|
143
|
+
for key in (
|
|
144
|
+
"prerequisiteFormElementInputs",
|
|
145
|
+
"prerequisiteInputEvaluationStrategy",
|
|
146
|
+
"prerequisiteInputEvaluationStrategyUnion",
|
|
147
|
+
)
|
|
148
|
+
),
|
|
149
|
+
repeatable=repeatable,
|
|
150
|
+
input_values=values,
|
|
151
|
+
raw=deepcopy(element),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(frozen=True, slots=True)
|
|
156
|
+
class FileQuestion:
|
|
157
|
+
"""A file slot such as the resume; `id` is the upload form element URN it answers.
|
|
158
|
+
|
|
159
|
+
`prefilled` is the resume URN LinkedIn preselected, if any. `mime_types` are the app's
|
|
160
|
+
names (PDF, DOC, DOCX) and `max_size` is in bytes.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
id: str
|
|
164
|
+
label: str | None
|
|
165
|
+
section: str | None
|
|
166
|
+
required: bool
|
|
167
|
+
mime_types: tuple[str, ...]
|
|
168
|
+
max_size: int | None
|
|
169
|
+
prefilled: str | None = field(default=None, repr=False)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(frozen=True, slots=True)
|
|
173
|
+
class Resume:
|
|
174
|
+
"""A resume LinkedIn offers from earlier applications; `urn` answers a file slot."""
|
|
175
|
+
|
|
176
|
+
urn: str
|
|
177
|
+
file_name: str | None
|
|
178
|
+
file_type: str | None
|
|
179
|
+
size: int | None
|
|
180
|
+
created_at: datetime | None
|
|
181
|
+
last_used_at: datetime | None
|
|
182
|
+
download_url: str | None = field(default=None, repr=False)
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def from_json(cls, data: dict[str, Any]) -> "Resume | None":
|
|
186
|
+
urn = string(data, "entityUrn")
|
|
187
|
+
if not urn:
|
|
188
|
+
return None
|
|
189
|
+
# The app answers with an fsd_resume URN built from the resume's own ID.
|
|
190
|
+
resume_id = urn.split(":", 3)[3] if urn.count(":") >= 3 else urn
|
|
191
|
+
return cls(
|
|
192
|
+
urn=f"urn:li:fsd_resume:{resume_id}",
|
|
193
|
+
file_name=string(data, "fileName"),
|
|
194
|
+
file_type=string(data, "fileType"),
|
|
195
|
+
size=integer(data, "fileSize"),
|
|
196
|
+
created_at=epoch_ms(integer(data, "createdAt")),
|
|
197
|
+
last_used_at=epoch_ms(integer(data, "lastUsedAt")),
|
|
198
|
+
download_url=string(data, "downloadUrl"),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass(frozen=True, slots=True)
|
|
203
|
+
class UploadedFile:
|
|
204
|
+
"""A file stored for an application; its `urn` answers a file slot.
|
|
205
|
+
|
|
206
|
+
Uploading attaches the file to nothing: it is used only when a submission names it.
|
|
207
|
+
LinkedIn's app offers no way to delete an uploaded file.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
urn: str
|
|
211
|
+
file_name: str
|
|
212
|
+
content_type: str
|
|
213
|
+
size: int
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass(frozen=True, slots=True)
|
|
217
|
+
class FormPage:
|
|
218
|
+
"""One page of the form (a question grouping such as CONTACT_INFO, RESUME or BASIC)."""
|
|
219
|
+
|
|
220
|
+
section: str | None
|
|
221
|
+
title: str | None
|
|
222
|
+
questions: tuple[Question, ...]
|
|
223
|
+
files: tuple[FileQuestion, ...]
|
|
224
|
+
prefilled: bool | None = None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@dataclass(frozen=True, slots=True)
|
|
228
|
+
class ApplicationForm:
|
|
229
|
+
"""The Easy Apply form of one job, read without starting or submitting anything.
|
|
230
|
+
|
|
231
|
+
`resumes` are resumes from earlier applications that LinkedIn offers again.
|
|
232
|
+
`needs_data_consent` and `has_repeatable_sections` mark forms whose extra steps this
|
|
233
|
+
client does not build, so a submission for them is refused.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
job_id: str
|
|
237
|
+
company: str | None
|
|
238
|
+
applied: bool | None
|
|
239
|
+
pages: tuple[FormPage, ...]
|
|
240
|
+
resumes: tuple[Resume, ...]
|
|
241
|
+
needs_data_consent: bool = False
|
|
242
|
+
has_repeatable_sections: bool = False
|
|
243
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def questions(self) -> tuple[Question, ...]:
|
|
247
|
+
return tuple(question for page in self.pages for question in page.questions)
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def files(self) -> tuple[FileQuestion, ...]:
|
|
251
|
+
return tuple(slot for page in self.pages for slot in page.files)
|
|
252
|
+
|
|
253
|
+
def question(self, key: str) -> Question:
|
|
254
|
+
"""Find a question by its URN, or by its exact label when that label is unique."""
|
|
255
|
+
return _find(self.questions, key, "question", lambda entry: (entry.label,))
|
|
256
|
+
|
|
257
|
+
def file(self, key: str) -> FileQuestion:
|
|
258
|
+
"""Find a file slot by its URN, its section (RESUME) or its exact label."""
|
|
259
|
+
return _find(self.files, key, "file slot", lambda entry: (entry.label, entry.section))
|
|
260
|
+
|
|
261
|
+
@classmethod
|
|
262
|
+
def from_json(cls, job_id: str, application: dict[str, Any]) -> "ApplicationForm":
|
|
263
|
+
pages: list[FormPage] = []
|
|
264
|
+
resumes: dict[str, Resume] = {}
|
|
265
|
+
repeatable_seen = False
|
|
266
|
+
for form in items(application.get("jobApplicationForms")):
|
|
267
|
+
for grouping in items(form.get("questionGroupings")):
|
|
268
|
+
section = string(grouping, "questionGroupingType")
|
|
269
|
+
form_section = obj(grouping.get("formSection"))
|
|
270
|
+
repeatable = bool(form_section.get("repeatableSectionData"))
|
|
271
|
+
repeatable_seen = repeatable_seen or repeatable
|
|
272
|
+
questions = [
|
|
273
|
+
question
|
|
274
|
+
for element in _elements(form_section)
|
|
275
|
+
if (question := Question.from_element(element, section, repeatable))
|
|
276
|
+
]
|
|
277
|
+
upload = obj(grouping.get("fileUploadFormSection")) or obj(
|
|
278
|
+
dig(grouping, "customizedFormSection", "fileUploadFormSection")
|
|
279
|
+
)
|
|
280
|
+
slot = _file_question(upload, section)
|
|
281
|
+
for data in items(grouping.get("usedResumesResolutionResults")):
|
|
282
|
+
if resume := Resume.from_json(data):
|
|
283
|
+
resumes.setdefault(resume.urn, resume)
|
|
284
|
+
pages.append(
|
|
285
|
+
FormPage(
|
|
286
|
+
section=section,
|
|
287
|
+
title=text(form_section, "title") or string(upload, "title"),
|
|
288
|
+
questions=tuple(questions),
|
|
289
|
+
files=(slot,) if slot else (),
|
|
290
|
+
prefilled=boolean(grouping, "prefilled"),
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
return cls(
|
|
294
|
+
job_id=job_id,
|
|
295
|
+
company=string(application, "jobPosting", "companyDetails", "name"),
|
|
296
|
+
applied=boolean(application, "jobSeekerApplicationDetail", "applied"),
|
|
297
|
+
pages=tuple(pages),
|
|
298
|
+
resumes=tuple(resumes.values()),
|
|
299
|
+
needs_data_consent=any(page.section == "DATA_CONSENT" for page in pages),
|
|
300
|
+
has_repeatable_sections=repeatable_seen,
|
|
301
|
+
raw=deepcopy(application),
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@dataclass(frozen=True, slots=True)
|
|
306
|
+
class Submission:
|
|
307
|
+
"""The exact `submitApplication` body for one job; building it sends nothing.
|
|
308
|
+
|
|
309
|
+
`answers` lists every value sent, in form order, as (question or file slot label,
|
|
310
|
+
values) pairs for review before submitting; two questions with the same label stay
|
|
311
|
+
separate. Neither the body nor the answers appear in the repr.
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
job_id: str
|
|
315
|
+
body: dict[str, Any] = field(repr=False)
|
|
316
|
+
answers: tuple[tuple[str, tuple[str, ...]], ...] = field(default=(), repr=False)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@dataclass(frozen=True, slots=True)
|
|
320
|
+
class ApplicationStatus:
|
|
321
|
+
"""The viewer's application state for a job after a submission."""
|
|
322
|
+
|
|
323
|
+
applied: bool | None
|
|
324
|
+
applied_at: datetime | None
|
|
325
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
326
|
+
|
|
327
|
+
@classmethod
|
|
328
|
+
def from_json(cls, detail: dict[str, Any]) -> "ApplicationStatus":
|
|
329
|
+
return cls(
|
|
330
|
+
applied=boolean(detail, "applied"),
|
|
331
|
+
applied_at=epoch_ms(integer(detail, "appliedAt")),
|
|
332
|
+
raw=deepcopy(detail),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _elements(form_section: dict[str, Any]) -> list[dict[str, Any]]:
|
|
337
|
+
found = items(form_section.get("formElements"))
|
|
338
|
+
for group in items(form_section.get("formElementGroups")):
|
|
339
|
+
found.extend(items(group.get("formElements")))
|
|
340
|
+
return found
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _options(component: dict[str, Any]) -> tuple[Option, ...]:
|
|
344
|
+
options = items(component.get("textSelectableOptions"))
|
|
345
|
+
for entry in items(component.get("selectableOptionsResolutionResults")) + items(
|
|
346
|
+
component.get("selectableOptions")
|
|
347
|
+
):
|
|
348
|
+
options.append(
|
|
349
|
+
obj(entry.get("textSelectableOption")) or obj(entry.get("entitySelectableOption"))
|
|
350
|
+
)
|
|
351
|
+
if exclusive := obj(component.get("exclusiveSelectableOption")):
|
|
352
|
+
options.append(exclusive)
|
|
353
|
+
return tuple(
|
|
354
|
+
Option(
|
|
355
|
+
label=text(option, "optionText"),
|
|
356
|
+
value=string(option, "option", "optionEnumString")
|
|
357
|
+
or string(option, "optionUnion", "optionEnumString")
|
|
358
|
+
or string(option, "optionEnumString"),
|
|
359
|
+
urn=string(option, "option", "optionUrn")
|
|
360
|
+
or string(option, "optionUnion", "optionUrn")
|
|
361
|
+
or string(option, "optionUrn"),
|
|
362
|
+
)
|
|
363
|
+
for option in options
|
|
364
|
+
if option
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _file_question(upload: dict[str, Any], section: str | None) -> FileQuestion | None:
|
|
369
|
+
element = obj(upload.get("fileUploadFormElement"))
|
|
370
|
+
urn = string(element, "formElementUrn")
|
|
371
|
+
if not urn:
|
|
372
|
+
return None
|
|
373
|
+
return FileQuestion(
|
|
374
|
+
id=urn,
|
|
375
|
+
label=string(upload, "title") or string(element, "title"),
|
|
376
|
+
section=section,
|
|
377
|
+
required=boolean(element, "required") is True,
|
|
378
|
+
mime_types=tuple(kind for kind in element.get("mimeTypes") or [] if isinstance(kind, str)),
|
|
379
|
+
max_size=integer(element, "maximumFileSizeSupported"),
|
|
380
|
+
prefilled=string(element, "input", "inputUrn"),
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _find(
|
|
385
|
+
entries: tuple[T, ...], key: str, what: str, names: Callable[[T], tuple[str | None, ...]]
|
|
386
|
+
) -> T:
|
|
387
|
+
if not isinstance(key, str) or not key:
|
|
388
|
+
raise ValueError(f"A {what} URN or label is required.")
|
|
389
|
+
for entry in entries:
|
|
390
|
+
if getattr(entry, "id", None) == key:
|
|
391
|
+
return entry
|
|
392
|
+
matches = [entry for entry in entries if key in names(entry)]
|
|
393
|
+
if len(matches) == 1:
|
|
394
|
+
return matches[0]
|
|
395
|
+
if matches:
|
|
396
|
+
raise ValueError(f"Several entries match {key!r}; use the {what} URN.")
|
|
397
|
+
raise ValueError(f"The form has no {what} {key!r}.")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _clean(value: Any) -> Any:
|
|
401
|
+
"""Drop null members, as the app's serializer does when sending values back."""
|
|
402
|
+
if isinstance(value, dict):
|
|
403
|
+
return {key: _clean(item) for key, item in value.items() if item is not None}
|
|
404
|
+
if isinstance(value, list):
|
|
405
|
+
return [_clean(item) for item in value]
|
|
406
|
+
return value
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _shown(value: dict[str, Any]) -> str | None:
|
|
410
|
+
shown = value.get("textInputValue")
|
|
411
|
+
if isinstance(shown, str):
|
|
412
|
+
return shown or None
|
|
413
|
+
entity = obj(value.get("entityInputValue"))
|
|
414
|
+
name = entity.get("inputEntityName") or entity.get("inputEntityUrn")
|
|
415
|
+
if isinstance(name, str):
|
|
416
|
+
return name
|
|
417
|
+
for key in ("integerInputValue", "floatInputValue", "booleanInputValue", "urnInputValue"):
|
|
418
|
+
if value.get(key) is not None:
|
|
419
|
+
return str(value[key])
|
|
420
|
+
return None
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _number(value: object) -> float | None:
|
|
424
|
+
return float(value) if isinstance(value, int | float) and not isinstance(value, bool) else None
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Typed member identity, independent of HTTP and persistence."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True, slots=True)
|
|
7
|
+
class Member:
|
|
8
|
+
"""Identity returned by the authenticated mobile SSO endpoint."""
|
|
9
|
+
|
|
10
|
+
id: str
|
|
11
|
+
first_name: str
|
|
12
|
+
last_name: str
|
|
13
|
+
full_name: str
|
|
14
|
+
headline: str
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Result pages whose cursors only continue the request that produced them."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from dataclasses import field
|
|
5
|
+
from typing import Any
|
|
6
|
+
from typing import Generic
|
|
7
|
+
from typing import TypeVar
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class Cursor:
|
|
14
|
+
"""Where the next page starts, tied to one operation, query and session.
|
|
15
|
+
|
|
16
|
+
`token` is LinkedIn's opaque pagination token when the operation issues one. `owner`
|
|
17
|
+
names the session that read the page (its device ID); an empty owner, as in a cursor
|
|
18
|
+
built by hand, is accepted by any session.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
operation: str
|
|
22
|
+
scope: str
|
|
23
|
+
start: int
|
|
24
|
+
token: str | None = field(default=None, repr=False)
|
|
25
|
+
owner: str = field(default="", repr=False, compare=False)
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
if not self.operation or not isinstance(self.scope, str):
|
|
29
|
+
raise ValueError("A cursor needs its operation and query scope.")
|
|
30
|
+
if type(self.start) is not int or self.start < 0:
|
|
31
|
+
raise ValueError("A cursor start must be a nonnegative integer.")
|
|
32
|
+
|
|
33
|
+
def check(self, operation: str, scope: str, owner: str = "") -> None:
|
|
34
|
+
"""Refuse a cursor from a different operation, query or session."""
|
|
35
|
+
if (self.operation, self.scope) != (operation, scope):
|
|
36
|
+
raise ValueError("This cursor belongs to a different operation or query.")
|
|
37
|
+
if self.owner and owner and self.owner != owner:
|
|
38
|
+
raise ValueError("This cursor belongs to a different session.")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class Page(Generic[T]):
|
|
43
|
+
"""One page of results; reading it never fetches another page."""
|
|
44
|
+
|
|
45
|
+
items: tuple[T, ...]
|
|
46
|
+
next: Cursor | None = None
|
|
47
|
+
total: int | None = None
|
|
48
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def has_more(self) -> bool:
|
|
52
|
+
return self.next is not None
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""What a new post can carry: audience, comment control and one attachment.
|
|
2
|
+
|
|
3
|
+
Mentions use `models.comments.Mention`: a member (`urn:li:fsd_profile:<id>`) or company
|
|
4
|
+
(`urn:li:fsd_company:<id>`) URN covering `length` code points of the text from `start`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from dataclasses import field
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Visibility(StrEnum):
|
|
14
|
+
"""Who can see a post; the app's share box offers these two for members."""
|
|
15
|
+
|
|
16
|
+
ANYONE = "ANYONE"
|
|
17
|
+
CONNECTIONS_ONLY = "CONNECTIONS_ONLY"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CommentScope(StrEnum):
|
|
21
|
+
"""Who can comment; the UI labels are Anyone, Connections only and No one."""
|
|
22
|
+
|
|
23
|
+
ANYONE = "ALL"
|
|
24
|
+
CONNECTIONS_ONLY = "CONNECTIONS_ONLY"
|
|
25
|
+
NOBODY = "NONE"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PollDuration(StrEnum):
|
|
29
|
+
"""How long a poll stays open, as the poll composer offers."""
|
|
30
|
+
|
|
31
|
+
ONE_DAY = "ONE_DAY"
|
|
32
|
+
THREE_DAYS = "THREE_DAYS"
|
|
33
|
+
ONE_WEEK = "ONE_WEEK"
|
|
34
|
+
TWO_WEEKS = "TWO_WEEKS"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MediaKind(StrEnum):
|
|
38
|
+
"""The kind of file uploaded for a post, named by the app's upload use case."""
|
|
39
|
+
|
|
40
|
+
IMAGE = "IMAGE_SHARING"
|
|
41
|
+
VIDEO = "VIDEO_SHARING"
|
|
42
|
+
DOCUMENT = "DOCUMENT_SHARING"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class UploadedMedia:
|
|
47
|
+
"""A file stored for a post (`urn:li:digitalmediaAsset:<id>`), not yet attached.
|
|
48
|
+
|
|
49
|
+
`recipes` are the processing recipes LinkedIn assigned; the share repeats them.
|
|
50
|
+
LinkedIn's app offers no way to delete an upload that is never posted.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
urn: str
|
|
54
|
+
kind: MediaKind
|
|
55
|
+
recipes: tuple[str, ...] = ()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class Image:
|
|
60
|
+
"""An uploaded image to post, with optional alternative text for screen readers."""
|
|
61
|
+
|
|
62
|
+
media: UploadedMedia
|
|
63
|
+
alt_text: str | None = None
|
|
64
|
+
|
|
65
|
+
def __post_init__(self) -> None:
|
|
66
|
+
_check_media(self.media, MediaKind.IMAGE)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class Video:
|
|
71
|
+
"""An uploaded video to post."""
|
|
72
|
+
|
|
73
|
+
media: UploadedMedia
|
|
74
|
+
|
|
75
|
+
def __post_init__(self) -> None:
|
|
76
|
+
_check_media(self.media, MediaKind.VIDEO)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class Document:
|
|
81
|
+
"""An uploaded PDF or slide document to post; the app requires a title for it."""
|
|
82
|
+
|
|
83
|
+
media: UploadedMedia
|
|
84
|
+
title: str
|
|
85
|
+
|
|
86
|
+
def __post_init__(self) -> None:
|
|
87
|
+
_check_media(self.media, MediaKind.DOCUMENT)
|
|
88
|
+
if not isinstance(self.title, str) or not self.title.strip():
|
|
89
|
+
raise ValueError("A document post needs a title.")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class NewPoll:
|
|
94
|
+
"""A poll for a new post, within the composer's limits.
|
|
95
|
+
|
|
96
|
+
The question has at most 140 characters; two to five options have at most 30 each.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
question: str
|
|
100
|
+
options: tuple[str, ...]
|
|
101
|
+
duration: PollDuration = PollDuration.ONE_WEEK
|
|
102
|
+
|
|
103
|
+
def __post_init__(self) -> None:
|
|
104
|
+
if not _filled(self.question, 140):
|
|
105
|
+
raise ValueError("A poll needs a question of at most 140 characters.")
|
|
106
|
+
options = tuple(self.options)
|
|
107
|
+
if not 2 <= len(options) <= 5 or not all(_filled(option, 30) for option in options):
|
|
108
|
+
raise ValueError("A poll needs two to five options of at most 30 characters.")
|
|
109
|
+
object.__setattr__(self, "options", options)
|
|
110
|
+
object.__setattr__(self, "duration", PollDuration(self.duration))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True, slots=True)
|
|
114
|
+
class LinkPreview:
|
|
115
|
+
"""The article card LinkedIn builds for a URL; attach it to share the link.
|
|
116
|
+
|
|
117
|
+
`article_urn` (`urn:li:article:<id>`) is what the share references; `url` is the
|
|
118
|
+
address that was previewed. `original_url` is set when LinkedIn names the link
|
|
119
|
+
differently; the share then carries it, as the app does.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
url: str
|
|
123
|
+
article_urn: str
|
|
124
|
+
title: str | None = None
|
|
125
|
+
subtitle: str | None = None
|
|
126
|
+
original_url: str | None = None
|
|
127
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _check_media(media: UploadedMedia, kind: MediaKind) -> None:
|
|
131
|
+
if not isinstance(media, UploadedMedia) or media.kind is not kind:
|
|
132
|
+
raise ValueError(f"An uploaded {kind.name.lower()} is required.")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _filled(value: object, limit: int) -> bool:
|
|
136
|
+
"""Non-blank text within `limit` UTF-16 units, as the app's validators count."""
|
|
137
|
+
return (
|
|
138
|
+
isinstance(value, str)
|
|
139
|
+
and bool(value.strip())
|
|
140
|
+
and len(value.encode("utf-16-le")) <= limit * 2
|
|
141
|
+
)
|