marqetive-lib 0.1.5__py3-none-any.whl → 0.1.7__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.
@@ -0,0 +1,413 @@
1
+ """LinkedIn-specific models for the Community Management API.
2
+
3
+ This module defines LinkedIn-specific data models that extend the core models
4
+ for features unique to LinkedIn's Community Management API, including
5
+ reactions, social metadata, and organization management.
6
+ """
7
+
8
+ from datetime import datetime
9
+ from enum import StrEnum
10
+ from typing import Any, Literal
11
+
12
+ from pydantic import BaseModel, Field
13
+
14
+
15
+ class ReactionType(StrEnum):
16
+ """LinkedIn reaction types.
17
+
18
+ These correspond to the UI labels users see when reacting to content.
19
+
20
+ Attributes:
21
+ LIKE: Standard like reaction
22
+ PRAISE: "Celebrate" reaction
23
+ EMPATHY: "Love" reaction
24
+ INTEREST: "Insightful" reaction
25
+ APPRECIATION: "Support" reaction
26
+ ENTERTAINMENT: "Funny" reaction
27
+ """
28
+
29
+ LIKE = "LIKE"
30
+ PRAISE = "PRAISE" # Celebrate
31
+ EMPATHY = "EMPATHY" # Love
32
+ INTEREST = "INTEREST" # Insightful
33
+ APPRECIATION = "APPRECIATION" # Support
34
+ ENTERTAINMENT = "ENTERTAINMENT" # Funny
35
+
36
+
37
+ class CallToActionLabel(StrEnum):
38
+ """LinkedIn call-to-action button labels for posts.
39
+
40
+ These can be used with articles and sponsored content to add
41
+ clickable action buttons.
42
+ """
43
+
44
+ APPLY = "APPLY"
45
+ DOWNLOAD = "DOWNLOAD"
46
+ VIEW_QUOTE = "VIEW_QUOTE"
47
+ LEARN_MORE = "LEARN_MORE"
48
+ SIGN_UP = "SIGN_UP"
49
+ SUBSCRIBE = "SUBSCRIBE"
50
+ REGISTER = "REGISTER"
51
+ JOIN = "JOIN"
52
+ ATTEND = "ATTEND"
53
+ REQUEST_DEMO = "REQUEST_DEMO"
54
+ SEE_MORE = "SEE_MORE"
55
+ BUY_NOW = "BUY_NOW"
56
+ SHOP_NOW = "SHOP_NOW"
57
+
58
+
59
+ class FeedDistribution(StrEnum):
60
+ """LinkedIn feed distribution options for posts.
61
+
62
+ Controls how and where posts are distributed in feeds.
63
+ """
64
+
65
+ MAIN_FEED = "MAIN_FEED"
66
+ NONE = "NONE"
67
+
68
+
69
+ class PostVisibility(StrEnum):
70
+ """LinkedIn post visibility options.
71
+
72
+ Controls who can see the post.
73
+ """
74
+
75
+ PUBLIC = "PUBLIC"
76
+ CONNECTIONS = "CONNECTIONS"
77
+ LOGGED_IN = "LOGGED_IN"
78
+ CONTAINER = "CONTAINER"
79
+
80
+
81
+ class CommentsState(StrEnum):
82
+ """State of comments on a LinkedIn post.
83
+
84
+ OPEN: Comments are enabled
85
+ CLOSED: Comments are disabled (deletes existing comments)
86
+ """
87
+
88
+ OPEN = "OPEN"
89
+ CLOSED = "CLOSED"
90
+
91
+
92
+ class OrganizationType(StrEnum):
93
+ """LinkedIn organization types."""
94
+
95
+ COMPANY = "COMPANY"
96
+ SCHOOL = "SCHOOL"
97
+ GROUP = "GROUP"
98
+ SHOWCASE = "SHOWCASE"
99
+
100
+
101
+ class Reaction(BaseModel):
102
+ """Represents a reaction on a LinkedIn post or comment.
103
+
104
+ Attributes:
105
+ actor: URN of the person or organization that reacted
106
+ entity: URN of the entity (post/comment) that was reacted to
107
+ reaction_type: Type of reaction (LIKE, PRAISE, etc.)
108
+ created_at: Timestamp when the reaction was created
109
+
110
+ Example:
111
+ >>> reaction = Reaction(
112
+ ... actor="urn:li:person:abc123",
113
+ ... entity="urn:li:share:12345",
114
+ ... reaction_type=ReactionType.LIKE
115
+ ... )
116
+ """
117
+
118
+ actor: str
119
+ entity: str
120
+ reaction_type: ReactionType
121
+ created_at: datetime | None = None
122
+ raw_data: dict[str, Any] = Field(default_factory=dict)
123
+
124
+
125
+ class ReactionSummary(BaseModel):
126
+ """Summary of reactions by type.
127
+
128
+ Attributes:
129
+ reaction_type: Type of reaction
130
+ count: Number of reactions of this type
131
+ """
132
+
133
+ reaction_type: ReactionType
134
+ count: int
135
+
136
+
137
+ class CommentSummary(BaseModel):
138
+ """Summary of comments on a post.
139
+
140
+ Attributes:
141
+ count: Total number of comments (including replies)
142
+ top_level_count: Number of top-level comments (excluding replies)
143
+ """
144
+
145
+ count: int
146
+ top_level_count: int
147
+
148
+
149
+ class SocialMetadata(BaseModel):
150
+ """Social metadata for a LinkedIn post or comment.
151
+
152
+ Contains engagement metrics like reactions and comment counts.
153
+
154
+ Attributes:
155
+ entity: URN of the entity (post/comment)
156
+ reaction_summaries: Dictionary mapping reaction types to counts
157
+ comment_count: Total number of comments
158
+ top_level_comment_count: Number of top-level comments
159
+ comments_state: Whether comments are enabled (OPEN) or disabled (CLOSED)
160
+
161
+ Example:
162
+ >>> metadata = SocialMetadata(
163
+ ... entity="urn:li:share:12345",
164
+ ... reaction_summaries={ReactionType.LIKE: 10, ReactionType.PRAISE: 5},
165
+ ... comment_count=3,
166
+ ... top_level_comment_count=2,
167
+ ... comments_state=CommentsState.OPEN
168
+ ... )
169
+ """
170
+
171
+ entity: str
172
+ reaction_summaries: dict[ReactionType, int] = Field(default_factory=dict)
173
+ comment_count: int = 0
174
+ top_level_comment_count: int = 0
175
+ comments_state: CommentsState = CommentsState.OPEN
176
+ raw_data: dict[str, Any] = Field(default_factory=dict)
177
+
178
+
179
+ class Organization(BaseModel):
180
+ """Represents a LinkedIn organization (Company Page).
181
+
182
+ Attributes:
183
+ id: Organization URN (e.g., urn:li:organization:12345)
184
+ name: Organization name
185
+ localized_name: Localized organization name
186
+ vanity_name: URL-friendly name (e.g., "linkedin" for linkedin.com/company/linkedin)
187
+ logo_url: URL of the organization's logo
188
+ follower_count: Number of followers
189
+ primary_type: Type of organization (COMPANY, SCHOOL, etc.)
190
+ website_url: Organization's website
191
+ description: Organization description
192
+ industry: Industry category
193
+
194
+ Example:
195
+ >>> org = Organization(
196
+ ... id="urn:li:organization:12345",
197
+ ... name="Example Corp",
198
+ ... localized_name="Example Corp",
199
+ ... vanity_name="examplecorp"
200
+ ... )
201
+ """
202
+
203
+ id: str
204
+ name: str
205
+ localized_name: str
206
+ vanity_name: str | None = None
207
+ logo_url: str | None = None
208
+ follower_count: int | None = None
209
+ primary_type: OrganizationType | None = None
210
+ website_url: str | None = None
211
+ description: str | None = None
212
+ industry: str | None = None
213
+ raw_data: dict[str, Any] = Field(default_factory=dict)
214
+
215
+
216
+ class LinkedInPostContent(BaseModel):
217
+ """Content attachment for a LinkedIn post.
218
+
219
+ Can contain media (image/video/document) or an article link.
220
+
221
+ Attributes:
222
+ media: Media content details
223
+ article: Article link details
224
+ """
225
+
226
+ media: "MediaContent | None" = None
227
+ article: "ArticleContent | None" = None
228
+
229
+
230
+ class MediaContent(BaseModel):
231
+ """Media content for a LinkedIn post.
232
+
233
+ Attributes:
234
+ id: Media URN (e.g., urn:li:image:xxx, urn:li:video:xxx)
235
+ title: Optional title for the media
236
+ alt_text: Alternative text for accessibility
237
+ """
238
+
239
+ id: str
240
+ title: str | None = None
241
+ alt_text: str | None = None
242
+
243
+
244
+ class ArticleContent(BaseModel):
245
+ """Article content for a LinkedIn post.
246
+
247
+ Attributes:
248
+ source: URL of the article
249
+ title: Article title
250
+ description: Article description
251
+ thumbnail: URN of the thumbnail image
252
+ """
253
+
254
+ source: str
255
+ title: str | None = None
256
+ description: str | None = None
257
+ thumbnail: str | None = None
258
+
259
+
260
+ class LinkedInPostDistribution(BaseModel):
261
+ """Distribution settings for a LinkedIn post.
262
+
263
+ Attributes:
264
+ feed_distribution: Where to distribute the post
265
+ target_entities: Audience targeting criteria
266
+ third_party_distribution_channels: External distribution channels
267
+ """
268
+
269
+ feed_distribution: FeedDistribution = FeedDistribution.MAIN_FEED
270
+ target_entities: list[dict[str, Any]] = Field(default_factory=list)
271
+ third_party_distribution_channels: list[str] = Field(default_factory=list)
272
+
273
+
274
+ class CommentMention(BaseModel):
275
+ """Mention in a comment.
276
+
277
+ Attributes:
278
+ start: Start position in the text
279
+ length: Length of the mention text
280
+ person_urn: URN of the mentioned person (if person mention)
281
+ organization_urn: URN of the mentioned organization (if org mention)
282
+ """
283
+
284
+ start: int
285
+ length: int
286
+ person_urn: str | None = None
287
+ organization_urn: str | None = None
288
+
289
+
290
+ class LinkedInCommentRequest(BaseModel):
291
+ """Request model for creating or updating a LinkedIn comment.
292
+
293
+ Attributes:
294
+ content: Text content of the comment
295
+ parent_comment_id: URN of parent comment (for nested replies)
296
+ mentions: List of mentions to include
297
+ image_id: URN of image to attach to comment
298
+
299
+ Example:
300
+ >>> request = LinkedInCommentRequest(
301
+ ... content="Great post! @[John Doe](urn:li:person:abc123)",
302
+ ... mentions=[CommentMention(start=12, length=8, person_urn="urn:li:person:abc123")]
303
+ ... )
304
+ """
305
+
306
+ content: str
307
+ parent_comment_id: str | None = None
308
+ mentions: list[CommentMention] = Field(default_factory=list)
309
+ image_id: str | None = None
310
+
311
+
312
+ # Type alias for sort options
313
+ type PostSortBy = Literal["LAST_MODIFIED", "CREATED"]
314
+ type ReactionSortBy = Literal["CHRONOLOGICAL", "REVERSE_CHRONOLOGICAL", "RELEVANCE"]
315
+
316
+
317
+ class LinkedInPostRequest(BaseModel):
318
+ """LinkedIn-specific post creation request.
319
+
320
+ Supports text posts, articles, media (images/videos/documents), and
321
+ rich formatting options like CTAs and visibility controls.
322
+ LinkedIn has a 3000 character limit for post content.
323
+
324
+ Attributes:
325
+ content: Post text/commentary (max 3000 characters)
326
+ media_urls: List of media URLs to upload
327
+ media_ids: List of pre-uploaded media URNs
328
+ link: Article/link URL to share
329
+ visibility: Post visibility (PUBLIC, CONNECTIONS, LOGGED_IN)
330
+ feed_distribution: Feed distribution (MAIN_FEED, NONE)
331
+ target_entities: Audience targeting criteria
332
+ call_to_action: CTA button label
333
+ landing_page: URL for CTA button
334
+ article_title: Title for link preview
335
+ article_description: Description for link preview
336
+ article_thumbnail: Thumbnail URN for article
337
+ media_title: Title for media attachment
338
+ media_alt_text: Alt text for media (accessibility)
339
+ disable_reshare: Prevent resharing
340
+ disable_comments: Disable comments
341
+
342
+ Example:
343
+ >>> # Simple post
344
+ >>> request = LinkedInPostRequest(
345
+ ... content="Excited to share our latest update!"
346
+ ... )
347
+
348
+ >>> # Article post with CTA
349
+ >>> request = LinkedInPostRequest(
350
+ ... content="Check out our new blog post!",
351
+ ... link="https://example.com/blog",
352
+ ... article_title="Our Latest Update",
353
+ ... article_description="Learn about new features",
354
+ ... call_to_action=CallToActionLabel.LEARN_MORE,
355
+ ... landing_page="https://example.com/learn-more"
356
+ ... )
357
+
358
+ >>> # Post with media and visibility control
359
+ >>> request = LinkedInPostRequest(
360
+ ... content="New product launch!",
361
+ ... media_urls=["https://example.com/product.jpg"],
362
+ ... visibility=PostVisibility.PUBLIC,
363
+ ... media_alt_text="Product image"
364
+ ... )
365
+ """
366
+
367
+ content: str
368
+ media_urls: list[str] = Field(default_factory=list)
369
+ media_ids: list[str] = Field(default_factory=list)
370
+ link: str | None = None
371
+ visibility: PostVisibility = PostVisibility.PUBLIC
372
+ feed_distribution: FeedDistribution = FeedDistribution.MAIN_FEED
373
+ target_entities: list[dict[str, Any]] = Field(default_factory=list)
374
+ call_to_action: CallToActionLabel | None = None
375
+ landing_page: str | None = None
376
+ article_title: str | None = None
377
+ article_description: str | None = None
378
+ article_thumbnail: str | None = None
379
+ media_title: str | None = None
380
+ media_alt_text: str | None = None
381
+ disable_reshare: bool = False
382
+ disable_comments: bool = False
383
+
384
+
385
+ class LinkedInPostUpdateRequest(BaseModel):
386
+ """LinkedIn-specific post update request.
387
+
388
+ LinkedIn supports updating certain fields of published posts:
389
+ - commentary (post text)
390
+ - call-to-action label and landing page
391
+ - lifecycle state
392
+ - ad context (for sponsored content)
393
+
394
+ Attributes:
395
+ content: Updated post text/commentary
396
+ call_to_action: Updated CTA button label
397
+ landing_page: Updated URL for CTA button
398
+ lifecycle_state: Updated lifecycle state (e.g., PUBLISHED, DRAFT)
399
+ ad_context: Ad context updates for sponsored content
400
+
401
+ Example:
402
+ >>> request = LinkedInPostUpdateRequest(
403
+ ... content="Updated post content!",
404
+ ... call_to_action=CallToActionLabel.SIGN_UP,
405
+ ... landing_page="https://example.com/signup"
406
+ ... )
407
+ """
408
+
409
+ content: str | None = None
410
+ call_to_action: CallToActionLabel | None = None
411
+ landing_page: str | None = None
412
+ lifecycle_state: str | None = None
413
+ ad_context: dict[str, Any] | None = None
@@ -1,5 +1,6 @@
1
1
  """TikTok platform integration."""
