marqetive-lib 0.1.2__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 (41) 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/registry.py +3 -3
  6. marqetive/platforms/__init__.py +6 -6
  7. marqetive/platforms/base.py +3 -3
  8. marqetive/platforms/instagram/__init__.py +3 -3
  9. marqetive/platforms/instagram/client.py +4 -4
  10. marqetive/platforms/instagram/exceptions.py +1 -1
  11. marqetive/platforms/instagram/factory.py +5 -5
  12. marqetive/platforms/instagram/manager.py +4 -4
  13. marqetive/platforms/instagram/media.py +2 -2
  14. marqetive/platforms/linkedin/__init__.py +3 -3
  15. marqetive/platforms/linkedin/client.py +4 -4
  16. marqetive/platforms/linkedin/exceptions.py +1 -1
  17. marqetive/platforms/linkedin/factory.py +5 -5
  18. marqetive/platforms/linkedin/manager.py +4 -4
  19. marqetive/platforms/linkedin/media.py +4 -4
  20. marqetive/platforms/tiktok/__init__.py +3 -3
  21. marqetive/platforms/tiktok/client.py +319 -104
  22. marqetive/platforms/tiktok/exceptions.py +170 -66
  23. marqetive/platforms/tiktok/factory.py +5 -5
  24. marqetive/platforms/tiktok/manager.py +5 -5
  25. marqetive/platforms/tiktok/media.py +547 -159
  26. marqetive/platforms/twitter/__init__.py +3 -3
  27. marqetive/platforms/twitter/client.py +7 -53
  28. marqetive/platforms/twitter/exceptions.py +1 -1
  29. marqetive/platforms/twitter/factory.py +5 -6
  30. marqetive/platforms/twitter/manager.py +4 -4
  31. marqetive/platforms/twitter/media.py +4 -4
  32. marqetive/registry_init.py +10 -8
  33. marqetive/utils/__init__.py +3 -3
  34. marqetive/utils/file_handlers.py +1 -1
  35. marqetive/utils/oauth.py +2 -2
  36. marqetive/utils/token_validator.py +1 -1
  37. {marqetive_lib-0.1.2.dist-info → marqetive_lib-0.1.3.dist-info}/METADATA +1 -1
  38. marqetive_lib-0.1.3.dist-info/RECORD +47 -0
  39. marqetive/platforms/twitter/threads.py +0 -442
  40. marqetive_lib-0.1.2.dist-info/RECORD +0 -48
  41. {marqetive_lib-0.1.2.dist-info → marqetive_lib-0.1.3.dist-info}/WHEEL +0 -0
@@ -1,9 +1,9 @@
1
1
  """TikTok API client implementation.
2
2
 
3
3
  This module provides a concrete implementation of the SocialMediaPlatform
4
- ABC for TikTok, using the TikTok for Business API (hypothetical).
4
+ ABC for TikTok, using the TikTok Content Posting API v2.
5
5
 
6
- API Documentation: https://developers.tiktok.com/doc/overview
6
+ API Documentation: https://developers.tiktok.com/doc/content-posting-api-get-started
7
7
  """
8
8
 
9
9
  from datetime import datetime
@@ -11,14 +11,14 @@ from typing import Any
11
11
 
12
12
  from pydantic import HttpUrl
13
13
 
