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