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,79 @@
1
+ """The app's persisted search operations and the variables it sends them.
2
+
3
+ Search screens build a `SearchQueryForInput` (SearchFrameworkUtils.getSearchQuery) and job
4
+ search a `JobSearchQueryForInput` (JobSearchQueryUtils); filters travel as a map of query
5
+ keys to value lists, which rest.li writes as `(geoUrn:List(103644278),...)`.
6
+ """
7
+
8
+ from typing import Any
9
+
10
+ from ...models.search import TypeaheadKind
11
+ from ...models.search import Vertical
12
+ from ...models.search_filters import Params
13
+ from ...transport.voyager import GraphQLQuery
14
+
15
+ CLUSTERS = GraphQLQuery(
16
+ "voyagerSearchDashClusters.c2c5d4b012c05a6a94396191208d45f6", "SearchClusterCollection"
17
+ )
18
+ FILTERS = GraphQLQuery(
19
+ "voyagerSearchDashFilterClusters.d163cc1a7c36e8f4472c2b539b730829",
20
+ "SearchFilterClustersByFilters",
21
+ )
22
+ TYPEAHEAD = GraphQLQuery(
23
+ "voyagerSearchDashReusableTypeahead.ae52ba922ef9ede5129d80936bd4249c",
24
+ "SearchReusableTypeaheadByType",
25
+ )
26
+ GLOBAL_TYPEAHEAD = GraphQLQuery(
27
+ "voyagerSearchDashTypeahead.0b3defba877012c806fcd8427c7ce135", "SearchGlobalTypeahead"
28
+ )
29
+ JOB_CARDS = GraphQLQuery(
30
+ "voyagerJobsDashJobCards.eb54097e5bda5435af797ec5bbedf723", "JobCardsByJobSearch"
31
+ )
32
+ JOB_FILTERS = GraphQLQuery(
33
+ "voyagerJobsDashSearchFilterClustersResource.4a3b04e4d980bf06ab986f66ef129f4e",
34
+ "JobSearchFiltersByJobSeachQuery",
35
+ )
36
+
37
+ # The filter sheet's location typeahead asks for these place types.
38
+ GEO_SEARCH_TYPES = ("MARKET_AREA", "COUNTRY_REGION", "ADMIN_DIVISION_1", "CITY")
39
+
40
+
41
+ def search_query(vertical: Vertical, keywords: str, params: Params) -> dict[str, Any]:
42
+ """A `SearchQueryForInput`; keywords are left out when empty, as the app does."""
43
+ return {
44
+ "flagshipSearchIntent": "SEARCH_SRP",
45
+ "keywords": keywords or None,
46
+ "queryParameters": {"resultType": [vertical.value], **params},
47
+ "spellCorrectionEnabled": True,
48
+ }
49
+
50
+
51
+ def search_origin(vertical: Vertical, params: Params) -> str:
52
+ """The origin the app reports: the search bar, a vertical switch or applied filters."""
53
+ if vertical is Vertical.ALL:
54
+ return "GLOBAL_SEARCH_HEADER"
55
+ return "FACETED_SEARCH" if params else "SWITCH_SEARCH_VERTICAL"
56
+
57
+
58
+ def job_query(keywords: str, geo_urn: str | None, params: Params) -> dict[str, Any]:
59
+ """A `JobSearchQueryForInput`; `locationUnion` is the geoUrn member of its union."""
60
+ return {
61
+ "origin": "JOB_SEARCH_PAGE_JOB_FILTER" if params else "JOB_SEARCH_PAGE_SEARCH_BUTTON",
62
+ "keywords": keywords or None,
63
+ "locationUnion": {"geoUrn": geo_urn} if geo_urn else None,
64
+ "selectedFilters": params or None,
65
+ "spellCorrectionEnabled": True,
66
+ }
67
+
68
+
69
+ def typeahead_variables(kind: TypeaheadKind, keywords: str, count: int | None) -> dict[str, Any]:
70
+ """TypeaheadRoutes.buildTypeaheadQuery as the search filter sheet calls it."""
71
+ filter_query: dict[str, Any] = {"standardizationEntityType": "industryV2"}
72
+ if kind is TypeaheadKind.GEO:
73
+ filter_query = {"geoSearchTypes": list(GEO_SEARCH_TYPES), **filter_query}
74
+ return {
75
+ "keywords": keywords,
76
+ "query": {"showFullLastNameForConnections": True, "typeaheadFilterQuery": filter_query},
77
+ "type": kind.value,
78
+ "count": count,
79
+ }
@@ -0,0 +1,85 @@
1
+ """Decode search replies into pages whose cursors continue only the same query."""
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+ from typing import TypeVar
6
+
7
+ from ...models._json import dig
8
+ from ...models._json import integer
9
+ from ...models._json import items
10
+ from ...models._json import obj
11
+ from ...models._json import text
12
+ from ...models.content import Post
13
+ from ...models.pagination import Page
14
+ from ...models.search import PersonResult
15
+ from ...models.search import ResultGroup
16
+ from ...models.search import SearchResult
17
+ from .. import paging
18
+
19
+ T = TypeVar("T")
20
+ Item = PersonResult | SearchResult | Post
21
+
22
+
23
+ Where = tuple[str, str, int, int, str] # operation, scope, start, count, cursor owner
24
+
25
+
26
+ def result_page(
27
+ reply: dict[str, Any], read: Callable[[dict[str, Any]], T | None], where: Where
28
+ ) -> Page[T]:
29
+ """Flatten the items of every cluster on a vertical's page. Banners, counts and feedback
30
+ cards are skipped."""
31
+ entries = [
32
+ obj(entry.get("item"))
33
+ for cluster in items(reply.get("elements"))
34
+ for entry in items(cluster.get("items"))
35
+ ]
36
+ return paging.page(reply, read, *where, elements=entries, total=_total(reply))
37
+
38
+
39
+ def group_page(reply: dict[str, Any], where: Where) -> Page[ResultGroup]:
40
+ """Blended results: one group per cluster that holds people, entities or posts."""
41
+ return paging.page(reply, _group, *where, total=_total(reply))
42
+
43
+
44
+ def job_page(
45
+ reply: dict[str, Any], read: Callable[[dict[str, Any]], T | None], where: Where
46
+ ) -> Page[T]:
47
+ cards = [obj(element.get("jobCard")) for element in items(reply.get("elements"))]
48
+ return paging.page(reply, read, *where, elements=cards)
49
+
50
+
51
+ def _group(cluster: dict[str, Any]) -> ResultGroup | None:
52
+ found = [
53
+ result
54
+ for entry in items(cluster.get("items"))
55
+ if (result := any_item(obj(entry.get("item")))) is not None
56
+ ]
57
+ return ResultGroup(text(cluster, "title"), tuple(found), cluster) if found else None
58
+
59
+
60
+ def person(item: dict[str, Any]) -> PersonResult | None:
61
+ return PersonResult.from_entity(obj(item.get("entityResult")))
62
+
63
+
64
+ def entity(item: dict[str, Any]) -> SearchResult | None:
65
+ found = obj(item.get("entityResult"))
66
+ return SearchResult.from_entity(found) if found else None
67
+
68
+
69
+ def post(item: dict[str, Any]) -> Post | None:
70
+ update = obj(dig(item, "searchFeedUpdate", "update"))
71
+ if not update:
72
+ return None
73
+ return Post.from_update(update)
74
+
75
+
76
+ def any_item(item: dict[str, Any]) -> Item | None:
77
+ """A blended item: a profile, another entity, or a post."""
78
+ if "searchFeedUpdate" in item:
79
+ return post(item)
80
+ return person(item) or entity(item)
81
+
82
+
83
+ def _total(reply: dict[str, Any]) -> int | None:
84
+ total = integer(reply, "metadata", "totalResultCount")
85
+ return total if total is not None else integer(reply, "paging", "total")
@@ -0,0 +1,284 @@
1
+ """Search people, posts, companies, jobs and other verticals, their filters and typeahead.
2
+
3
+ Every call is one read. Nothing here adds to the viewer's search history; the app sends
4
+ that as a separate write, which this client does not.
5
+ """
6
+
7
+ from collections.abc import Callable
8
+ from typing import Any
9
+ from typing import TypeVar
10
+
11
+ from ...models._json import items
12
+ from ...models.content import Post
13
+ from ...models.pagination import Cursor
14
+ from ...models.pagination import Page
15
+ from ...models.search import Filter
16
+ from ...models.search import PersonResult
17
+ from ...models.search import ResultGroup
18
+ from ...models.search import SearchResult
19
+ from ...models.search import Suggestion
20
+ from ...models.search import TypeaheadKind
21
+ from ...models.search import Vertical
22
+ from ...models.search import filters_from_clusters
23
+ from ...models.search_filters import CompanyFilters
24
+ from ...models.search_filters import CourseFilters
25
+ from ...models.search_filters import Params
26
+ from ...models.search_filters import PeopleFilters
27
+ from ...models.search_filters import PostFilters
28
+ from ...models.search_filters import ProductFilters
29
+ from ...models.search_filters import ServiceFilters
30
+ from ...models.search_jobs import JobFilters
31
+ from ...models.search_jobs import JobResult
32
+ from ...transport import restli
33
+ from ...transport.voyager import Voyager
34
+ from .. import paging
35
+ from . import queries
36
+ from . import results
37
+
38
+ T = TypeVar("T")
39
+ VerticalFilters = (
40
+ PeopleFilters | PostFilters | CompanyFilters | ServiceFilters | ProductFilters | CourseFilters
41
+ )
42
+ FILTER_TYPES: dict[Vertical, type[VerticalFilters]] = {
43
+ Vertical.PEOPLE: PeopleFilters,
44
+ Vertical.POSTS: PostFilters,
45
+ Vertical.COMPANIES: CompanyFilters,
46
+ Vertical.SERVICES: ServiceFilters,
47
+ Vertical.PRODUCTS: ProductFilters,
48
+ Vertical.COURSES: CourseFilters,
49
+ }
50
+ MAX_COUNT = 50
51
+
52
+
53
+ class Search:
54
+ """Search LinkedIn the way the app's search and job search screens do.
55
+
56
+ Vertical methods return one page; pass `page.next` as `cursor` with the same keywords
57
+ and filters to continue. `count` is a page size from 1 to 50.
58
+ """
59
+
60
+ def __init__(self, voyager: Voyager) -> None:
61
+ self._voyager = voyager
62
+
63
+ async def all(
64
+ self, keywords: str, *, count: int = 10, cursor: Cursor | None = None
65
+ ) -> Page[ResultGroup]:
66
+ """Blended results: groups such as Jobs, Posts, People and Companies."""
67
+ if not _keywords(keywords):
68
+ raise ValueError("Blended search needs keywords.")
69
+ reply, where = await self._clusters(Vertical.ALL, keywords, {}, count, cursor)
70
+ return results.group_page(reply, where)
71
+
72
+ async def people(
73
+ self,
74
+ keywords: str = "",
75
+ filters: PeopleFilters | None = None,
76
+ *,
77
+ count: int = 10,
78
+ cursor: Cursor | None = None,
79
+ ) -> Page[PersonResult]:
80
+ return await self._vertical(
81
+ Vertical.PEOPLE, keywords, filters, count, cursor, results.person
82
+ )
83
+
84
+ async def posts(
85
+ self,
86
+ keywords: str = "",
87
+ filters: PostFilters | None = None,
88
+ *,
89
+ count: int = 10,
90
+ cursor: Cursor | None = None,
91
+ ) -> Page[Post]:
92
+ """Posts as feed `Post`s; results the feed model cannot read are left out."""
93
+ return await self._vertical(Vertical.POSTS, keywords, filters, count, cursor, results.post)
94
+
95
+ async def companies(
96
+ self,
97
+ keywords: str = "",
98
+ filters: CompanyFilters | None = None,
99
+ *,
100
+ count: int = 10,
101
+ cursor: Cursor | None = None,
102
+ ) -> Page[SearchResult]:
103
+ return await self._vertical(
104
+ Vertical.COMPANIES, keywords, filters, count, cursor, results.entity
105
+ )
106
+
107
+ async def groups(
108
+ self, keywords: str, *, count: int = 10, cursor: Cursor | None = None
109
+ ) -> Page[SearchResult]:
110
+ return await self._vertical(Vertical.GROUPS, keywords, None, count, cursor, results.entity)
111
+
112
+ async def schools(
113
+ self, keywords: str, *, count: int = 10, cursor: Cursor | None = None
114
+ ) -> Page[SearchResult]:
115
+ return await self._vertical(Vertical.SCHOOLS, keywords, None, count, cursor, results.entity)
116
+
117
+ async def events(
118
+ self, keywords: str, *, count: int = 10, cursor: Cursor | None = None
119
+ ) -> Page[SearchResult]:
120
+ return await self._vertical(Vertical.EVENTS, keywords, None, count, cursor, results.entity)
121
+
122
+ async def courses(
123
+ self,
124
+ keywords: str = "",
125
+ filters: CourseFilters | None = None,
126
+ *,
127
+ count: int = 10,
128
+ cursor: Cursor | None = None,
129
+ ) -> Page[SearchResult]:
130
+ """LinkedIn Learning courses."""
131
+ return await self._vertical(
132
+ Vertical.COURSES, keywords, filters, count, cursor, results.entity
133
+ )
134
+
135
+ async def products(
136
+ self,
137
+ keywords: str = "",
138
+ filters: ProductFilters | None = None,
139
+ *,
140
+ count: int = 10,
141
+ cursor: Cursor | None = None,
142
+ ) -> Page[SearchResult]:
143
+ return await self._vertical(
144
+ Vertical.PRODUCTS, keywords, filters, count, cursor, results.entity
145
+ )
146
+
147
+ async def services(
148
+ self,
149
+ keywords: str = "",
150
+ filters: ServiceFilters | None = None,
151
+ *,
152
+ count: int = 10,
153
+ cursor: Cursor | None = None,
154
+ ) -> Page[PersonResult]:
155
+ """Members who offer services."""
156
+ return await self._vertical(
157
+ Vertical.SERVICES, keywords, filters, count, cursor, results.person
158
+ )
159
+
160
+ async def jobs(
161
+ self,
162
+ keywords: str = "",
163
+ filters: JobFilters | None = None,
164
+ *,
165
+ count: int = 25,
166
+ cursor: Cursor | None = None,
167
+ ) -> Page[JobResult]:
168
+ """Job cards; pass `JobResult.id` to the jobs feature for details or applying."""
169
+ query = _job_query(keywords, filters)
170
+ operation, scope = "search.jobs", restli.encode(query)
171
+ start, _token = paging.resume(cursor, operation, scope, self._voyager.owner)
172
+ size = _count(count)
173
+ data = await self._voyager.query(
174
+ queries.JOB_CARDS,
175
+ {"query": query, "count": size, "start": start, "includeJobState": True},
176
+ )
177
+ reply = paging.collection(data, "jobsDashJobCardsByJobSearch")
178
+ where = (operation, scope, start, size, self._voyager.owner)
179
+ return results.job_page(reply, JobResult.from_card, where)
180
+
181
+ async def filters(
182
+ self,
183
+ vertical: Vertical,
184
+ keywords: str = "",
185
+ applied: VerticalFilters | None = None,
186
+ ) -> tuple[Filter, ...]:
187
+ """The filters and suggested values the app offers for a vertical and query."""
188
+ vertical = Vertical(vertical)
189
+ query = queries.search_query(vertical, _keywords(keywords), _params(vertical, applied))
190
+ data = await self._voyager.query(queries.FILTERS, {"query": query})
191
+ reply = paging.collection(data, "searchDashFilterClustersByFilters")
192
+ return filters_from_clusters(items(reply.get("elements")))
193
+
194
+ async def job_filters(
195
+ self, keywords: str = "", filters: JobFilters | None = None
196
+ ) -> tuple[Filter, ...]:
197
+ """Job search filters for a query, such as salary buckets and distances near a place."""
198
+ data = await self._voyager.query(
199
+ queries.JOB_FILTERS, {"query": _job_query(keywords, filters)}
200
+ )
201
+ reply = paging.collection(data, "jobsDashSearchFilterClustersResourceByFilters")
202
+ return filters_from_clusters(items(reply.get("elements")))
203
+
204
+ async def typeahead(
205
+ self, kind: TypeaheadKind, keywords: str, *, count: int | None = None
206
+ ) -> tuple[Suggestion, ...]:
207
+ """Find filter values by name, for example `typeahead(TypeaheadKind.GEO, "Berlin")`."""
208
+ kind = TypeaheadKind(kind)
209
+ if not _keywords(keywords):
210
+ raise ValueError("Typeahead needs some text to match.")
211
+ variables = queries.typeahead_variables(
212
+ kind, _keywords(keywords), None if count is None else _count(count)
213
+ )
214
+ data = await self._voyager.query(queries.TYPEAHEAD, variables)
215
+ reply = paging.collection(data, "searchDashReusableTypeaheadByType")
216
+ return tuple(Suggestion.from_typeahead(item) for item in items(reply.get("elements")))
217
+
218
+ async def suggest(self, keywords: str) -> tuple[Suggestion, ...]:
219
+ """The search bar's suggestions: matching entities and completed keywords."""
220
+ if not _keywords(keywords):
221
+ raise ValueError("Suggestions need some text to match.")
222
+ data = await self._voyager.query(queries.GLOBAL_TYPEAHEAD, {"query": _keywords(keywords)})
223
+ reply = paging.collection(data, "searchDashTypeaheadByGlobalTypeahead")
224
+ return tuple(Suggestion.from_global(item) for item in items(reply.get("elements")))
225
+
226
+ async def _vertical(
227
+ self,
228
+ vertical: Vertical,
229
+ keywords: str,
230
+ filters: VerticalFilters | None,
231
+ count: int,
232
+ cursor: Cursor | None,
233
+ read: Callable[[dict[str, Any]], T | None],
234
+ ) -> Page[T]:
235
+ params = _params(vertical, filters)
236
+ reply, where = await self._clusters(vertical, keywords, params, count, cursor)
237
+ return results.result_page(reply, read, where)
238
+
239
+ async def _clusters(
240
+ self, vertical: Vertical, keywords: str, params: Params, count: int, cursor: Cursor | None
241
+ ) -> tuple[dict[str, Any], results.Where]:
242
+ query = queries.search_query(vertical, _keywords(keywords), params)
243
+ operation, scope = f"search.{vertical.name.lower()}", restli.encode(query)
244
+ start, token = paging.resume(cursor, operation, scope, self._voyager.owner)
245
+ size = _count(count)
246
+ variables = {
247
+ "start": start,
248
+ "count": size,
249
+ "origin": queries.search_origin(vertical, params),
250
+ "query": query,
251
+ "paginationToken": token,
252
+ }
253
+ data = await self._voyager.query(queries.CLUSTERS, variables)
254
+ reply = paging.collection(data, "searchDashClustersByAll")
255
+ return reply, (operation, scope, start, size, self._voyager.owner)
256
+
257
+
258
+ def _params(vertical: Vertical, filters: VerticalFilters | None) -> Params:
259
+ if filters is None:
260
+ return {}
261
+ expected = FILTER_TYPES.get(vertical)
262
+ if expected is None or not isinstance(filters, expected):
263
+ name = expected.__name__ if expected else "no filters"
264
+ raise ValueError(f"{vertical.name.lower()} search takes {name}.")
265
+ return filters.params()
266
+
267
+
268
+ def _job_query(keywords: str, filters: JobFilters | None) -> dict[str, Any]:
269
+ if filters is not None and not isinstance(filters, JobFilters):
270
+ raise ValueError("Job search takes JobFilters.")
271
+ filters = filters or JobFilters()
272
+ return queries.job_query(_keywords(keywords), filters.geo_urn(), filters.params())
273
+
274
+
275
+ def _keywords(keywords: str) -> str:
276
+ if not isinstance(keywords, str):
277
+ raise ValueError("keywords must be text.")
278
+ return " ".join(keywords.split())
279
+
280
+
281
+ def _count(count: int) -> int:
282
+ if type(count) is not int or not 1 <= count <= MAX_COUNT:
283
+ raise ValueError(f"count must be an integer from 1 to {MAX_COUNT}.")
284
+ return count
@@ -0,0 +1,30 @@
1
+ """Social threads: the post and comment URNs that reactions and comment lists address."""
2
+
3
+ import re
4
+
5
+ _POST = re.compile(r"urn:li:(activity|ugcPost):[0-9]+")
6
+ _COMMENT = re.compile(r"urn:li:comment:\((activity|ugcPost):[0-9]+,[0-9]+\)")
7
+
8
+
9
+ def post(urn: str) -> str:
10
+ """A post's social thread: `urn:li:activity:<id>` or `urn:li:ugcPost:<id>` (`Post.urn`)."""
11
+ if not isinstance(urn, str) or not _POST.fullmatch(urn):
12
+ raise ValueError(
13
+ "A post URN such as urn:li:activity:<id> or urn:li:ugcPost:<id> is needed."
14
+ )
15
+ return urn
16
+
17
+
18
+ def thread(urn: str) -> str:
19
+ """A reactable thread: a post URN or `urn:li:comment:(activity:<post>,<id>)`."""
20
+ if isinstance(urn, str) and _COMMENT.fullmatch(urn):
21
+ return urn
22
+ if not isinstance(urn, str) or not _POST.fullmatch(urn):
23
+ raise ValueError("A post or comment URN such as urn:li:activity:<id> is needed.")
24
+ return urn
25
+
26
+
27
+ def social_detail(thread_urn: str) -> str:
28
+ """The social detail of a thread: it lists a post's comments or a comment's replies and
29
+ carries the thread's comment settings."""
30
+ return f"urn:li:fsd_socialDetail:({thread_urn},{thread_urn},urn:li:highlightedReply:-)"
@@ -0,0 +1,5 @@
1
+ """Session public exports."""
2
+
3
+ from .state import Session # Private account state and persistence.
4
+
5
+ __all__ = ["Session"]
@@ -0,0 +1,52 @@
1
+ """Scoped, serializable cookies."""
2
+
3
+ from dataclasses import dataclass
4
+ from http.cookiejar import Cookie
5
+
6
+ from ..exceptions import SessionError
7
+
8
+
9
+ @dataclass(slots=True, repr=False)
10
+ class SessionCookie:
11
+ """Preserve cookie scope and expiry instead of flattening the cookie jar."""
12
+
13
+ name: str
14
+ value: str
15
+ domain: str
16
+ path: str = "/"
17
+ secure: bool = True
18
+ expires: int | None = None
19
+ host_only: bool = False
20
+
21
+ def __post_init__(self) -> None:
22
+ if (
23
+ not all(isinstance(v, str) for v in (self.name, self.value, self.domain, self.path))
24
+ or not self.name
25
+ or not self.path.startswith("/")
26
+ or self.domain.lstrip(".") not in {"linkedin.com", "www.linkedin.com"}
27
+ or any(ch in self.name + self.value for ch in "\r\n")
28
+ or type(self.secure) is not bool
29
+ or type(self.host_only) is not bool
30
+ or (self.expires is not None and type(self.expires) is not int)
31
+ ):
32
+ raise SessionError("Invalid LinkedIn cookie state.")
33
+
34
+ def to_cookie(self) -> Cookie:
35
+ return Cookie(
36
+ version=0,
37
+ name=self.name,
38
+ value=self.value,
39
+ port=None,
40
+ port_specified=False,
41
+ domain=self.domain,
42
+ domain_specified=not self.host_only,
43
+ domain_initial_dot=self.domain.startswith("."),
44
+ path=self.path,
45
+ path_specified=True,
46
+ secure=self.secure,
47
+ expires=self.expires,
48
+ discard=self.expires is None,
49
+ comment=None,
50
+ comment_url=None,
51
+ rest={},
52
+ )
@@ -0,0 +1,30 @@
1
+ """POSIX ownership of one session file, following the local Instagram client's pattern."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from ..exceptions import SessionInUse
7
+
8
+
9
+ class SessionLease:
10
+ """Hold a stable sidecar lock until the owning client closes."""
11
+
12
+ def __init__(self, path: Path) -> None:
13
+ import fcntl
14
+
15
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
16
+ self._fd = os.open(str(path) + ".lock", os.O_CREAT | os.O_RDWR, 0o600)
17
+ try:
18
+ os.fchmod(self._fd, 0o600)
19
+ fcntl.flock(self._fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
20
+ except BlockingIOError:
21
+ self.close()
22
+ raise SessionInUse("This session file is already open in another client.") from None
23
+ except BaseException:
24
+ self.close()
25
+ raise
26
+
27
+ def close(self) -> None:
28
+ if self._fd >= 0:
29
+ os.close(self._fd)
30
+ self._fd = -1