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