2
2
 
3
3
  from marqetive.platforms.tiktok.client import TikTokClient
4
+ from marqetive.platforms.tiktok.models import PrivacyLevel, TikTokPostRequest
4
5
 
5
- __all__ = ["TikTokClient"]
6
+ __all__ = ["TikTokClient", "TikTokPostRequest", "PrivacyLevel"]
@@ -10,10 +10,11 @@ Reference: https://developers.tiktok.com/doc/content-posting-api-reference-direc
10
10
  """
11
11
 
12
12
  import asyncio
13
+ import inspect
13
14
  import logging
14
15
  import math
15
16
  import os
16
- from collections.abc import Callable
17
+ from collections.abc import Awaitable, Callable
17
18
  from dataclasses import dataclass
18
19
  from enum import Enum
19
20
  from typing import Any, Literal
@@ -24,10 +25,16 @@ from marqetive.core.exceptions import (
24
25
  InvalidFileTypeError,
25
26
  MediaUploadError,
26
27
  )
28
+ from marqetive.core.models import ProgressEvent, ProgressStatus
27
29
  from marqetive.platforms.tiktok.exceptions import TikTokErrorCode, map_tiktok_error
28
30
  from marqetive.utils.file_handlers import download_file
29
31
  from marqetive.utils.media import detect_mime_type, format_file_size
30
32
 
33
+ # Type aliases for progress callbacks
34
+ type SyncProgressCallback = Callable[[ProgressEvent], None]
35
+ type AsyncProgressCallback = Callable[[ProgressEvent], Awaitable[None]]
36
+ type ProgressCallback = SyncProgressCallback | AsyncProgressCallback
37
+
31
38
  logger = logging.getLogger(__name__)
32
39
 
33
40
  # TikTok API Base URLs
@@ -72,7 +79,19 @@ class PrivacyLevel(str, Enum):
72
79
 
73
80
  @dataclass
74
81
  class UploadProgress:
75
- """Progress information for a media upload."""
82
+ """Progress information for a media upload.
83
+
84
+ .. deprecated:: 0.2.0
85
+ Use :class:`marqetive.core.models.ProgressEvent` instead.
86
+ This class will be removed in a future version.
87
+
88
+ Attributes:
89
+ publish_id: TikTok publish ID (if available).
90
+ file_path: Path to file being uploaded.
91
+ bytes_uploaded: Number of bytes uploaded so far.
92
+ total_bytes: Total file size in bytes.
93
+ status: Current upload status.
94
+ """
76
95
 
77
96
  publish_id: str | None
78
97
  file_path: str
@@ -141,7 +160,7 @@ class TikTokMediaManager:
141
160
  access_token: str,
142
161
  open_id: str,
143
162
  *,
144
- progress_callback: Callable[[UploadProgress], None] | None = None,
163
+ progress_callback: ProgressCallback | None = None,
145
164
  timeout: float = DEFAULT_REQUEST_TIMEOUT,
146
165
  ) -> None:
147
166
  """Initialize the TikTok media manager.
