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,155 @@
1
+ """Models public exports."""
2
+
3
+ from .comments import Comment # One comment or reply.
4
+ from .comments import CommentSort # Most relevant or most recent comments.
5
+ from .comments import Mention # A tagged member inside comment text.
6
+ from .content import Author # Member or company shown on a post or comment.
7
+ from .content import Poll # A poll attached to a post.
8
+ from .content import PollOption # One poll choice and its vote state.
9
+ from .content import Post # One feed update.
10
+ from .content import ReactionType # Like, Celebrate, Support, Love, Insightful, Funny.
11
+ from .content import SocialCounts # Reaction, comment and repost totals.
12
+ from .feed import CompanyContent # Which company page posts to list.
13
+ from .feed import FeedSort # Top or recent main feed.
14
+ from .feed import ProfileActivity # A member's posts, comments or reactions.
15
+ from .feed import ReactionSort # Order of a reactions list.
16
+ from .feed import Reactor # Who reacted to a post, and how.
17
+ from .feed import SavedPost # One entry of the saved-posts list.
18
+ from .jobs import Job # One job posting as a seeker sees it.
19
+ from .jobs import TrackedJob # One entry of the My jobs lists.
20
+ from .jobs import TrackedStatus # Saved, in progress, applied or archived.
21
+ from .jobs import WorkplaceType # On-site, remote or hybrid.
22
+ from .jobs_apply import ApplicationForm # An Easy Apply form: pages, questions, resumes.
23
+ from .jobs_apply import ApplicationStatus # Result of a submitted application.
24
+ from .jobs_apply import FileQuestion # A file slot such as the resume.
25
+ from .jobs_apply import FormPage # One page of an Easy Apply form.
26
+ from .jobs_apply import Option # One choice of a question.
27
+ from .jobs_apply import Question # One Easy Apply question.
28
+ from .jobs_apply import QuestionKind # Text, number, single choice, dropdown, checkbox.
29
+ from .jobs_apply import Resume # A resume offered from earlier applications.
30
+ from .jobs_apply import Submission # A checked application body, not yet sent.
31
+ from .jobs_apply import UploadedFile # A stored file, ready to attach.
32
+ from .member import Member # Verified member identity.
33
+ from .pagination import Cursor # Continues the request that produced a page.
34
+ from .pagination import Page # One page of results.
35
+ from .posts import CommentScope # Who may comment on a post.
36
+ from .posts import Document # A document attachment.
37
+ from .posts import Image # An image attachment.
38
+ from .posts import LinkPreview # A link attachment.
39
+ from .posts import MediaKind # Image, video or document upload.
40
+ from .posts import NewPoll # A poll to publish.
41
+ from .posts import PollDuration # How long a new poll runs.
42
+ from .posts import UploadedMedia # An uploaded file, ready to attach.
43
+ from .posts import Video # A video attachment.
44
+ from .posts import Visibility # Anyone or connections only.
45
+ from .posts_results import MediaStatus # Processing state of an upload.
46
+ from .posts_results import Repost # An instant repost.
47
+ from .posts_results import ScheduledPost # One scheduled post.
48
+ from .posts_results import SharedPost # A published or scheduled post.
49
+ from .search import Filter # One available search filter.
50
+ from .search import FilterOption # One suggested filter value.
51
+ from .search import PersonResult # A person in search results.
52
+ from .search import ResultGroup # One block of blended results.
53
+ from .search import SearchResult # A company, group, school, event, course or product.
54
+ from .search import Suggestion # A typeahead match; its id is a filter value.
55
+ from .search import TypeaheadKind # What a typeahead looks up.
56
+ from .search import Vertical # Which kind of result a search returns.
57
+ from .search_filters import CompanyFilters # Company search filters.
58
+ from .search_filters import CompanySize # Company size buckets.
59
+ from .search_filters import ContentType # Post search content types.
60
+ from .search_filters import CourseFilters # Course search filters.
61
+ from .search_filters import CourseLength # Course length buckets.
62
+ from .search_filters import CourseLevel # Course levels.
63
+ from .search_filters import DatePosted # Post search date ranges.
64
+ from .search_filters import Network # 1st, 2nd and 3rd+ connections.
65
+ from .search_filters import PeopleFilters # People search filters.
66
+ from .search_filters import PostedBy # Post search author scope.
67
+ from .search_filters import PostFilters # Post search filters.
68
+ from .search_filters import PostSort # Post search order.
69
+ from .search_filters import ProductFilters # Product search filters.
70
+ from .search_filters import ServiceFilters # Service search filters.
71
+ from .search_jobs import Commitment # Employer commitments filter.
72
+ from .search_jobs import ExperienceLevel # Job seniority filter.
73
+ from .search_jobs import JobDatePosted # Job posting date ranges.
74
+ from .search_jobs import JobFilters # Job search filters.
75
+ from .search_jobs import JobResult # One job card in search results.
76
+ from .search_jobs import JobSort # Job search order.
77
+ from .search_jobs import JobType # Full-time, part-time, contract and more.
78
+
79
+ __all__ = [
80
+ "ApplicationForm",
81
+ "ApplicationStatus",
82
+ "Author",
83
+ "Comment",
84
+ "CommentScope",
85
+ "CommentSort",
86
+ "Commitment",
87
+ "CompanyContent",
88
+ "CompanyFilters",
89
+ "CompanySize",
90
+ "ContentType",
91
+ "CourseFilters",
92
+ "CourseLength",
93
+ "CourseLevel",
94
+ "Cursor",
95
+ "DatePosted",
96
+ "Document",
97
+ "ExperienceLevel",
98
+ "FeedSort",
99
+ "FileQuestion",
100
+ "Filter",
101
+ "FilterOption",
102
+ "FormPage",
103
+ "Image",
104
+ "Job",
105
+ "JobDatePosted",
106
+ "JobFilters",
107
+ "JobResult",
108
+ "JobSort",
109
+ "JobType",
110
+ "LinkPreview",
111
+ "MediaKind",
112
+ "MediaStatus",
113
+ "Member",
114
+ "Mention",
115
+ "Network",
116
+ "NewPoll",
117
+ "Option",
118
+ "Page",
119
+ "PeopleFilters",
120
+ "PersonResult",
121
+ "Poll",
122
+ "PollDuration",
123
+ "PollOption",
124
+ "Post",
125
+ "PostFilters",
126
+ "PostSort",
127
+ "PostedBy",
128
+ "ProductFilters",
129
+ "ProfileActivity",
130
+ "Question",
131
+ "QuestionKind",
132
+ "ReactionSort",
133
+ "ReactionType",
134
+ "Reactor",
135
+ "Repost",
136
+ "ResultGroup",
137
+ "Resume",
138
+ "SavedPost",
139
+ "ScheduledPost",
140
+ "SearchResult",
141
+ "ServiceFilters",
142
+ "SharedPost",
143
+ "SocialCounts",
144
+ "Submission",
145
+ "Suggestion",
146
+ "TrackedJob",
147
+ "TrackedStatus",
148
+ "TypeaheadKind",
149
+ "UploadedFile",
150
+ "UploadedMedia",
151
+ "Vertical",
152
+ "Video",
153
+ "Visibility",
154
+ "WorkplaceType",
155
+ ]
@@ -0,0 +1,65 @@
1
+ """Small readers for Voyager JSON; absent or mistyped fields read as None."""
2
+
3
+ from datetime import UTC
4
+ from datetime import datetime
5
+ from typing import Any
6
+ from urllib.parse import urlsplit
7
+
8
+
9
+ def obj(value: object) -> dict[str, Any]:
10
+ return value if isinstance(value, dict) else {}
11
+
12
+
13
+ def items(value: object) -> list[dict[str, Any]]:
14
+ return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
15
+
16
+
17
+ def dig(value: object, *keys: str) -> Any:
18
+ """Follow nested object keys; None as soon as one is missing."""
19
+ for key in keys:
20
+ if not isinstance(value, dict):
21
+ return None
22
+ value = value.get(key)
23
+ return value
24
+
25
+
26
+ def string(value: object, *keys: str) -> str | None:
27
+ found = dig(value, *keys)
28
+ return found if isinstance(found, str) else None
29
+
30
+
31
+ def integer(value: object, *keys: str) -> int | None:
32
+ found = dig(value, *keys)
33
+ return found if type(found) is int else None
34
+
35
+
36
+ def boolean(value: object, *keys: str) -> bool | None:
37
+ found = dig(value, *keys)
38
+ return found if isinstance(found, bool) else None
39
+
40
+
41
+ def text(value: object, *keys: str) -> str | None:
42
+ """The string of a Voyager TextViewModel ({"text": ...}) at the given path."""
43
+ return string(value, *keys, "text")
44
+
45
+
46
+ def urn_id(urn: str | None) -> str | None:
47
+ """The last colon-separated part of a simple URN, such as the digits of an activity."""
48
+ if not urn or "(" in urn:
49
+ return None
50
+ return urn.rsplit(":", 1)[-1] or None
51
+
52
+
53
+ def public_id(
54
+ url: str | None, sections: tuple[str, ...] = ("in", "company", "school", "showcase")
55
+ ) -> str | None:
56
+ """The vanity name in a profile or page URL, such as `ada` in linkedin.com/in/ada?x=1."""
57
+ if not url:
58
+ return None
59
+ parts = [part for part in urlsplit(url).path.split("/") if part]
60
+ return parts[1] if len(parts) >= 2 and parts[0] in sections else None
61
+
62
+
63
+ def epoch_ms(millis: int | None) -> datetime | None:
64
+ """A UTC time from Voyager's epoch milliseconds; zero or absent reads as None."""
65
+ return datetime.fromtimestamp(millis / 1000, UTC) if millis else None
@@ -0,0 +1,145 @@
1
+ """Comments, replies and comment reactions as Voyager's dash `Comment` objects describe them."""
2
+
3
+ from copy import deepcopy
4
+ from dataclasses import dataclass
5
+ from dataclasses import field
6
+ from enum import StrEnum
7
+ from typing import Any
8
+
9
+ from ._json import boolean
10
+ from ._json import dig
11
+ from ._json import integer
12
+ from ._json import items
13
+ from ._json import obj
14
+ from ._json import public_id
15
+ from ._json import string
16
+ from ._json import text
17
+ from .content import Author
18
+ from .content import SocialCounts
19
+
20
+
21
+ class CommentSort(StrEnum):
22
+ """The app's two comment orders; the UI labels are in comments."""
23
+
24
+ RELEVANCE = "RELEVANCE" # Most relevant
25
+ RECENT = "REVERSE_CHRONOLOGICAL" # Most recent
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class Mention:
30
+ """A tag inside comment or post text: `urn` covers `length` characters from `start`.
31
+
32
+ `urn` is a member's profile URN (`urn:li:fsd_profile:<id>`, as `Author.profile_urn`
33
+ reads it); posts also accept a company (`urn:li:fsd_company:<id>`). Positions count
34
+ Unicode code points, as the app does; the server silently drops a wrong offset.
35
+ """
36
+
37
+ urn: str
38
+ start: int
39
+ length: int
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class Comment:
44
+ """One comment or reply.
45
+
46
+ `urn` (`urn:li:comment:(activity:<post>,<id>)`) is the comment's own social thread: every
47
+ comment operation, reaction and reply addresses it. `entity_urn` is the dash entity form.
48
+ `post_urn` is the post's thread; `parent_urn` is set on replies. `replies` holds only the
49
+ preview the server embeds; `Comments.replies()` pages through all of them.
50
+ `created_at` is in epoch milliseconds. `actions` lists the server's allowed actions,
51
+ such as `EDIT_COMMENT` and `DELETE`.
52
+ """
53
+
54
+ urn: str
55
+ entity_urn: str | None
56
+ post_urn: str | None
57
+ parent_urn: str | None
58
+ author: Author | None
59
+ text: str | None
60
+ mentions: tuple[Mention, ...]
61
+ created_at: int | None
62
+ edited: bool | None
63
+ pinned: bool | None
64
+ by_post_author: bool | None
65
+ counts: SocialCounts
66
+ actions: tuple[str, ...]
67
+ can_reply: bool | None = None
68
+ can_react: bool | None = None
69
+ hide_action_urn: str | None = None
70
+ permalink: str | None = None
71
+ replies: tuple["Comment", ...] = ()
72
+ raw: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
73
+
74
+ @classmethod
75
+ def from_json(cls, comment: dict[str, Any]) -> "Comment | None":
76
+ """Build a comment from a dash `Comment`; None when it has no comment URN."""
77
+ urn = string(comment, "urn")
78
+ if not urn or not urn.startswith("urn:li:comment:("):
79
+ return None
80
+ social = obj(comment.get("socialDetail"))
81
+ counts = obj(social.get("totalSocialActivityCounts"))
82
+ permissions = obj(social.get("socialPermissions"))
83
+ commenter = comment.get("commenter")
84
+ replies = (cls.from_json(reply) for reply in items(dig(social, "comments", "elements")))
85
+ hide_action = string(comment, "hideCommentActionUrn") or string(
86
+ comment, "hideCommentAction", "entityUrn"
87
+ )
88
+ return cls(
89
+ urn=urn,
90
+ entity_urn=string(comment, "entityUrn"),
91
+ post_urn=_post_urn(urn),
92
+ parent_urn=string(comment, "parentCommentBackendUrn"),
93
+ author=_author(commenter) if isinstance(commenter, dict) else None,
94
+ text=text(comment, "commentary"),
95
+ mentions=_mentions(obj(comment.get("commentary"))),
96
+ created_at=integer(comment, "createdAt"),
97
+ edited=boolean(comment, "edited"),
98
+ pinned=boolean(comment, "pinned"),
99
+ by_post_author=boolean(commenter, "author"),
100
+ counts=SocialCounts.from_json(counts),
101
+ actions=tuple(a for a in comment.get("actions") or () if isinstance(a, str)),
102
+ can_reply=boolean(permissions, "canPostComments"),
103
+ can_react=boolean(permissions, "canReact"),
104
+ hide_action_urn=hide_action,
105
+ permalink=string(comment, "permalink"),
106
+ replies=tuple(reply for reply in replies if reply is not None),
107
+ raw=deepcopy(comment),
108
+ )
109
+
110
+
111
+ def _author(commenter: dict[str, Any]) -> Author:
112
+ """Read a comment's `commenter`; its shape differs from a feed `ActorComponent`."""
113
+ actor = obj(commenter.get("actor"))
114
+ profile = string(actor, "profileUrn", "entityUrn") or string(
115
+ commenter, "actorUnion", "profileUrn"
116
+ )
117
+ subtitle = commenter.get("subtitle")
118
+ url = string(commenter, "navigationUrl")
119
+ return Author(
120
+ urn=string(commenter, "urn"),
121
+ name=text(commenter, "title"),
122
+ headline=subtitle if isinstance(subtitle, str) else text(commenter, "subtitle"),
123
+ profile_urn=profile,
124
+ public_id=public_id(url),
125
+ url=url.split("?", 1)[0] if url else None,
126
+ )
127
+
128
+
129
+ def _mentions(commentary: dict[str, Any]) -> tuple[Mention, ...]:
130
+ found = []
131
+ for attribute in items(commentary.get("attributesV2")):
132
+ start, length = integer(attribute, "start"), integer(attribute, "length")
133
+ detail = obj(attribute.get("detailData"))
134
+ urn = string(detail, "profileMention", "entityUrn") or string(
135
+ detail, "profileFullName", "entityUrn"
136
+ )
137
+ if urn and start is not None and length is not None:
138
+ found.append(Mention(urn, start, length))
139
+ return tuple(found)
140
+
141
+
142
+ def _post_urn(comment_urn: str) -> str | None:
143
+ """The post thread inside `urn:li:comment:(activity:<id>,<comment id>)`."""
144
+ thread = comment_urn.removeprefix("urn:li:comment:(").rpartition(",")[0]
145
+ return f"urn:li:{thread}" if thread and "(" not in thread else None
@@ -0,0 +1,207 @@
1
+ """Posts and their authors as Voyager's feed `Update` objects describe them."""
2
+
3
+ from copy import deepcopy
4
+ from dataclasses import dataclass
5
+ from dataclasses import field
6
+ from enum import StrEnum
7
+ from typing import Any
8
+
9
+ from ._json import boolean
10
+ from ._json import dig
11
+ from ._json import integer
12
+ from ._json import items
13
+ from ._json import obj
14
+ from ._json import public_id
15
+ from ._json import string
16
+ from ._json import text
17
+
18
+
19
+ class ReactionType(StrEnum):
20
+ """The app's reaction names; the UI labels are in comments."""
21
+
22
+ LIKE = "LIKE"
23
+ CELEBRATE = "PRAISE" # Celebrate
24
+ SUPPORT = "APPRECIATION" # Support
25
+ LOVE = "EMPATHY" # Love
26
+ INSIGHTFUL = "INTEREST" # Insightful
27
+ FUNNY = "ENTERTAINMENT" # Funny
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class Author:
32
+ """A member or organization shown as a post's or comment's actor."""
33
+
34
+ urn: str | None
35
+ name: str | None
36
+ headline: str | None = None
37
+ profile_urn: str | None = None
38
+ public_id: str | None = None
39
+ url: str | None = None
40
+
41
+ @property
42
+ def is_company(self) -> bool:
43
+ return bool(self.urn and self.urn.startswith(("urn:li:company:", "urn:li:organization:")))
44
+
45
+ @classmethod
46
+ def from_actor(cls, actor: dict[str, Any]) -> "Author":
47
+ """Read an `ActorComponent` (feed updates) or comment `commenter` block."""
48
+ attributes = items(dig(actor, "name", "attributesV2"))
49
+ detail = obj(attributes[0].get("detailData")) if attributes else {}
50
+ profile = obj(detail.get("profileFullName"))
51
+ url = string(actor, "navigationContext", "actionTarget")
52
+ return cls(
53
+ urn=string(actor, "backendUrn"),
54
+ name=text(actor, "name"),
55
+ headline=text(actor, "description"),
56
+ profile_urn=string(profile, "entityUrn"),
57
+ public_id=public_id(url),
58
+ url=url.split("?", 1)[0] if url else None,
59
+ )
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class SocialCounts:
64
+ """Reaction, comment and repost totals, and the viewer's own reaction.
65
+
66
+ `viewer_reaction` is the viewer's reaction name, such as `PRAISE`, when they reacted.
67
+ """
68
+
69
+ reactions: int = 0
70
+ comments: int = 0
71
+ reposts: int = 0
72
+ by_type: dict[str, int] = field(default_factory=dict)
73
+ viewer_reacted: bool | None = None
74
+ viewer_reaction: str | None = None
75
+
76
+ @classmethod
77
+ def from_json(cls, counts: dict[str, Any]) -> "SocialCounts":
78
+ by_type = {
79
+ entry["reactionType"]: entry["count"]
80
+ for entry in items(counts.get("reactionTypeCounts"))
81
+ if isinstance(entry.get("reactionType"), str) and type(entry.get("count")) is int
82
+ }
83
+ return cls(
84
+ reactions=integer(counts, "numLikes") or 0,
85
+ comments=integer(counts, "numComments") or 0,
86
+ reposts=integer(counts, "numShares") or 0,
87
+ by_type=by_type,
88
+ viewer_reacted=boolean(counts, "liked"),
89
+ viewer_reaction=string(counts, "reacted"),
90
+ )
91
+
92
+
93
+ @dataclass(frozen=True, slots=True)
94
+ class PollOption:
95
+ """One poll choice; `urn` is what `Feed.vote` and `Feed.unvote` take."""
96
+
97
+ urn: str | None
98
+ text: str | None
99
+ votes: int | None = None
100
+ voted: bool = False
101
+
102
+
103
+ @dataclass(frozen=True, slots=True)
104
+ class Poll:
105
+ """A post's poll: its question, options with vote counts, and whether it closed."""
106
+
107
+ question: str | None
108
+ options: tuple[PollOption, ...]
109
+ voters: int | None = None
110
+ closed: bool | None = None
111
+ multi_select: bool | None = None
112
+
113
+ @classmethod
114
+ def from_component(cls, poll: dict[str, Any]) -> "Poll":
115
+ """Read a `PollComponent`; summaries line up with options by position, as in the app."""
116
+ summary = obj(poll.get("pollSummary"))
117
+ totals = items(summary.get("pollOptionSummaries"))
118
+ chosen = summary.get("votedPollOptionUrns")
119
+ voted = (
120
+ {urn for urn in chosen if isinstance(urn, str)} if isinstance(chosen, list) else set()
121
+ )
122
+ options = []
123
+ for index, option in enumerate(items(poll.get("pollOptions"))):
124
+ urn = string(option, "pollOptionUrn")
125
+ votes = integer(totals[index], "voteCount") if index < len(totals) else None
126
+ options.append(PollOption(urn, text(option, "option"), votes, urn in voted))
127
+ return cls(
128
+ question=text(poll, "question"),
129
+ options=tuple(options),
130
+ voters=integer(summary, "uniqueVotersCount"),
131
+ closed=boolean(summary, "closed"),
132
+ multi_select=boolean(poll, "multiSelect"),
133
+ )
134
+
135
+
136
+ @dataclass(frozen=True, slots=True)
137
+ class Post:
138
+ """One feed update.
139
+
140
+ `urn` is the post's social thread (an activity or ugcPost URN), which reactions and
141
+ comments address. `activity_urn` is the feed activity, which differs when the update is
142
+ a "liked this" or similar context around someone else's post. `update_urn` is the feed
143
+ update itself (`urn:li:fsd_update:(...)`), which hiding addresses. `share_url` is the
144
+ post's public link.
145
+ """
146
+
147
+ urn: str
148
+ activity_urn: str | None
149
+ share_urn: str | None
150
+ author: Author | None
151
+ text: str | None
152
+ age: str | None
153
+ visibility: str | None
154
+ context: str | None
155
+ content_type: str | None
156
+ counts: SocialCounts
157
+ can_react: bool | None = None
158
+ can_comment: bool | None = None
159
+ can_share: bool | None = None
160
+ reshared: "Post | None" = None
161
+ update_urn: str | None = None
162
+ share_url: str | None = None
163
+ poll: Poll | None = None
164
+ raw: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
165
+
166
+ @classmethod
167
+ def from_update(cls, update: dict[str, Any]) -> "Post | None":
168
+ """Build a post from an `Update`; None for modules such as recommendations.
169
+
170
+ The full-screen video layout carries the actor and commentary inside its component
171
+ instead of on the update, so both places are read.
172
+ """
173
+ social = obj(update.get("socialDetail"))
174
+ activity = string(update, "metadata", "backendUrn")
175
+ urn = string(social, "threadUrn") or activity
176
+ content = obj(update.get("content"))
177
+ video = obj(content.get("largeSingleVideoComponent"))
178
+ actor = update.get("actor") or video.get("actor")
179
+ if not urn or not isinstance(actor, dict):
180
+ return None
181
+ kinds = [key for key, value in content.items() if value and key.endswith("Component")]
182
+ age = text(actor, "subDescription")
183
+ reshared = update.get("resharedUpdate")
184
+ permissions = obj(social.get("socialPermissions"))
185
+ poll = content.get("pollComponent")
186
+ # The link's query names the viewer as the sharer (rcm=...), so only the path is kept.
187
+ share_url = string(update, "socialContent", "shareUrl")
188
+ return cls(
189
+ urn=urn,
190
+ activity_urn=activity,
191
+ share_urn=string(update, "metadata", "shareUrn"),
192
+ author=Author.from_actor(actor),
193
+ text=text(update, "commentary", "text") or text(video, "commentary"),
194
+ age=(age.split("•", 1)[0].strip() or None) if age else None,
195
+ visibility=string(update, "metadata", "shareAudience"),
196
+ context=text(update, "header", "text"),
197
+ content_type=kinds[0].removesuffix("Component") if kinds else None,
198
+ counts=SocialCounts.from_json(obj(social.get("totalSocialActivityCounts"))),
199
+ can_react=boolean(permissions, "canReact"),
200
+ can_comment=boolean(permissions, "canPostComments"),
201
+ can_share=boolean(permissions, "canShare"),
202
+ reshared=cls.from_update(reshared) if isinstance(reshared, dict) else None,
203
+ update_urn=string(update, "entityUrn"),
204
+ share_url=share_url.split("?", 1)[0] if share_url else None,
205
+ poll=Poll.from_component(poll) if isinstance(poll, dict) else None,
206
+ raw=deepcopy(update),
207
+ )