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,227 @@
|
|
|
1
|
+
"""Read, write and react to comments with the app's Voyager comment operations.
|
|
2
|
+
|
|
3
|
+
Reads are the conversations GraphQL finders; comments are created with the GraphQL
|
|
4
|
+
`CreateComment` mutation the app uses on threaded posts, edited and deleted through the
|
|
5
|
+
`voyagerSocialDashNormComments` rest.li resource. Every write is sent once.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from typing import TypeVar
|
|
10
|
+
|
|
11
|
+
from ...exceptions import ProtocolError
|
|
12
|
+
from ...exceptions import UnconfirmedWrite
|
|
13
|
+
from ...models._json import dig
|
|
14
|
+
from ...models._json import items
|
|
15
|
+
from ...models._json import obj
|
|
16
|
+
from ...models.comments import Comment
|
|
17
|
+
from ...models.comments import CommentSort
|
|
18
|
+
from ...models.comments import Mention
|
|
19
|
+
from ...models.content import ReactionType
|
|
20
|
+
from ...models.feed import ReactionSort
|
|
21
|
+
from ...models.feed import Reactor
|
|
22
|
+
from ...models.pagination import Cursor
|
|
23
|
+
from ...models.pagination import Page
|
|
24
|
+
from ...transport.voyager import GraphQLQuery
|
|
25
|
+
from ...transport.voyager import Voyager
|
|
26
|
+
from .. import paging
|
|
27
|
+
from .. import threads
|
|
28
|
+
from ..mentions import attributes as mention_attributes
|
|
29
|
+
from ..reactions import Reactions
|
|
30
|
+
from .urns import CommentUrn
|
|
31
|
+
|
|
32
|
+
LIST = GraphQLQuery(
|
|
33
|
+
"voyagerSocialDashComments.b3ab21bfb7ad22b2f1bcb3b38149a0cb", "FetchCommentsWithReplySortOrder"
|
|
34
|
+
)
|
|
35
|
+
# The comment-detail finder: given a comment's social detail it lists that comment's replies.
|
|
36
|
+
REPLIES = GraphQLQuery(
|
|
37
|
+
"voyagerSocialDashComments.59bca422f480a4cc0ce56ccd81181488", "FetchComments"
|
|
38
|
+
)
|
|
39
|
+
SINGLE = GraphQLQuery(
|
|
40
|
+
"voyagerSocialDashComments.8033a0107cfe3b64ffebc9eef95f2f37", "FetchSingleComment"
|
|
41
|
+
)
|
|
42
|
+
CREATE = GraphQLQuery(
|
|
43
|
+
"voyagerSocialDashNormComments.cd3d2a3fd6c9b2881c7cac32847ec05e", "CreateComment"
|
|
44
|
+
)
|
|
45
|
+
HIDE = GraphQLQuery(
|
|
46
|
+
"voyagerSocialDashHideCommentAction.42dde3771a51671edde117e558ab9d46", "HideCommentAction"
|
|
47
|
+
)
|
|
48
|
+
UNHIDE = GraphQLQuery(
|
|
49
|
+
"voyagerSocialDashHideCommentAction.e0b625699dc0ad3e093ac92a0b53dfc0", "UndoHideCommentAction"
|
|
50
|
+
)
|
|
51
|
+
NORM_COMMENTS = "voyagerSocialDashNormComments"
|
|
52
|
+
DECORATION = "com.linkedin.voyager.dash.deco.social.NormComment-38"
|
|
53
|
+
# Replies always come oldest first in the app.
|
|
54
|
+
REPLY_ORDER = "CHRONOLOGICAL"
|
|
55
|
+
|
|
56
|
+
T = TypeVar("T")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Comments:
|
|
60
|
+
"""Comments on any post the account can see; editing and deleting only its own."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, voyager: Voyager) -> None:
|
|
63
|
+
self._voyager = voyager
|
|
64
|
+
self._reactions = Reactions(voyager)
|
|
65
|
+
|
|
66
|
+
async def list(
|
|
67
|
+
self,
|
|
68
|
+
post_urn: str,
|
|
69
|
+
*,
|
|
70
|
+
sort: CommentSort = CommentSort.RELEVANCE,
|
|
71
|
+
count: int = 10,
|
|
72
|
+
cursor: Cursor | None = None,
|
|
73
|
+
) -> Page[Comment]:
|
|
74
|
+
"""Read one page of a post's top-level comments, each with its reply preview."""
|
|
75
|
+
thread, order = threads.post(post_urn), CommentSort(sort)
|
|
76
|
+
scope = f"{thread}|{order.value}"
|
|
77
|
+
start, token = paging.resume(cursor, "comments.list", scope, self._voyager.owner)
|
|
78
|
+
size = _count(count)
|
|
79
|
+
variables = {
|
|
80
|
+
"socialDetailUrn": threads.social_detail(thread),
|
|
81
|
+
"start": start,
|
|
82
|
+
"count": size,
|
|
83
|
+
"paginationToken": token,
|
|
84
|
+
"sortOrder": order.value,
|
|
85
|
+
"replySortOrder": REPLY_ORDER,
|
|
86
|
+
}
|
|
87
|
+
data = await self._voyager.query(LIST, variables)
|
|
88
|
+
found = paging.collection(data, "socialDashCommentsBySocialDetail")
|
|
89
|
+
return paging.page(
|
|
90
|
+
found, Comment.from_json, "comments.list", scope, start, size, self._voyager.owner
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
async def replies(
|
|
94
|
+
self, comment_urn: str, *, count: int = 10, cursor: Cursor | None = None
|
|
95
|
+
) -> Page[Comment]:
|
|
96
|
+
"""Read one page of a comment's replies, oldest first."""
|
|
97
|
+
comment = CommentUrn.parse(comment_urn)
|
|
98
|
+
start, token = paging.resume(cursor, "comments.replies", comment.urn, self._voyager.owner)
|
|
99
|
+
size = _count(count)
|
|
100
|
+
variables = {
|
|
101
|
+
"socialDetailUrn": threads.social_detail(comment.urn),
|
|
102
|
+
"start": start,
|
|
103
|
+
"count": size,
|
|
104
|
+
"paginationToken": token,
|
|
105
|
+
"sortOrder": REPLY_ORDER,
|
|
106
|
+
}
|
|
107
|
+
data = await self._voyager.query(REPLIES, variables)
|
|
108
|
+
found = paging.collection(data, "socialDashCommentsBySocialDetail")
|
|
109
|
+
return paging.page(
|
|
110
|
+
found,
|
|
111
|
+
Comment.from_json,
|
|
112
|
+
"comments.replies",
|
|
113
|
+
comment.urn,
|
|
114
|
+
start,
|
|
115
|
+
size,
|
|
116
|
+
self._voyager.owner,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
async def get(self, comment_urn: str) -> Comment:
|
|
120
|
+
"""Read one comment or reply; a deleted one raises `OperationRejected`."""
|
|
121
|
+
comment = CommentUrn.parse(comment_urn)
|
|
122
|
+
data = await self._voyager.query(
|
|
123
|
+
SINGLE, {"commentUrn": comment.entity_urn, "updateThreadUrn": comment.post_urn}
|
|
124
|
+
)
|
|
125
|
+
elements = items(dig(data, "socialDashCommentsBySingleComment", "elements"))
|
|
126
|
+
return _comment(elements[0] if elements else None)
|
|
127
|
+
|
|
128
|
+
async def create(
|
|
129
|
+
self, post_urn: str, text: str, *, mentions: Sequence[Mention] = ()
|
|
130
|
+
) -> Comment:
|
|
131
|
+
"""Comment on a post; `mentions` tag members inside `text`."""
|
|
132
|
+
return await self._create(threads.post(post_urn), text, mentions)
|
|
133
|
+
|
|
134
|
+
async def reply(
|
|
135
|
+
self, comment_urn: str, text: str, *, mentions: Sequence[Mention] = ()
|
|
136
|
+
) -> Comment:
|
|
137
|
+
"""Reply to a top-level comment."""
|
|
138
|
+
return await self._create(CommentUrn.parse(comment_urn).urn, text, mentions)
|
|
139
|
+
|
|
140
|
+
async def edit(
|
|
141
|
+
self, comment_urn: str, text: str, *, mentions: Sequence[Mention] = ()
|
|
142
|
+
) -> Comment:
|
|
143
|
+
"""Replace the text of the account's own comment or reply."""
|
|
144
|
+
comment = CommentUrn.parse(comment_urn)
|
|
145
|
+
patch = {"patch": {"$set": {"commentary": _commentary(text, mentions)}}}
|
|
146
|
+
reply = await self._voyager.post(
|
|
147
|
+
f"{NORM_COMMENTS}/{comment.entity_urn}", patch, {"decorationId": DECORATION}
|
|
148
|
+
)
|
|
149
|
+
elements = items(dig(reply.data, "singleComment", "elements"))
|
|
150
|
+
return _comment(elements[0] if elements else None, written=True)
|
|
151
|
+
|
|
152
|
+
async def delete(self, comment_urn: str) -> None:
|
|
153
|
+
"""Delete the account's own comment (with its replies) or reply."""
|
|
154
|
+
comment = CommentUrn.parse(comment_urn)
|
|
155
|
+
await self._voyager.delete(f"{NORM_COMMENTS}/{comment.entity_urn}")
|
|
156
|
+
|
|
157
|
+
async def react(self, comment_urn: str, reaction: ReactionType = ReactionType.LIKE) -> None:
|
|
158
|
+
"""React to a comment or reply."""
|
|
159
|
+
await self._reactions.add(CommentUrn.parse(comment_urn).urn, reaction)
|
|
160
|
+
|
|
161
|
+
async def change_reaction(self, comment_urn: str, reaction: ReactionType) -> None:
|
|
162
|
+
"""Change the account's existing reaction on a comment or reply."""
|
|
163
|
+
await self._reactions.change(CommentUrn.parse(comment_urn).urn, reaction)
|
|
164
|
+
|
|
165
|
+
async def unreact(self, comment_urn: str) -> None:
|
|
166
|
+
"""Remove the account's reaction from a comment or reply."""
|
|
167
|
+
await self._reactions.remove(CommentUrn.parse(comment_urn).urn)
|
|
168
|
+
|
|
169
|
+
async def reactions(
|
|
170
|
+
self,
|
|
171
|
+
comment_urn: str,
|
|
172
|
+
*,
|
|
173
|
+
reaction: ReactionType | None = None,
|
|
174
|
+
sort: ReactionSort = ReactionSort.RELEVANCE,
|
|
175
|
+
count: int = 10,
|
|
176
|
+
cursor: Cursor | None = None,
|
|
177
|
+
) -> Page[Reactor]:
|
|
178
|
+
"""Who reacted to a comment, optionally only one reaction type."""
|
|
179
|
+
thread = CommentUrn.parse(comment_urn).urn
|
|
180
|
+
return await self._reactions.list(
|
|
181
|
+
thread, reaction=reaction, sort=sort, count=count, cursor=cursor
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
async def hide(self, comment: Comment) -> None:
|
|
185
|
+
"""Hide someone's comment on the account's own post via its `hide_action_urn`."""
|
|
186
|
+
await self._voyager.mutate(HIDE, {"hideCommentActionUrn": _hide_action(comment)})
|
|
187
|
+
|
|
188
|
+
async def unhide(self, comment: Comment) -> None:
|
|
189
|
+
"""Show a comment hidden with `hide()` again."""
|
|
190
|
+
await self._voyager.mutate(UNHIDE, {"hideCommentActionUrn": _hide_action(comment)})
|
|
191
|
+
|
|
192
|
+
async def _create(self, thread_urn: str, text: str, mentions: Sequence[Mention]) -> Comment:
|
|
193
|
+
entity = {"commentary": _commentary(text, mentions), "threadUrn": thread_urn}
|
|
194
|
+
data = await self._voyager.mutate(CREATE, {"entity": entity})
|
|
195
|
+
return _comment(
|
|
196
|
+
dig(data, "createSocialDashNormComments", "entity", "comment"), written=True
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _commentary(text: str, mentions: Sequence[Mention]) -> dict[str, object]:
|
|
201
|
+
"""The app's comment `TextViewModel`: plain text plus one attribute per member mention."""
|
|
202
|
+
if not isinstance(text, str) or not text.strip():
|
|
203
|
+
raise ValueError("Comment text must not be empty.")
|
|
204
|
+
written = mention_attributes(text, mentions, companies=False)
|
|
205
|
+
return {"text": text, "attributesV2": written, "textDirection": "FIRST_STRONG"}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _comment(value: object, *, written: bool = False) -> Comment:
|
|
209
|
+
comment = Comment.from_json(obj(value))
|
|
210
|
+
if comment is None:
|
|
211
|
+
failure = UnconfirmedWrite if written else ProtocolError
|
|
212
|
+
raise failure("LinkedIn's reply did not contain the comment.")
|
|
213
|
+
return comment
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _hide_action(comment: Comment) -> str:
|
|
217
|
+
if not isinstance(comment, Comment):
|
|
218
|
+
raise ValueError("A Comment read from LinkedIn is required.")
|
|
219
|
+
if not comment.hide_action_urn:
|
|
220
|
+
raise ValueError("LinkedIn offers no hide action for this comment.")
|
|
221
|
+
return comment.hide_action_urn
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _count(count: int) -> int:
|
|
225
|
+
if type(count) is not int or not 1 <= count <= 100:
|
|
226
|
+
raise ValueError("count must be an integer from 1 to 100.")
|
|
227
|
+
return count
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""The URN forms of posts and comments that the comment operations exchange.
|
|
2
|
+
|
|
3
|
+
A comment has two spellings, both seen in every comment the server returns:
|
|
4
|
+
`urn:li:comment:(activity:<post>,<id>)` (its social thread, for reactions and replies) and
|
|
5
|
+
`urn:li:fsd_comment:(<id>,urn:li:activity:<post>)` (the entity that is edited or deleted).
|
|
6
|
+
Replies use the post as their thread too, never the parent comment.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
_COMMENT = re.compile(r"urn:li:comment:\(([A-Za-z]+:[0-9]+),([0-9]+)\)")
|
|
13
|
+
_ENTITY = re.compile(r"urn:li:fsd_comment:\(([0-9]+),urn:li:([A-Za-z]+:[0-9]+)\)")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class CommentUrn:
|
|
18
|
+
"""A comment identified by its post thread (`activity:<id>`) and its own ID."""
|
|
19
|
+
|
|
20
|
+
thread: str
|
|
21
|
+
id: str
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def parse(cls, urn: str) -> "CommentUrn":
|
|
25
|
+
"""Accept either spelling of a comment URN."""
|
|
26
|
+
if isinstance(urn, str):
|
|
27
|
+
if found := _COMMENT.fullmatch(urn):
|
|
28
|
+
return cls(found[1], found[2])
|
|
29
|
+
if found := _ENTITY.fullmatch(urn):
|
|
30
|
+
return cls(found[2], found[1])
|
|
31
|
+
raise ValueError("A comment URN such as urn:li:comment:(activity:<id>,<id>) is required.")
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def urn(self) -> str:
|
|
35
|
+
return f"urn:li:comment:({self.thread},{self.id})"
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def entity_urn(self) -> str:
|
|
39
|
+
return f"urn:li:fsd_comment:({self.id},urn:li:{self.thread})"
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def post_urn(self) -> str:
|
|
43
|
+
return f"urn:li:{self.thread}"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""The app's persisted feed operations, with where each is built in LinkedIn Android 4.1.1267."""
|
|
2
|
+
|
|
3
|
+
from ...transport.voyager import GraphQLQuery
|
|
4
|
+
|
|
5
|
+
# feed/pages/main/MainFeedUpdatesConfigFactory.createGraphQLRequest
|
|
6
|
+
MAIN_FEED = GraphQLQuery("voyagerFeedDashMainFeed.8a3a2fc70646e2b7462e79e23cee3935", "MainFeed")
|
|
7
|
+
|
|
8
|
+
# feed/framework/graphql/FeedFrameworkGraphQLClient.feedSingleUpdateByBackendUrnOrNss
|
|
9
|
+
UPDATE = GraphQLQuery(
|
|
10
|
+
"voyagerFeedDashUpdates.5f0daf4e88c68281f1fac9d328bc386e", "FeedSingleUpdateByBackendUrnOrNss"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
# learning/LearningRepository$$ExternalSyntheticLambda0 (CreatorProfileFeedUpdateRepository)
|
|
14
|
+
PROFILE_POSTS = GraphQLQuery(
|
|
15
|
+
"voyagerFeedDashProfileUpdates.8f803ebb60ca03b522e43c9a46dd539b",
|
|
16
|
+
"CreatorFeedDashProfileUpdatesByMemberShareFeed",
|
|
17
|
+
)
|
|
18
|
+
PROFILE_COMMENTS = GraphQLQuery(
|
|
19
|
+
"voyagerFeedDashProfileUpdates.6d854fab352bd47d28325b6abb93c6e8",
|
|
20
|
+
"FeedDashProfileUpdatesByMemberComments",
|
|
21
|
+
)
|
|
22
|
+
PROFILE_REACTIONS = GraphQLQuery(
|
|
23
|
+
"voyagerFeedDashProfileUpdates.1d5b78d08a87d2367604ab284efe1f1a",
|
|
24
|
+
"FeedDashProfileUpdatesByMemberReactions",
|
|
25
|
+
)
|
|
26
|
+
# pages/graphql/PagesGraphQLClient.organizationalPageMemberPosts (PagesMemberPostsFeature)
|
|
27
|
+
COMPANY_POSTS = GraphQLQuery(
|
|
28
|
+
"voyagerFeedDashOrganizationalPageUpdates.def294ef4d8d43b7b53324bf739d00d3",
|
|
29
|
+
"OrganizationalPageMemberPosts",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# feed/framework/action/savestate/SaveStateManager.getSaveStateRequester
|
|
33
|
+
SAVE_STATE = GraphQLQuery(
|
|
34
|
+
"voyagerFeedDashSaveStates.26886775a6d430481be84c4ca2eb1a5f", "UpdateSaveStateBySaveStateUrn"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# feed/framework/action/updateaction/UpdateActionPublisher.publish(Undo)FeedbackAction
|
|
39
|
+
HIDE = GraphQLQuery(
|
|
40
|
+
"voyagerFeedDashUpdateFeedback.46392e3c45b16ecca40508fabcb2b9ab", "FeedbackAction"
|
|
41
|
+
)
|
|
42
|
+
UNDO_HIDE = GraphQLQuery(
|
|
43
|
+
"voyagerFeedDashUpdateFeedback.27ec6f4a27da65e6f4a9338f8195f37d", "UndoFeedbackAction"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# feed/framework/plugin/poll/PollManager.performVote
|
|
47
|
+
VOTE = GraphQLQuery(
|
|
48
|
+
"voyagerSocialDashPollVotes.3f3a224fe4f900d90add90c8095a4eb7", "VoteByPollOptionUrn"
|
|
49
|
+
)
|
|
50
|
+
UNVOTE = GraphQLQuery(
|
|
51
|
+
"voyagerSocialDashPollVotes.686cdba82be7dc6f851fea7ab0a963d8", "UnVoteByPollOptionUrn"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Module keys: FeedModuleKeyUtils.getModuleKey for the post detail page, and the constant
|
|
55
|
+
# PagesGraphQLClient.organizationalPageMemberPosts always sends.
|
|
56
|
+
DETAIL_MODULE = "feed-item:phone"
|
|
57
|
+
COMPANY_MODULE = "ORGANIZATION_MEMBER_FEED_PHONE"
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Feed reads and the viewer's explicit actions on posts.
|
|
2
|
+
|
|
3
|
+
Unset GraphQL variables are None, which rest.li encoding leaves out, as the app does.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from ...exceptions import ProtocolError
|
|
7
|
+
from ...exceptions import UnconfirmedWrite
|
|
8
|
+
from ...models._json import items
|
|
9
|
+
from ...models.content import Post
|
|
10
|
+
from ...models.content import ReactionType
|
|
11
|
+
from ...models.feed import CompanyContent
|
|
12
|
+
from ...models.feed import FeedSort
|
|
13
|
+
from ...models.feed import ProfileActivity
|
|
14
|
+
from ...models.feed import ReactionSort
|
|
15
|
+
from ...models.feed import Reactor
|
|
16
|
+
from ...models.feed import SavedPost
|
|
17
|
+
from ...models.pagination import Cursor
|
|
18
|
+
from ...models.pagination import Page
|
|
19
|
+
from ...transport.voyager import GraphQLQuery
|
|
20
|
+
from ...transport.voyager import Voyager
|
|
21
|
+
from .. import paging
|
|
22
|
+
from ..reactions import Reactions
|
|
23
|
+
from . import queries
|
|
24
|
+
from . import saved
|
|
25
|
+
from . import urns
|
|
26
|
+
|
|
27
|
+
_PROFILE_QUERIES = {
|
|
28
|
+
ProfileActivity.POSTS: (queries.PROFILE_POSTS, "feedDashProfileUpdatesByMemberShareFeed"),
|
|
29
|
+
ProfileActivity.COMMENTS: (queries.PROFILE_COMMENTS, "feedDashProfileUpdatesByMemberComments"),
|
|
30
|
+
ProfileActivity.REACTIONS: (
|
|
31
|
+
queries.PROFILE_REACTIONS,
|
|
32
|
+
"feedDashProfileUpdatesByMemberReactions",
|
|
33
|
+
),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Feed:
|
|
38
|
+
"""Read feeds, posts, reactors and saved posts; act on posts only when asked.
|
|
39
|
+
|
|
40
|
+
Reads never write. Each write is sent once and never retried; a timeout leaves its
|
|
41
|
+
outcome unknown.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, voyager: Voyager) -> None:
|
|
45
|
+
self._voyager = voyager
|
|
46
|
+
self._reactions = Reactions(voyager)
|
|
47
|
+
|
|
48
|
+
async def home(
|
|
49
|
+
self, *, count: int = 10, sort: FeedSort | None = None, cursor: Cursor | None = None
|
|
50
|
+
) -> Page[Post]:
|
|
51
|
+
"""One page of the main feed; recommendation modules are left out of `items`.
|
|
52
|
+
|
|
53
|
+
Without `sort` the server uses the member's preference. A sort applies to this
|
|
54
|
+
request only; it does not change the saved preference.
|
|
55
|
+
"""
|
|
56
|
+
size = urns.count(count)
|
|
57
|
+
order = FeedSort(sort) if sort is not None else None
|
|
58
|
+
scope = order.value if order else ""
|
|
59
|
+
start, token = paging.resume(cursor, "feed.home", scope, self._voyager.owner)
|
|
60
|
+
variables = {
|
|
61
|
+
"paginationToken": token,
|
|
62
|
+
"start": start,
|
|
63
|
+
"count": size,
|
|
64
|
+
"sortOrder": order.value if order else None,
|
|
65
|
+
}
|
|
66
|
+
data = await self._voyager.query(queries.MAIN_FEED, variables)
|
|
67
|
+
found = paging.collection(data, "feedDashMainFeedByMainFeed")
|
|
68
|
+
return paging.page(
|
|
69
|
+
found, Post.from_update, "feed.home", scope, start, size, self._voyager.owner
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
async def get(self, urn: str) -> Post:
|
|
73
|
+
"""One post by its activity, ugcPost or share URN.
|
|
74
|
+
|
|
75
|
+
The server refuses a missing or deleted post (`OperationRejected`).
|
|
76
|
+
"""
|
|
77
|
+
data = await self._voyager.query(
|
|
78
|
+
queries.UPDATE,
|
|
79
|
+
{
|
|
80
|
+
"urnOrNss": urns.backend(urn),
|
|
81
|
+
"commentsCount": 10,
|
|
82
|
+
"likesCount": 10,
|
|
83
|
+
"moduleKey": queries.DETAIL_MODULE,
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
found = paging.collection(data, "feedDashUpdatesByBackendUrnOrNss")
|
|
87
|
+
for update in items(found.get("elements")):
|
|
88
|
+
post = Post.from_update(update)
|
|
89
|
+
if post is not None:
|
|
90
|
+
return post
|
|
91
|
+
raise ProtocolError("LinkedIn returned no post for this URN.")
|
|
92
|
+
|
|
93
|
+
async def profile(
|
|
94
|
+
self,
|
|
95
|
+
profile_urn: str,
|
|
96
|
+
activity: ProfileActivity = ProfileActivity.POSTS,
|
|
97
|
+
*,
|
|
98
|
+
count: int = 10,
|
|
99
|
+
cursor: Cursor | None = None,
|
|
100
|
+
) -> Page[Post]:
|
|
101
|
+
"""A member's posts, or the posts they commented on or reacted to, newest first.
|
|
102
|
+
|
|
103
|
+
For comments and reactions, `Post.context` says what the member did.
|
|
104
|
+
"""
|
|
105
|
+
kind = ProfileActivity(activity)
|
|
106
|
+
query, field = _PROFILE_QUERIES[kind]
|
|
107
|
+
profile = urns.profile(profile_urn)
|
|
108
|
+
size = urns.count(count)
|
|
109
|
+
scope = f"{kind.value}:{profile}"
|
|
110
|
+
start, token = paging.resume(cursor, "feed.profile", scope, self._voyager.owner)
|
|
111
|
+
variables = {
|
|
112
|
+
"profileUrn": profile,
|
|
113
|
+
"count": size,
|
|
114
|
+
"paginationToken": token,
|
|
115
|
+
"start": start,
|
|
116
|
+
}
|
|
117
|
+
data = await self._voyager.query(query, variables)
|
|
118
|
+
found = paging.collection(data, field)
|
|
119
|
+
return paging.page(
|
|
120
|
+
found, Post.from_update, "feed.profile", scope, start, size, self._voyager.owner
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
async def company(
|
|
124
|
+
self,
|
|
125
|
+
company_urn: str,
|
|
126
|
+
content: CompanyContent = CompanyContent.ALL,
|
|
127
|
+
*,
|
|
128
|
+
count: int = 10,
|
|
129
|
+
cursor: Cursor | None = None,
|
|
130
|
+
) -> Page[Post]:
|
|
131
|
+
"""A company page's posts, optionally only images, videos, documents or articles."""
|
|
132
|
+
page_urn = urns.company_page(company_urn)
|
|
133
|
+
kind = CompanyContent(content)
|
|
134
|
+
size = urns.count(count)
|
|
135
|
+
scope = f"{kind.value}:{page_urn}"
|
|
136
|
+
start, token = paging.resume(cursor, "feed.company", scope, self._voyager.owner)
|
|
137
|
+
variables = {
|
|
138
|
+
"organizationalPageUrn": page_urn,
|
|
139
|
+
"count": size,
|
|
140
|
+
"filter": kind.value,
|
|
141
|
+
"moduleKey": queries.COMPANY_MODULE,
|
|
142
|
+
"paginationToken": token,
|
|
143
|
+
"start": start,
|
|
144
|
+
}
|
|
145
|
+
data = await self._voyager.query(queries.COMPANY_POSTS, variables)
|
|
146
|
+
found = paging.collection(data, "feedDashOrganizationalPageUpdatesByOrganizationalPageFeed")
|
|
147
|
+
return paging.page(
|
|
148
|
+
found, Post.from_update, "feed.company", scope, start, size, self._voyager.owner
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
async def reactions(
|
|
152
|
+
self,
|
|
153
|
+
urn: str,
|
|
154
|
+
*,
|
|
155
|
+
reaction: ReactionType | None = None,
|
|
156
|
+
sort: ReactionSort = ReactionSort.RELEVANCE,
|
|
157
|
+
count: int = 10,
|
|
158
|
+
cursor: Cursor | None = None,
|
|
159
|
+
) -> Page[Reactor]:
|
|
160
|
+
"""Who reacted to a post or comment, optionally only one reaction type."""
|
|
161
|
+
return await self._reactions.list(
|
|
162
|
+
urn, reaction=reaction, sort=sort, count=count, cursor=cursor
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
async def react(self, urn: str, reaction: ReactionType = ReactionType.LIKE) -> None:
|
|
166
|
+
"""Add the viewer's reaction to a post's thread (`Post.urn`)."""
|
|
167
|
+
await self._reactions.add(urn, reaction)
|
|
168
|
+
|
|
169
|
+
async def change_reaction(self, urn: str, reaction: ReactionType) -> None:
|
|
170
|
+
"""Replace the viewer's existing reaction on a post's thread."""
|
|
171
|
+
await self._reactions.change(urn, reaction)
|
|
172
|
+
|
|
173
|
+
async def unreact(self, urn: str) -> None:
|
|
174
|
+
"""Remove the viewer's reaction from a post's thread."""
|
|
175
|
+
await self._reactions.remove(urn)
|
|
176
|
+
|
|
177
|
+
async def save(self, activity_urn: str) -> None:
|
|
178
|
+
"""Save a post for later.
|
|
179
|
+
|
|
180
|
+
`activity_urn` is the post's own activity: `Post.urn` when it is an activity URN,
|
|
181
|
+
otherwise `Post.activity_urn` of the post as `get()` returns it.
|
|
182
|
+
"""
|
|
183
|
+
await self._set_saved(activity_urn, True)
|
|
184
|
+
|
|
185
|
+
async def unsave(self, activity_urn: str) -> None:
|
|
186
|
+
"""Remove a post from the saved items; takes the same URN as `save`."""
|
|
187
|
+
await self._set_saved(activity_urn, False)
|
|
188
|
+
|
|
189
|
+
async def saved(self, *, count: int = 10, cursor: Cursor | None = None) -> Page[SavedPost]:
|
|
190
|
+
"""One page of the viewer's saved posts, most recently saved first."""
|
|
191
|
+
return await saved.read(self._voyager, count, cursor)
|
|
192
|
+
|
|
193
|
+
async def hide(self, update_urn: str) -> None:
|
|
194
|
+
"""Hide a feed update (`Post.update_urn`) from the viewer's feed, like the app's Hide."""
|
|
195
|
+
await self._send(queries.HIDE, {"updateUrn": urns.update(update_urn)})
|
|
196
|
+
|
|
197
|
+
async def undo_hide(self, update_urn: str) -> None:
|
|
198
|
+
"""Undo `hide` for the same update URN."""
|
|
199
|
+
await self._send(queries.UNDO_HIDE, {"updateUrn": urns.update(update_urn)})
|
|
200
|
+
|
|
201
|
+
async def vote(self, poll_option_urn: str) -> None:
|
|
202
|
+
"""Vote for a poll option (`PollOption.urn`)."""
|
|
203
|
+
await self._send(queries.VOTE, {"pollOptionUrn": urns.poll_option(poll_option_urn)})
|
|
204
|
+
|
|
205
|
+
async def unvote(self, poll_option_urn: str) -> None:
|
|
206
|
+
"""Withdraw the viewer's vote for a poll option."""
|
|
207
|
+
await self._send(queries.UNVOTE, {"pollOptionUrn": urns.poll_option(poll_option_urn)})
|
|
208
|
+
|
|
209
|
+
async def _set_saved(self, activity_urn: str, is_saved: bool) -> None:
|
|
210
|
+
# The save state is keyed on the post's activity, as the app's update actions and
|
|
211
|
+
# the saved-posts list name it.
|
|
212
|
+
activity = urns.activity(activity_urn)
|
|
213
|
+
entity = {
|
|
214
|
+
"entity": {
|
|
215
|
+
"saved": is_saved,
|
|
216
|
+
"preDashEntityUrn": f"urn:li:fs_saveAction:(SAVE,{activity})",
|
|
217
|
+
},
|
|
218
|
+
"resourceKey": f"urn:li:fsd_saveState:(SAVE,{activity})",
|
|
219
|
+
}
|
|
220
|
+
await self._send(queries.SAVE_STATE, {"entity": entity})
|
|
221
|
+
|
|
222
|
+
async def _send(self, query: GraphQLQuery, variables: dict[str, object]) -> None:
|
|
223
|
+
data = await self._voyager.mutate(query, variables)
|
|
224
|
+
if not data:
|
|
225
|
+
raise UnconfirmedWrite("LinkedIn's reply to the action had no result.")
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""The viewer's saved posts, which the app lists as "My items" search results.
|
|
2
|
+
|
|
3
|
+
Every reply to this list carries a GraphQL error beside complete data: the server reports a
|
|
4
|
+
null in `metadata.entityResultAttributes`, a field the list does not use, so the read accepts
|
|
5
|
+
null-field errors and nothing else.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ...models._json import items
|
|
9
|
+
from ...models._json import obj
|
|
10
|
+
from ...models.feed import SavedPost
|
|
11
|
+
from ...models.pagination import Cursor
|
|
12
|
+
from ...models.pagination import Page
|
|
13
|
+
from ...transport.voyager import Voyager
|
|
14
|
+
from .. import paging
|
|
15
|
+
from ..search.queries import CLUSTERS
|
|
16
|
+
from . import urns
|
|
17
|
+
|
|
18
|
+
FIELD = "searchDashClustersByAll"
|
|
19
|
+
_INTENT = "SEARCH_MY_ITEMS_SAVED_POSTS"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def read(voyager: Voyager, count: int, cursor: Cursor | None) -> Page[SavedPost]:
|
|
23
|
+
"""One page of saved posts, most recently saved first."""
|
|
24
|
+
size = urns.count(count)
|
|
25
|
+
start, token = paging.resume(cursor, "feed.saved", "", voyager.owner)
|
|
26
|
+
variables = {
|
|
27
|
+
"query": {"flagshipSearchIntent": _INTENT},
|
|
28
|
+
"count": size,
|
|
29
|
+
"paginationToken": token,
|
|
30
|
+
"start": start,
|
|
31
|
+
}
|
|
32
|
+
data = await voyager.query(CLUSTERS, variables, ignore_null_field_errors=True)
|
|
33
|
+
found = paging.collection(data, FIELD)
|
|
34
|
+
# Each search cluster lists entity results; saved posts are those results.
|
|
35
|
+
results = [
|
|
36
|
+
obj(entry.get("item")).get("entityResult")
|
|
37
|
+
for cluster in items(found.get("elements"))
|
|
38
|
+
for entry in items(cluster.get("items"))
|
|
39
|
+
]
|
|
40
|
+
return paging.page(
|
|
41
|
+
found,
|
|
42
|
+
SavedPost.from_result,
|
|
43
|
+
"feed.saved",
|
|
44
|
+
"",
|
|
45
|
+
start,
|
|
46
|
+
size,
|
|
47
|
+
voyager.owner,
|
|
48
|
+
elements=[result for result in results if isinstance(result, dict)],
|
|
49
|
+
)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Input checks for feed operations; each raises ValueError with the expected form."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
_BACKEND = re.compile(r"urn:li:(activity|ugcPost|share):\d+")
|
|
6
|
+
_ACTIVITY = re.compile(r"urn:li:activity:\d+")
|
|
7
|
+
_UPDATE = re.compile(r"urn:li:fsd_update:\(.+\)")
|
|
8
|
+
_PROFILE = re.compile(r"urn:li:fsd_profile:[A-Za-z0-9_-]+")
|
|
9
|
+
_COMPANY = re.compile(r"urn:li:(company|fsd_company|fsd_organizationalPage):(\d+)")
|
|
10
|
+
|
|
11
|
+
# The app asks for 3 to 10 items at a time; this keeps requests in a modest range.
|
|
12
|
+
MAX_COUNT = 50
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def backend(urn: str) -> str:
|
|
16
|
+
"""A post to read: its activity, ugcPost or share URN."""
|
|
17
|
+
if not isinstance(urn, str) or not _BACKEND.fullmatch(urn):
|
|
18
|
+
raise ValueError(
|
|
19
|
+
"A post URN such as urn:li:activity:<id>, urn:li:ugcPost:<id> or urn:li:share:<id>"
|
|
20
|
+
" is needed."
|
|
21
|
+
)
|
|
22
|
+
return urn
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def activity(urn: str) -> str:
|
|
26
|
+
"""A post's own activity URN, which its save state is keyed on."""
|
|
27
|
+
if not isinstance(urn, str) or not _ACTIVITY.fullmatch(urn):
|
|
28
|
+
raise ValueError(
|
|
29
|
+
"Saving takes the post's activity URN (urn:li:activity:<id>): Post.urn when it is "
|
|
30
|
+
"an activity, otherwise Post.activity_urn."
|
|
31
|
+
)
|
|
32
|
+
return urn
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def update(urn: str) -> str:
|
|
36
|
+
"""A feed update URN, as `Post.update_urn` reads it."""
|
|
37
|
+
if not isinstance(urn, str) or not _UPDATE.fullmatch(urn):
|
|
38
|
+
raise ValueError("A feed update URN (urn:li:fsd_update:(...), Post.update_urn) is needed.")
|
|
39
|
+
return urn
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def profile(urn: str) -> str:
|
|
43
|
+
"""A member profile URN, as `Author.profile_urn` reads it."""
|
|
44
|
+
if not isinstance(urn, str) or not _PROFILE.fullmatch(urn):
|
|
45
|
+
raise ValueError("A profile URN such as urn:li:fsd_profile:<id> is needed.")
|
|
46
|
+
return urn
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def company_page(urn: str) -> str:
|
|
50
|
+
"""The company's page URN; a company URN names the page with the same ID."""
|
|
51
|
+
match = _COMPANY.fullmatch(urn) if isinstance(urn, str) else None
|
|
52
|
+
if not match:
|
|
53
|
+
raise ValueError("A company URN such as urn:li:company:<id> is needed.")
|
|
54
|
+
return f"urn:li:fsd_organizationalPage:{match.group(2)}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def poll_option(urn: str) -> str:
|
|
58
|
+
"""A poll option URN, as `PollOption.urn` reads it."""
|
|
59
|
+
if not isinstance(urn, str) or not urn.startswith("urn:li:") or len(urn) <= 7:
|
|
60
|
+
raise ValueError("A poll option URN (PollOption.urn) is needed.")
|
|
61
|
+
return urn
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def count(value: int) -> int:
|
|
65
|
+
"""A page size from 1 to MAX_COUNT."""
|
|
66
|
+
if type(value) is not int or not 1 <= value <= MAX_COUNT:
|
|
67
|
+
raise ValueError(f"count must be an integer from 1 to {MAX_COUNT}.")
|
|
68
|
+
return value
|