marqetive-lib 0.1.1__py3-none-any.whl → 0.1.3__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 (44) hide show
  1. marqetive/__init__.py +13 -13
  2. marqetive/core/__init__.py +1 -1
  3. marqetive/core/account_factory.py +2 -2
  4. marqetive/core/base_manager.py +4 -4
  5. marqetive/core/client.py +1 -1
  6. marqetive/core/registry.py +3 -3
  7. marqetive/platforms/__init__.py +6 -6
  8. marqetive/platforms/base.py +3 -3
  9. marqetive/platforms/exceptions.py +2 -1
  10. marqetive/platforms/instagram/__init__.py +3 -3
  11. marqetive/platforms/instagram/client.py +4 -4
  12. marqetive/platforms/instagram/exceptions.py +1 -1
  13. marqetive/platforms/instagram/factory.py +5 -5
  14. marqetive/platforms/instagram/manager.py +4 -4
  15. marqetive/platforms/instagram/media.py +2 -2
  16. marqetive/platforms/linkedin/__init__.py +3 -3
  17. marqetive/platforms/linkedin/client.py +4 -4
  18. marqetive/platforms/linkedin/exceptions.py +1 -1
  19. marqetive/platforms/linkedin/factory.py +5 -5
  20. marqetive/platforms/linkedin/manager.py +4 -4
  21. marqetive/platforms/linkedin/media.py +4 -4
  22. marqetive/platforms/models.py +2 -0
  23. marqetive/platforms/tiktok/__init__.py +7 -0
  24. marqetive/platforms/tiktok/client.py +492 -0
  25. marqetive/platforms/tiktok/exceptions.py +284 -0
  26. marqetive/platforms/tiktok/factory.py +188 -0
  27. marqetive/platforms/tiktok/manager.py +115 -0
  28. marqetive/platforms/tiktok/media.py +693 -0
  29. marqetive/platforms/twitter/__init__.py +3 -3
  30. marqetive/platforms/twitter/client.py +8 -54
  31. marqetive/platforms/twitter/exceptions.py +1 -1
  32. marqetive/platforms/twitter/factory.py +5 -6
  33. marqetive/platforms/twitter/manager.py +4 -4
  34. marqetive/platforms/twitter/media.py +4 -4
  35. marqetive/registry_init.py +10 -8
  36. marqetive/utils/__init__.py +3 -3
  37. marqetive/utils/file_handlers.py +1 -1
  38. marqetive/utils/oauth.py +137 -2
  39. marqetive/utils/token_validator.py +1 -1
  40. {marqetive_lib-0.1.1.dist-info → marqetive_lib-0.1.3.dist-info}/METADATA +1 -2
  41. marqetive_lib-0.1.3.dist-info/RECORD +47 -0
  42. marqetive/platforms/twitter/threads.py +0 -442
  43. marqetive_lib-0.1.1.dist-info/RECORD +0 -43
  44. {marqetive_lib-0.1.1.dist-info → marqetive_lib-0.1.3.dist-info}/WHEEL +0 -0
