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,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,5 @@
1
+ """Feed resource exports."""
2
+
3
+ from .resource import Feed # Feed reads and explicit post actions.
4
+
5
+ __all__ = ["Feed"]
@@ -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
@@ -0,0 +1,5 @@
1
+ """Jobs resource exports."""
2
+
3
+ from .resource import Jobs # Job details, saved jobs, the tracker and Easy Apply.
4
+
5
+ __all__ = ["Jobs"]