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,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)
@@ -0,0 +1,6 @@
1
+ """Posts resource exports."""
2
+
3
+ from .media import PostMedia # Uploads, processing status and link previews for posts.
4
+ from .resource import Posts # Create, edit, delete, schedule and repost own posts.
5
+
6
+ __all__ = ["PostMedia", "Posts"]