@@ -0,0 +1,492 @@
1
+ """TikTok API client implementation.
2
+
3
+ This module provides a concrete implementation of the SocialMediaPlatform
4
+ ABC for TikTok, using the TikTok Content Posting API v2.
5
+
6
+ API Documentation: https://developers.tiktok.com/doc/content-posting-api-get-started
7
+ """
8
+
9
+ from datetime import datetime
10
+ from typing import Any
11
+
12
+ from pydantic import HttpUrl
13
+
14
+ from marqetive.platforms.base import SocialMediaPlatform
15
+ from marqetive.platforms.exceptions import (
16
+ PlatformAuthError,
17
+ PlatformError,
18
+ PostNotFoundError,
19
+ ValidationError,
20
+ )
21
+ from marqetive.platforms.models import (
22
+ AuthCredentials,
23
+ Comment,
24
+ CommentStatus,
25
+ MediaAttachment,
26
+ MediaType,
27
+ Post,
28
+ PostCreateRequest,
29
+ PostStatus,
30
+ PostUpdateRequest,
31
+ )
32
+ from marqetive.platforms.tiktok.exceptions import TikTokErrorCode, map_tiktok_error
33
+ from marqetive.platforms.tiktok.media import (
34
+ CreatorInfo,
35
+ MediaUploadResult,
36
+ PrivacyLevel,
37
+ TikTokMediaManager,
38
+ )
39
+
40
+ # TikTok API base URL
41
+ TIKTOK_API_BASE = "https://open.tiktokapis.com/v2"
42
+
43
+ # Video query fields
44
+ VIDEO_QUERY_FIELDS = [
45
+ "id",
46
+ "title",
47
+ "video_description",
48
+ "create_time",
49
+ "cover_image_url",
50
+ "share_url",
51
+ "duration",
52
+ "height",
53
+ "width",
54
+ "like_count",
55
+ "comment_count",
56
+ "share_count",
57
+ "view_count",
58
+ ]
59
+
60
+
61
+ class TikTokClient(SocialMediaPlatform):
62
+ """TikTok API client.
63
+
64
+ This client implements the SocialMediaPlatform interface for TikTok,
65
+ focusing on video uploads and management using the Content Posting API v2.
66
+
67
+ Note: Some operations (delete, update, comments) are not supported by TikTok API.
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ credentials: AuthCredentials,
73
+ timeout: float = 300.0,
74
+ ) -> None:
75
+ """Initialize TikTok client.
76
+
77
+ Args:
78
+ credentials: OAuth credentials with access_token and open_id in additional_data.
79
+ timeout: Request timeout in seconds (default 300s for video processing).
80
+ """
81
+ super().__init__(
82
+ platform_name="tiktok",
83
+ credentials=credentials,
84
+ base_url=TIKTOK_API_BASE,
85
+ timeout=timeout,
86
+ )
87
+ self._media_manager: TikTokMediaManager | None = None
88
+ self._creator_info: CreatorInfo | None = None
89
+
90
+ async def _setup_managers(self) -> None:
91
+ """Setup media manager."""
92
+ if not self.credentials.access_token:
93
+ raise PlatformAuthError("Access token is required", "tiktok")
94
+
95
+ if (
96
+ not self.credentials.additional_data
97
+ or "open_id" not in self.credentials.additional_data
98
+ ):
99
+ raise PlatformAuthError(
100
+ "open_id is required in additional_data", "tiktok"
101
+ )
102
+
103
+ self._media_manager = TikTokMediaManager(
104
+ access_token=self.credentials.access_token,
105
+ open_id=self.credentials.additional_data["open_id"],
106
+ timeout=self.timeout,
107
+ )
108
+
109
+ async def _cleanup_managers(self) -> None:
110
+ """Cleanup media manager."""
111
+ if self._media_manager:
112
+ await self._media_manager.__aexit__(None, None, None)
113
+ self._media_manager = None
114
+
115
+ async def __aenter__(self) -> "TikTokClient":
116
+ await super().__aenter__()
117
+ await self._setup_managers()
118
+ return self
119
+
120
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
121
+ await self._cleanup_managers()
122
+ await super().__aexit__(exc_type, exc_val, exc_tb)
123
+
124
+ async def authenticate(self) -> AuthCredentials:
125
+ """Perform TikTok authentication."""
126
+ if await self.is_authenticated():
127
+ return self.credentials
128
+ raise PlatformAuthError(
129
+ "Invalid or expired credentials. Please re-authenticate via TikTok OAuth.",
130
+ platform=self.platform_name,
131
+ )
132
+
133
+ async def refresh_token(self) -> AuthCredentials:
134
+ """Refresh TikTok access token.
135
+
136
+ Note: Token refresh should be handled by the AccountFactory.
137
+ This method just returns the current credentials.
138
+ """
139
+ return self.credentials
140
+
141
+ async def is_authenticated(self) -> bool:
142
+ """Check if TikTok credentials are valid.
143
+
144
+ Validates credentials by fetching user info from the API.
145
+ """
146
+ if not self.api_client:
147
+ return False
148
+ try:
149
+ # Verify credentials by fetching authenticated user info
150
+ # TikTok requires 'fields' as query parameter
151
+ response = await self.api_client.get(
152
+ "user/info/",
153
+ params={"fields": "open_id,union_id,avatar_url,display_name"},
154
+ )
155
+ data = response.data
156
+
157
+ # Check for error in response
158
+ error_code = data.get("error", {}).get("code", "")
159
+ if error_code and error_code != TikTokErrorCode.OK:
160
+ return False
161
+
162
+ return data.get("data", {}).get("user") is not None
163
+ except PlatformError:
164
+ return False
165
+
166
+ async def query_creator_info(self) -> CreatorInfo:
167
+ """Query creator info before posting.
168
+
169
+ This must be called before creating a post to get available
170
+ privacy levels and posting limits.
171
+
172
+ Returns:
173
+ CreatorInfo with available options for this creator.
174
+ """
175
+ if not self._media_manager:
176
+ raise RuntimeError("Client must be used as async context manager")
177
+
178
+ self._creator_info = await self._media_manager.query_creator_info()
179
+ return self._creator_info
180
+
181
+ async def create_post(self, request: PostCreateRequest) -> Post:
182
+ """Create and publish a TikTok video.
183
+
184
+ TikTok requires a video to be uploaded. The upload flow is:
185
+ 1. Query creator info (required)
186
+ 2. Initialize upload (get publish_id and upload_url)
187
+ 3. Upload video chunks
188
+ 4. Poll status until PUBLISH_COMPLETE
189
+
190
+ Args:
191
+ request: The post creation request, must contain a video URL.
192
+
193
+ Returns:
194
+ The created Post object.
195
+
196
+ Raises:
197
+ ValidationError: If the request is invalid (e.g., no media).
198
+ MediaUploadError: If the video upload fails.
199
+ """
200
+ if not self._media_manager or not self.api_client:
201
+ raise RuntimeError("Client must be used as async context manager")
202
+
203
+ if not request.media_urls:
204
+ raise ValidationError(
205
+ "A video URL is required to create a TikTok post.",
206
+ platform=self.platform_name,
207
+ )
208
+
209
+ # 1. Query creator info (required before posting)
210
+ if not self._creator_info:
211
+ await self.query_creator_info()
212
+
213
+ # 2. Determine privacy level
214
+ privacy_level = PrivacyLevel.SELF_ONLY # Default for unaudited apps
215
+ requested_privacy = request.additional_data.get("privacy_level")
216
+ if requested_privacy and self._creator_info:
217
+ # Check if requested privacy is available
218
+ available_privacies = self._creator_info.privacy_level_options or []
219
+ if requested_privacy in available_privacies:
220
+ privacy_level = PrivacyLevel(requested_privacy)
221
+
222
+ # 3. Upload video and wait for publish
223
+ video_url = request.media_urls[0]
224
+ upload_result = await self._media_manager.upload_media(
225
+ video_url,
226
+ title=request.content or "",
227
+ privacy_level=privacy_level,
228
+ wait_for_publish=True,
229
+ )
230
+
231
+ if not upload_result.video_id:
232
+ raise PlatformError(
233
+ "Video upload succeeded but no video ID returned. "
234
+ "Video may still be processing.",
235
+ platform=self.platform_name,
236
+ )
237
+
238
+ # 4. Fetch the created post to return full Post object
239
+ return await self.get_post(upload_result.video_id)
240
+
241
+ async def get_post(self, post_id: str) -> Post:
242
+ """Retrieve a TikTok video by its ID.
243
+
244
+ Uses POST /video/query/ endpoint with filters.video_ids.
245
+
246
+ Args:
247
+ post_id: The video ID to retrieve.
248
+
249
+ Returns:
250
+ Post object with video details.
251
+
252
+ Raises:
253
+ PostNotFoundError: If the video doesn't exist.
254
+ """
255
+ if not self.api_client:
256
+ raise RuntimeError("Client must be used as async context manager")
257
+
258
+ try:
259
+ # TikTok uses POST for video query with body
260
+ response = await self.api_client.post(
261
+ "video/query/",
262
+ data={
263
+ "filters": {"video_ids": [post_id]},
264
+ "fields": VIDEO_QUERY_FIELDS,
265
+ },
266
+ )
267
+ data = response.data
268
+
269
+ # Check for API errors
270
+ error_code = data.get("error", {}).get("code", "")
271
+ if error_code and error_code != TikTokErrorCode.OK:
272
+ raise map_tiktok_error(
273
+ status_code=response.status_code,
274
+ error_code=error_code,
275
+ error_message=data.get("error", {}).get("message"),
276
+ response_data=data,
277
+ )
278
+
279
+ videos = data.get("data", {}).get("videos", [])
280
+ if not videos:
281
+ raise PostNotFoundError(post_id, self.platform_name)
282
+
283
+ return self._parse_video_post(videos[0])
284
+
285
+ except PostNotFoundError:
286
+ raise
287
+ except PlatformError as e:
288
+ raise PostNotFoundError(
289
+ post_id, self.platform_name, status_code=e.status_code
290
+ ) from e
291
+
292
+ async def update_post(
293
+ self, post_id: str, request: PostUpdateRequest # noqa: ARG002
294
+ ) -> Post:
295
+ """Update a TikTok video.
296
+
297
+ TikTok API does not support updating videos after publishing.
298
+ """
299
+ raise PlatformError(
300
+ "TikTok API does not support updating videos after publishing.",
301
+ self.platform_name,
302
+ )
303
+
304
+ async def delete_post(self, post_id: str) -> bool: # noqa: ARG002
305
+ """Delete a TikTok video.
306
+
307
+ TikTok API does not support deleting videos via API.
308
+ Users must delete videos through the TikTok app.
309
+ """
310
+ raise PlatformError(
311
+ "TikTok API does not support deleting videos. "
312
+ "Please delete the video through the TikTok app.",
313
+ self.platform_name,
314
+ )
315
+
316
+ async def get_comments(
317
+ self,
318
+ post_id: str, # noqa: ARG002
319
+ limit: int = 20, # noqa: ARG002
320
+ offset: int = 0, # noqa: ARG002
321
+ ) -> list[Comment]:
322
+ """Retrieve comments for a TikTok video.
323
+
324
+ Note: The standard TikTok API v2 does NOT provide access to video comments.
325
+ Only comment_count is available in video metadata.
326
+ The Comments API is only available via the Research API for academic use.
327
+
328
+ Raises:
329
+ PlatformError: Always, as this feature is not available.
330
+ """
331
+ raise PlatformError(
332
+ "TikTok's standard API does not provide access to video comments. "
333
+ "Comments are only available via the Research API for academic researchers.",
334
+ self.platform_name,
335
+ )
336
+
337
+ async def create_comment(
338
+ self, post_id: str, content: str # noqa: ARG002
339
+ ) -> Comment:
340
+ """Create a comment on a TikTok video.
341
+
342
+ TikTok API does not support creating comments programmatically.
343
+ """
344
+ raise PlatformError(
345
+ "TikTok API does not support creating comments.",
346
+ self.platform_name,
347
+ )
348
+
349
+ async def delete_comment(self, comment_id: str) -> bool: # noqa: ARG002
350
+ """Delete a comment on a TikTok video.
351
+
352
+ TikTok API does not support deleting comments programmatically.
353
+ """
354
+ raise PlatformError(
355
+ "TikTok API does not support deleting comments.",
356
+ self.platform_name,
357
+ )
358
+
359
+ async def upload_media(
360
+ self, media_url: str, media_type: str, alt_text: str | None = None # noqa: ARG002
361
+ ) -> MediaAttachment:
362
+ """Upload a video to TikTok.
363
+
364
+ This initiates the upload flow but doesn't publish.
365
+ Use create_post() for full upload + publish flow.
366
+
367
+ Args:
368
+ media_url: URL or path to the video file.
369
+ media_type: Must be "video" for TikTok.
370
+ alt_text: Not used (TikTok doesn't support alt text).
371
+
372
+ Returns:
373
+ MediaAttachment with publish_id as media_id.
374
+
375
+ Raises:
376
+ ValidationError: If media_type is not "video".
377
+ """
378
+ if not self._media_manager:
379
+ raise RuntimeError("Client not initialized. Use as async context manager.")
380
+
381
+ if media_type != "video":
382
+ raise ValidationError(
383
+ "Only video media type is supported for TikTok.",
384
+ platform=self.platform_name,
385
+ )
386
+
387
+ # Upload without waiting for publish
388
+ result = await self._media_manager.upload_media(
389
+ media_url,
390
+ wait_for_publish=False,
391
+ )
392
+
393
+ # Use original URL if it's a valid HTTP URL, otherwise use a placeholder
394
+ url = (
395
+ HttpUrl(media_url)
396
+ if media_url.startswith("http")
397
+ else HttpUrl("https://www.tiktok.com/")
398
+ )
399
+
400
+ return MediaAttachment(
401
+ media_id=result.publish_id,
402
+ media_type=MediaType.VIDEO,
403
+ url=url,
404
+ )
405
+
406
+ async def check_publish_status(self, publish_id: str) -> MediaUploadResult:
407
+ """Check the publish status of an upload.
408
+
409
+ Args:
410
+ publish_id: The publish_id from upload_media.
411
+
412
+ Returns:
413
+ MediaUploadResult with current status and video_id if complete.
414
+ """
415
+ if not self._media_manager:
416
+ raise RuntimeError("Client not initialized. Use as async context manager.")
417
+
418
+ return await self._media_manager.check_publish_status(publish_id)
419
+
420
+ async def wait_for_publish(self, publish_id: str) -> MediaUploadResult:
421
+ """Wait for a video to finish publishing.
422
+
423
+ Args:
424
+ publish_id: The publish_id from upload_media.
425
+
426
+ Returns:
427
+ MediaUploadResult with video_id once published.
428
+ """
429
+ if not self._media_manager:
430
+ raise RuntimeError("Client not initialized. Use as async context manager.")
431
+
432
+ return await self._media_manager.wait_for_publish(publish_id)
433
+
434
+ def _parse_video_post(self, video_data: dict[str, Any]) -> Post:
435
+ """Parse a TikTok API video object into a Post model.
436
+
437
+ Args:
438
+ video_data: Raw video data from TikTok API.
439
+
440
+ Returns:
441
+ Post model with video details.
442
+ """
443
+ # TikTok uses 'id' field for video ID
444
+ video_id = video_data.get("id", video_data.get("video_id", ""))
445
+
446
+ # Handle share_url - use placeholder if empty
447
+ share_url = video_data.get("share_url", "")
448
+ media_url = (
449
+ HttpUrl(share_url) if share_url else HttpUrl("https://www.tiktok.com/")
450
+ )
451
+
452
+ return Post(
453
+ post_id=video_id,
454
+ platform=self.platform_name,
455
+ content=video_data.get("title", video_data.get("video_description", "")),
456
+ media=[
457
+ MediaAttachment(
458
+ media_id=video_id,
459
+ media_type=MediaType.VIDEO,
460
+ url=media_url,
461
+ width=video_data.get("width"),
462
+ height=video_data.get("height"),
463
+ )
464
+ ],
465
+ status=PostStatus.PUBLISHED,
466
+ created_at=datetime.fromtimestamp(video_data.get("create_time", 0)),
467
+ author_id=video_data.get("open_id", ""),
468
+ likes_count=video_data.get("like_count", 0),
469
+ comments_count=video_data.get("comment_count", 0),
470
+ shares_count=video_data.get("share_count", 0),
471
+ views_count=video_data.get("view_count", 0),
472
+ raw_data=video_data,
473
+ )
474
+
475
+ def _parse_comment(self, comment_data: dict[str, Any], post_id: str) -> Comment:
476
+ """Parse a TikTok API comment object into a Comment model.
477
+
478
+ Note: This method exists for interface completeness but comments
479
+ are not accessible via standard TikTok API.
480
+ """
481
+ return Comment(
482
+ comment_id=comment_data.get("id", ""),
483
+ post_id=post_id,
484
+ platform=self.platform_name,
485
+ content=comment_data.get("text", ""),
486
+ author_id=comment_data.get("open_id", ""),
487
+ created_at=datetime.fromtimestamp(comment_data.get("create_time", 0)),
488
+ likes_count=comment_data.get("like_count", 0),
489
+ replies_count=comment_data.get("reply_count", 0),
490
+ status=CommentStatus.VISIBLE,
491
+ raw_data=comment_data,
492
+ )