@@ -149,7 +168,8 @@ class TikTokMediaManager:
149
168
  Args:
150
169
  access_token: OAuth access token with video.publish scope.
151
170
  open_id: User's open_id from OAuth flow.
152
- progress_callback: Optional callback for upload progress updates.
171
+ progress_callback: Optional callback for progress updates.
172
+ Accepts ProgressEvent and can be sync or async.
153
173
  timeout: Request timeout in seconds.
154
174
  """
155
175
  self.access_token = access_token
@@ -176,6 +196,44 @@ class TikTokMediaManager:
176
196
  await self._client.aclose()
177
197
  await self._upload_client.aclose()
178
198
 
199
+ async def _emit_progress(
200
+ self,
201
+ status: ProgressStatus,
202
+ progress: int,
203
+ total: int,
204
+ message: str | None = None,
205
+ *,
206
+ entity_id: str | None = None,
207
+ file_path: str | None = None,
208
+ bytes_uploaded: int | None = None,
209
+ total_bytes: int | None = None,
210
+ ) -> None:
211
+ """Emit a progress update if a callback is registered.
212
+
213
+ Supports both sync and async callbacks.
214
+ """
215
+ if self.progress_callback is None:
216
+ return
217
+
218
+ event = ProgressEvent(
219
+ operation="upload_media",
220
+ platform="tiktok",
221
+ status=status,
222
+ progress=progress,
223
+ total=total,
224
+ message=message,
225
+ entity_id=entity_id,
226
+ file_path=file_path,
227
+ bytes_uploaded=bytes_uploaded,
228
+ total_bytes=total_bytes,
229
+ )
230
+
231
+ result = self.progress_callback(event)
232
+
233
+ # If callback returned a coroutine, await it
234
+ if inspect.iscoroutine(result):
235
+ await result
236
+
179
237
  async def query_creator_info(self) -> CreatorInfo:
180
238
  """Query creator info before posting.