14
- from src.marqetive.platforms.base import SocialMediaPlatform
15
- from src.marqetive.platforms.exceptions import (
14
+ from marqetive.platforms.base import SocialMediaPlatform
15
+ from marqetive.platforms.exceptions import (
16
16
  PlatformAuthError,
17
17
  PlatformError,
18
18
  PostNotFoundError,
19
19
  ValidationError,
20
20
  )
21
- from src.marqetive.platforms.models import (
21
+ from marqetive.platforms.models import (
22
22
  AuthCredentials,
23
23
  Comment,
24
24
  CommentStatus,
@@ -29,14 +29,42 @@ from src.marqetive.platforms.models import (
29
29
  PostStatus,
30
30
  PostUpdateRequest,
31
31
  )
32
- from src.marqetive.platforms.tiktok.media import TikTokMediaManager
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
+ ]
33
59
 
34
60
 
35
61
  class TikTokClient(SocialMediaPlatform):
36
62
  """TikTok API client.
37
63
 
38
64
  This client implements the SocialMediaPlatform interface for TikTok,
39
- focusing on video uploads and management.
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.
40
68
  """
41
69
 
42
70
  def __init__(
@@ -44,20 +72,33 @@ class TikTokClient(SocialMediaPlatform):
44
72
  credentials: AuthCredentials,
45
73
  timeout: float = 300.0,
46
74
  ) -> None:
47
- """Initialize TikTok client."""
48
- base_url = "https://open.tiktokapis.com/v2/"
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
+ """
49
81
  super().__init__(
50
82
  platform_name="tiktok",
51
83
  credentials=credentials,
52
- base_url=base_url,
84
+ base_url=TIKTOK_API_BASE,
53
85
  timeout=timeout,
54
86
  )
55
87
  self._media_manager: TikTokMediaManager | None = None
88
+ self._creator_info: CreatorInfo | None = None
56
89
 
57
90
  async def _setup_managers(self) -> None:
58
91
  """Setup media manager."""
59
- if not self.credentials.access_token or not self.credentials.additional_data:
60
- raise PlatformAuthError("Access token and open_id are required", "tiktok")
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
+ )
61
102
 
62
103
  self._media_manager = TikTokMediaManager(
63
104
  access_token=self.credentials.access_token,
@@ -90,29 +131,62 @@ class TikTokClient(SocialMediaPlatform):
90
131
  )
91
132
 
92
133
  async def refresh_token(self) -> AuthCredentials:
93
- """Refresh TikTok access token."""
94
- # The refresh logic should be handled by the AccountFactory
95
- # For now, we assume it's done and we just return the creds
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
+ """
96
139
  return self.credentials
97
140
 
98
141
  async def is_authenticated(self) -> bool:
99
- """Check if TikTok credentials are valid."""
100
- if not self.api_client or not self.credentials.additional_data:
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:
101
147
  return False
102
148
  try:
103
149
  # Verify credentials by fetching authenticated user info
104
- params = {
105
- "fields": "open_id,union_id,avatar_url,display_name",
106
- "open_id": self.credentials.additional_data["open_id"],
107
- }
108
- response = await self.api_client.get("user/info/", params=params)
109
- return response.data.get("data", {}).get("user") is not None
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
110
163
  except PlatformError:
111
164
  return False
112
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
+
113
181
  async def create_post(self, request: PostCreateRequest) -> Post:
114
182
  """Create and publish a TikTok video.
115
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
+
116
190
  Args:
117
191
  request: The post creation request, must contain a video URL.
118
192
 
@@ -132,128 +206,265 @@ class TikTokClient(SocialMediaPlatform):
132
206
  platform=self.platform_name,
133
207
  )
134
208
 
135
- # 1. Upload the video
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
136
223
  video_url = request.media_urls[0]
137
- upload_result = await self._media_manager.upload_media(video_url)
138
-
139
- if not upload_result.media_id:
140
- raise PlatformError("Media upload succeeded but did not yield a media ID.")
141
-
142
- # 2. Create the post with the uploaded video
143
- payload = {
144
- "post_info": {
145
- "title": request.content or "",
146
- "description": request.additional_data.get("description", ""),
147
- "privacy_level": request.additional_data.get(
148
- "privacy_level", "PUBLIC_TO_EVERYONE"
149
- ),
150
- },
151
- "source_info": {
152
- "source": "FILE_UPLOAD",
153
- "video_id": upload_result.media_id,
154
- },
155
- }
156
- response = await self.api_client.post("video/publish/", data=payload)
157
- post_id = response.data.get("data", {}).get("publish_id")
158
-
159
- if not post_id:
160
- raise PlatformError("Post creation succeeded but no publish_id returned.")
161
-
162
- # 3. Fetch the created post to return a full Post object
163
- return await self.get_post(post_id)
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)
164
240
 
165
241
  async def get_post(self, post_id: str) -> Post:
166
- """Retrieve a TikTok video by its ID."""
167
- if not self.api_client or not self.credentials.additional_data:
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:
168
256
  raise RuntimeError("Client must be used as async context manager")
257
+
169
258
  try:
170
- params = {
171
- "open_id": self.credentials.additional_data["open_id"],
172
- "video_ids": [post_id],
173
- }
174
- response = await self.api_client.post("video/query/", data=params)
175
- video_data = response.data.get("data", {}).get("videos", [])
176
- if not video_data:
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:
177
281
  raise PostNotFoundError(post_id, self.platform_name)
178
- return self._parse_video_post(video_data[0])
282
+
283
+ return self._parse_video_post(videos[0])
284
+
285
+ except PostNotFoundError:
286
+ raise
179
287
  except PlatformError as e:
180
288
  raise PostNotFoundError(
181
289
  post_id, self.platform_name, status_code=e.status_code
182
290
  ) from e
183
291
 
184
- async def update_post(self, post_id: str, request: PostUpdateRequest) -> Post:
185
- """Update a TikTok video. This is not supported by TikTok API."""
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
+ """
186
299
  raise PlatformError(
187
- "TikTok API does not support updating videos.", self.platform_name
300
+ "TikTok API does not support updating videos after publishing.",
301
+ self.platform_name,
188
302
  )
189
303
 
190
- async def delete_post(self, post_id: str) -> bool:
191
- """Delete a TikTok video. This is not supported by TikTok API."""
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
+ """
192
310
  raise PlatformError(
193
- "TikTok API does not support deleting videos.", self.platform_name
311
+ "TikTok API does not support deleting videos. "
312
+ "Please delete the video through the TikTok app.",
313
+ self.platform_name,
194
314
  )
195
315
 
196
316
  async def get_comments(
197
- self, post_id: str, limit: int = 20, offset: int = 0
317
+ self,
318
+ post_id: str, # noqa: ARG002
319
+ limit: int = 20, # noqa: ARG002
320
+ offset: int = 0, # noqa: ARG002
198
321
  ) -> list[Comment]:
199
- """Retrieve comments for a TikTok video."""
200
- if not self.api_client or not self.credentials.additional_data:
201
- raise RuntimeError("Client must be used as async context manager")
202
- # This is a hypothetical endpoint
203
- params = {
204
- "open_id": self.credentials.additional_data["open_id"],
205
- "video_id": post_id,
206
- "count": limit,
207
- "cursor": offset,
208
- }
209
- response = await self.api_client.get("video/comment/list/", params=params)
210
- comments_data = response.data.get("data", {}).get("comments", [])
211
- return [self._parse_comment(c, post_id) for c in comments_data]
212
-
213
- async def create_comment(self, post_id: str, content: str) -> Comment:
214
- """Create a comment on a TikTok video."""
215
- raise PlatformError("Commenting via API is not supported.", self.platform_name)
216
-
217
- async def delete_comment(self, comment_id: str) -> bool:
218
- """Delete a comment on a TikTok video."""
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
+ """
219
331
  raise PlatformError(
220
- "Deleting comments via API is not supported.", self.platform_name
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,
221
357
  )
222
358
 
223
359
  async def upload_media(
224
- self, media_url: str, media_type: str, alt_text: str | None = None
360
+ self, media_url: str, media_type: str, alt_text: str | None = None # noqa: ARG002
225
361
  ) -> MediaAttachment:
226
- """Upload a video to TikTok."""
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
+ """
227
378
  if not self._media_manager:
228
379
  raise RuntimeError("Client not initialized. Use as async context manager.")
380
+
229
381
  if media_type != "video":
230
- raise ValidationError("Only video media type is supported for TikTok.")
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
+ )
231
399
 
232
- result = await self._media_manager.upload_media(media_url)
233
400
  return MediaAttachment(
234
- media_id=result.media_id or result.upload_id,
401
+ media_id=result.publish_id,
235
402
  media_type=MediaType.VIDEO,
236
- url=HttpUrl(media_url),
403
+ url=url,
237
404
  )
238
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
+
239
434
  def _parse_video_post(self, video_data: dict[str, Any]) -> Post:
240
- """Parse a TikTok API video object into a Post model."""
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
+
241
452
  return Post(
242
- post_id=video_data["video_id"],
453
+ post_id=video_id,
243
454
  platform=self.platform_name,
244
- content=video_data.get("title", ""),
455
+ content=video_data.get("title", video_data.get("video_description", "")),
245
456
  media=[
246
457
  MediaAttachment(
247
- media_id=video_data["video_id"],
458
+ media_id=video_id,
248
459
  media_type=MediaType.VIDEO,
249
- url=HttpUrl(video_data.get("share_url", "")),
460
+ url=media_url,
250
461
  width=video_data.get("width"),
251
462
  height=video_data.get("height"),
252
463
  )
253
464
  ],
254
465
  status=PostStatus.PUBLISHED,
255
466
  created_at=datetime.fromtimestamp(video_data.get("create_time", 0)),
256
- author_id=str(video_data.get("open_id")),
467
+ author_id=video_data.get("open_id", ""),
257
468
  likes_count=video_data.get("like_count", 0),
258
469
  comments_count=video_data.get("comment_count", 0),
259
470
  shares_count=video_data.get("share_count", 0),
@@ -262,16 +473,20 @@ class TikTokClient(SocialMediaPlatform):
262
473
  )
263
474
 
264
475
  def _parse_comment(self, comment_data: dict[str, Any], post_id: str) -> Comment:
265
- """Parse a TikTok API comment object into a Comment model."""
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
+ """
266
481
  return Comment(
267
- comment_id=comment_data["comment_id"],
482
+ comment_id=comment_data.get("id", ""),
268
483
  post_id=post_id,
269
484
  platform=self.platform_name,
270
485
  content=comment_data.get("text", ""),
271
486
  author_id=comment_data.get("open_id", ""),
272
487
  created_at=datetime.fromtimestamp(comment_data.get("create_time", 0)),
273
488
  likes_count=comment_data.get("like_count", 0),
274
- replies_count=0,
489
+ replies_count=comment_data.get("reply_count", 0),
275
490
  status=CommentStatus.VISIBLE,
276
491
  raw_data=comment_data,
277
492
  )