181
239
 
@@ -368,16 +426,16 @@ class TikTokMediaManager:
368
426
  chunk_size = self._normalize_chunk_size(chunk_size, file_size)
369
427
  mime_type = detect_mime_type(file_path)
370
428
 
371
- if self.progress_callback:
372
- self.progress_callback(
373
- UploadProgress(
374
- publish_id=publish_id,
375
- file_path=file_path,
376
- bytes_uploaded=0,
377
- total_bytes=file_size,
378
- status="uploading",
379
- )
380
- )
429
+ await self._emit_progress(
430
+ status=ProgressStatus.UPLOADING,
431
+ progress=0,
432
+ total=100,
433
+ message="Starting video upload",
434
+ entity_id=publish_id,
435
+ file_path=file_path,
436
+ bytes_uploaded=0,
437
+ total_bytes=file_size,
438
+ )
381
439
 
382
440
  bytes_uploaded = 0
383
441
 
@@ -419,16 +477,16 @@ class TikTokMediaManager:
419
477
  bytes_uploaded += len(chunk_data)
420
478
  chunk_index += 1
421
479
 
422
- if self.progress_callback:
423
- self.progress_callback(
424
- UploadProgress(
425
- publish_id=publish_id,
426
- file_path=file_path,
427
- bytes_uploaded=bytes_uploaded,
428
- total_bytes=file_size,
429
- status="uploading",
430
- )
431
- )
480
+ await self._emit_progress(
481
+ status=ProgressStatus.UPLOADING,
482
+ progress=int((bytes_uploaded / file_size) * 100),
483
+ total=100,
484
+ message=f"Uploading chunk {chunk_index}",
485
+ entity_id=publish_id,
486
+ file_path=file_path,
487
+ bytes_uploaded=bytes_uploaded,
488
+ total_bytes=file_size,
489
+ )
432
490
 
433
491
  logger.info(
434
492
  f"TikTok video upload complete: {bytes_uploaded} bytes in {chunk_index} chunks"
@@ -490,16 +548,17 @@ class TikTokMediaManager:
490
548
  Raises:
491
549
  MediaUploadError: If publishing fails or times out.
492
550
  """
493
- if self.progress_callback and file_path:
551
+ if file_path:
494
552
  file_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
495
- self.progress_callback(
496
- UploadProgress(
497
- publish_id=publish_id,
498
- file_path=file_path,
499
- bytes_uploaded=file_size,
500
- total_bytes=file_size,
501
- status="processing",
502
- )
553
+ await self._emit_progress(
554
+ status=ProgressStatus.PROCESSING,
555
+ progress=0,
556
+ total=100,
557
+ message="Processing video on TikTok servers",
558
+ entity_id=publish_id,
559
+ file_path=file_path,
560
+ bytes_uploaded=file_size,
561
+ total_bytes=file_size,
503
562
  )
504
563
 
505
564
  elapsed = 0.0
@@ -508,31 +567,33 @@ class TikTokMediaManager:
508
567
 
509
568
  if result.status == PublishStatus.PUBLISH_COMPLETE:
510
569
  logger.info(f"TikTok video published: video_id={result.video_id}")
511
- if self.progress_callback and file_path:
570
+ if file_path:
512
571
  file_size = (
513
572
  os.path.getsize(file_path) if os.path.exists(file_path) else 0
514
573
  )
515
- self.progress_callback(
516
- UploadProgress(
517
- publish_id=publish_id,
518
- file_path=file_path,
519
- bytes_uploaded=file_size,
520
- total_bytes=file_size,
521
- status="completed",
522
- )
574
+ await self._emit_progress(
575
+ status=ProgressStatus.COMPLETED,
576
+ progress=100,
577
+ total=100,
578
+ message="Video published successfully",
579
+ entity_id=publish_id,
580
+ file_path=file_path,
581
+ bytes_uploaded=file_size,
582
+ total_bytes=file_size,
523
583
  )
524
584
  return result
525
585
 
526
586
  if result.status == PublishStatus.FAILED:
527
- if self.progress_callback and file_path:
528
- self.progress_callback(
529
- UploadProgress(
530
- publish_id=publish_id,
531
- file_path=file_path,
532
- bytes_uploaded=0,
533
- total_bytes=0,
534
- status="failed",
535
- )
587
+ if file_path:
588
+ await self._emit_progress(
589
+ status=ProgressStatus.FAILED,
590
+ progress=0,
591
+ total=100,
592
+ message="Video publishing failed",
593
+ entity_id=publish_id,
594
+ file_path=file_path,
595
+ bytes_uploaded=0,
596
+ total_bytes=0,
536
597
  )
537
598
  raise MediaUploadError(
538
599
  f"TikTok video publishing failed: publish_id={publish_id}",