marqetive-lib 0.1.7__py3-none-any.whl → 0.1.8__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.
- marqetive/core/client.py +15 -0
- marqetive/platforms/instagram/client.py +29 -8
- marqetive/platforms/tiktok/client.py +5 -4
- marqetive/platforms/tiktok/media.py +81 -51
- marqetive/platforms/tiktok/models.py +17 -20
- marqetive/platforms/twitter/media.py +6 -5
- marqetive/utils/media.py +86 -0
- marqetive/utils/oauth.py +31 -4
- {marqetive_lib-0.1.7.dist-info → marqetive_lib-0.1.8.dist-info}/METADATA +1 -1
- {marqetive_lib-0.1.7.dist-info → marqetive_lib-0.1.8.dist-info}/RECORD +11 -11
- {marqetive_lib-0.1.7.dist-info → marqetive_lib-0.1.8.dist-info}/WHEEL +0 -0
marqetive/core/client.py
CHANGED
|
@@ -5,6 +5,11 @@ from typing import Any
|
|
|
5
5
|
import httpx
|
|
6
6
|
from pydantic import BaseModel
|
|
7
7
|
|
|
8
|
+
# Default connection pool limits for optimal performance
|
|
9
|
+
DEFAULT_MAX_CONNECTIONS = 100
|
|
10
|
+
DEFAULT_MAX_KEEPALIVE_CONNECTIONS = 20
|
|
11
|
+
DEFAULT_KEEPALIVE_EXPIRY = 30.0
|
|
12
|
+
|
|
8
13
|
|
|
9
14
|
class APIResponse(BaseModel):
|
|
10
15
|
"""Response model for API calls."""
|
|
@@ -24,6 +29,8 @@ class APIClient:
|
|
|
24
29
|
base_url: The base URL for API requests
|
|
25
30
|
timeout: Request timeout in seconds (default: 30)
|
|
26
31
|
headers: Optional default headers for all requests
|
|
32
|
+
max_connections: Maximum total connections in pool (default: 100)
|
|
33
|
+
max_keepalive_connections: Maximum persistent connections (default: 20)
|
|
27
34
|
|
|
28
35
|
Example:
|
|
29
36
|
>>> client = APIClient(base_url="https://api.example.com")
|
|
@@ -36,12 +43,19 @@ class APIClient:
|
|
|
36
43
|
base_url: str,
|
|
37
44
|
timeout: float = 30.0,
|
|
38
45
|
headers: dict[str, str] | None = None,
|
|
46
|
+
max_connections: int = DEFAULT_MAX_CONNECTIONS,
|
|
47
|
+
max_keepalive_connections: int = DEFAULT_MAX_KEEPALIVE_CONNECTIONS,
|
|
39
48
|
) -> None:
|
|
40
49
|
"""Initialize the API client."""
|
|
41
50
|
self.base_url = base_url.rstrip("/")
|
|
42
51
|
self.timeout = timeout
|
|
43
52
|
self.default_headers = headers or {}
|
|
44
53
|
self._client: httpx.AsyncClient | None = None
|
|
54
|
+
self._limits = httpx.Limits(
|
|
55
|
+
max_connections=max_connections,
|
|
56
|
+
max_keepalive_connections=max_keepalive_connections,
|
|
57
|
+
keepalive_expiry=DEFAULT_KEEPALIVE_EXPIRY,
|
|
58
|
+
)
|
|
45
59
|
|
|
46
60
|
async def __aenter__(self) -> "APIClient":
|
|
47
61
|
"""Async context manager entry."""
|
|
@@ -49,6 +63,7 @@ class APIClient:
|
|
|
49
63
|
base_url=self.base_url,
|
|
50
64
|
timeout=self.timeout,
|
|
51
65
|
headers=self.default_headers,
|
|
66
|
+
limits=self._limits,
|
|
52
67
|
)
|
|
53
68
|
return self
|
|
54
69
|
|
|
@@ -35,6 +35,7 @@ from marqetive.platforms.instagram.media import (
|
|
|
35
35
|
InstagramMediaManager,
|
|
36
36
|
MediaItem,
|
|
37
37
|
)
|
|
38
|
+
from marqetive.utils.media import validate_media_url
|
|
38
39
|
|
|
39
40
|
|
|
40
41
|
class InstagramClient(SocialMediaPlatform):
|
|
@@ -245,7 +246,11 @@ class InstagramClient(SocialMediaPlatform):
|
|
|
245
246
|
}
|
|
246
247
|
|
|
247
248
|
if request.media_urls:
|
|
248
|
-
|
|
249
|
+
# Validate URL to prevent SSRF attacks
|
|
250
|
+
validated_url = validate_media_url(
|
|
251
|
+
request.media_urls[0], platform=self.platform_name
|
|
252
|
+
)
|
|
253
|
+
container_params["image_url"] = validated_url
|
|
249
254
|
|
|
250
255
|
if request.content:
|
|
251
256
|
container_params["caption"] = request.content
|
|
@@ -533,14 +538,17 @@ class InstagramClient(SocialMediaPlatform):
|
|
|
533
538
|
if not self.api_client:
|
|
534
539
|
raise RuntimeError("Client must be used as async context manager")
|
|
535
540
|
|
|
541
|
+
# Validate URL to prevent SSRF attacks
|
|
542
|
+
validated_url = validate_media_url(media_url, platform=self.platform_name)
|
|
543
|
+
|
|
536
544
|
params: dict[str, Any] = {
|
|
537
545
|
"access_token": self.credentials.access_token,
|
|
538
546
|
}
|
|
539
547
|
|
|
540
548
|
if media_type.lower() == "image":
|
|
541
|
-
params["image_url"] =
|
|
549
|
+
params["image_url"] = validated_url
|
|
542
550
|
elif media_type.lower() == "video":
|
|
543
|
-
params["video_url"] =
|
|
551
|
+
params["video_url"] = validated_url
|
|
544
552
|
params["media_type"] = "VIDEO"
|
|
545
553
|
else:
|
|
546
554
|
raise ValidationError(
|
|
@@ -612,13 +620,15 @@ class InstagramClient(SocialMediaPlatform):
|
|
|
612
620
|
if not self._media_manager:
|
|
613
621
|
raise RuntimeError("Client must be used as async context manager")
|
|
614
622
|
|
|
615
|
-
# Convert to MediaItem objects
|
|
623
|
+
# Convert to MediaItem objects with URL validation
|
|
616
624
|
media_items = []
|
|
617
625
|
for idx, url in enumerate(media_urls):
|
|
626
|
+
# Validate each URL to prevent SSRF attacks
|
|
627
|
+
validated_url = validate_media_url(url, platform=self.platform_name)
|
|
618
628
|
alt_text = None
|
|
619
629
|
if alt_texts and idx < len(alt_texts):
|
|
620
630
|
alt_text = alt_texts[idx]
|
|
621
|
-
media_items.append(MediaItem(url=
|
|
631
|
+
media_items.append(MediaItem(url=validated_url, type="image", alt_text=alt_text))
|
|
622
632
|
|
|
623
633
|
# Create containers
|
|
624
634
|
container_ids = await self._media_manager.create_feed_containers(
|
|
@@ -667,11 +677,19 @@ class InstagramClient(SocialMediaPlatform):
|
|
|
667
677
|
if not self._media_manager:
|
|
668
678
|
raise RuntimeError("Client must be used as async context manager")
|
|
669
679
|
|
|
680
|
+
# Validate URLs to prevent SSRF attacks
|
|
681
|
+
validated_video_url = validate_media_url(video_url, platform=self.platform_name)
|
|
682
|
+
validated_cover_url = None
|
|
683
|
+
if cover_url:
|
|
684
|
+
validated_cover_url = validate_media_url(
|
|
685
|
+
cover_url, platform=self.platform_name
|
|
686
|
+
)
|
|
687
|
+
|
|
670
688
|
# Create reel container
|
|
671
689
|
container_id = await self._media_manager.create_reel_container(
|
|
672
|
-
|
|
690
|
+
validated_video_url,
|
|
673
691
|
caption=caption,
|
|
674
|
-
cover_url=
|
|
692
|
+
cover_url=validated_cover_url,
|
|
675
693
|
share_to_feed=share_to_feed,
|
|
676
694
|
wait_for_processing=True,
|
|
677
695
|
)
|
|
@@ -710,9 +728,12 @@ class InstagramClient(SocialMediaPlatform):
|
|
|
710
728
|
if not self._media_manager:
|
|
711
729
|
raise RuntimeError("Client must be used as async context manager")
|
|
712
730
|
|
|
731
|
+
# Validate URL to prevent SSRF attacks
|
|
732
|
+
validated_url = validate_media_url(media_url, platform=self.platform_name)
|
|
733
|
+
|
|
713
734
|
# Create story container
|
|
714
735
|
container_id = await self._media_manager.create_story_container(
|
|
715
|
-
|
|
736
|
+
validated_url,
|
|
716
737
|
media_type,
|
|
717
738
|
wait_for_processing=(media_type == "video"),
|
|
718
739
|
)
|
|
@@ -33,9 +33,9 @@ from marqetive.platforms.tiktok.exceptions import TikTokErrorCode, map_tiktok_er
|
|
|
33
33
|
from marqetive.platforms.tiktok.media import (
|
|
34
34
|
CreatorInfo,
|
|
35
35
|
MediaUploadResult,
|
|
36
|
-
PrivacyLevel,
|
|
37
36
|
TikTokMediaManager,
|
|
38
37
|
)
|
|
38
|
+
from marqetive.platforms.tiktok.models import PrivacyLevel
|
|
39
39
|
|
|
40
40
|
# TikTok API base URL
|
|
41
41
|
TIKTOK_API_BASE = "https://open.tiktokapis.com/v2"
|
|
@@ -107,6 +107,7 @@ class TikTokClient(SocialMediaPlatform):
|
|
|
107
107
|
open_id=self.credentials.additional_data["open_id"],
|
|
108
108
|
timeout=self.timeout,
|
|
109
109
|
)
|
|
110
|
+
await self._media_manager.__aenter__()
|
|
110
111
|
|
|
111
112
|
async def _cleanup_managers(self) -> None:
|
|
112
113
|
"""Cleanup media manager."""
|
|
@@ -213,7 +214,7 @@ class TikTokClient(SocialMediaPlatform):
|
|
|
213
214
|
await self.query_creator_info()
|
|
214
215
|
|
|
215
216
|
# 2. Determine privacy level
|
|
216
|
-
privacy_level = PrivacyLevel.
|
|
217
|
+
privacy_level = PrivacyLevel.PRIVATE # Default for unaudited apps
|
|
217
218
|
requested_privacy = request.additional_data.get("privacy_level")
|
|
218
219
|
if requested_privacy and self._creator_info:
|
|
219
220
|
# Check if requested privacy is available
|
|
@@ -293,7 +294,7 @@ class TikTokClient(SocialMediaPlatform):
|
|
|
293
294
|
|
|
294
295
|
async def update_post(
|
|
295
296
|
self,
|
|
296
|
-
post_id: str,
|
|
297
|
+
post_id: str, # noqa: ARG002
|
|
297
298
|
request: PostUpdateRequest, # noqa: ARG002
|
|
298
299
|
) -> Post:
|
|
299
300
|
"""Update a TikTok video.
|
|
@@ -340,7 +341,7 @@ class TikTokClient(SocialMediaPlatform):
|
|
|
340
341
|
|
|
341
342
|
async def create_comment(
|
|
342
343
|
self,
|
|
343
|
-
post_id: str,
|
|
344
|
+
post_id: str, # noqa: ARG002
|
|
344
345
|
content: str, # noqa: ARG002
|
|
345
346
|
) -> Comment:
|
|
346
347
|
"""Create a comment on a TikTok video.
|
|
@@ -11,6 +11,7 @@ Reference: https://developers.tiktok.com/doc/content-posting-api-reference-direc
|
|
|
11
11
|
|
|
12
12
|
import asyncio
|
|
13
13
|
import inspect
|
|
14
|
+
import json
|
|
14
15
|
import logging
|
|
15
16
|
import math
|
|
16
17
|
import os
|
|
@@ -19,14 +20,17 @@ from dataclasses import dataclass
|
|
|
19
20
|
from enum import Enum
|
|
20
21
|
from typing import Any, Literal
|
|
21
22
|
|
|
23
|
+
import aiofiles
|
|
22
24
|
import httpx
|
|
23
25
|
|
|
24
26
|
from marqetive.core.exceptions import (
|
|
25
27
|
InvalidFileTypeError,
|
|
26
28
|
MediaUploadError,
|
|
29
|
+
PlatformError,
|
|
27
30
|
)
|
|
28
31
|
from marqetive.core.models import ProgressEvent, ProgressStatus
|
|
29
32
|
from marqetive.platforms.tiktok.exceptions import TikTokErrorCode, map_tiktok_error
|
|
33
|
+
from marqetive.platforms.tiktok.models import PrivacyLevel
|
|
30
34
|
from marqetive.utils.file_handlers import download_file
|
|
31
35
|
from marqetive.utils.media import detect_mime_type, format_file_size
|
|
32
36
|
|
|
@@ -69,14 +73,6 @@ class PublishStatus(str, Enum):
|
|
|
69
73
|
FAILED = "FAILED"
|
|
70
74
|
|
|
71
75
|
|
|
72
|
-
class PrivacyLevel(str, Enum):
|
|
73
|
-
"""Privacy level options for TikTok posts."""
|
|
74
|
-
|
|
75
|
-
PUBLIC_TO_EVERYONE = "PUBLIC_TO_EVERYONE"
|
|
76
|
-
MUTUAL_FOLLOW_FRIENDS = "MUTUAL_FOLLOW_FRIENDS"
|
|
77
|
-
SELF_ONLY = "SELF_ONLY" # Private - only for unaudited apps or private posts
|
|
78
|
-
|
|
79
|
-
|
|
80
76
|
@dataclass
|
|
81
77
|
class UploadProgress:
|
|
82
78
|
"""Progress information for a media upload.
|
|
@@ -196,6 +192,27 @@ class TikTokMediaManager:
|
|
|
196
192
|
await self._client.aclose()
|
|
197
193
|
await self._upload_client.aclose()
|
|
198
194
|
|
|
195
|
+
def _parse_json_response(self, response: httpx.Response) -> dict[str, Any]:
|
|
196
|
+
"""Parse JSON from HTTP response with proper error handling.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
response: HTTP response object.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Parsed JSON data as dictionary.
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
PlatformError: If response is not valid JSON.
|
|
206
|
+
"""
|
|
207
|
+
try:
|
|
208
|
+
return response.json()
|
|
209
|
+
except json.JSONDecodeError as e:
|
|
210
|
+
raise PlatformError(
|
|
211
|
+
f"Invalid JSON response from TikTok API: {e}",
|
|
212
|
+
platform="tiktok",
|
|
213
|
+
status_code=response.status_code,
|
|
214
|
+
) from e
|
|
215
|
+
|
|
199
216
|
async def _emit_progress(
|
|
200
217
|
self,
|
|
201
218
|
status: ProgressStatus,
|
|
@@ -249,7 +266,7 @@ class TikTokMediaManager:
|
|
|
249
266
|
url = f"{TIKTOK_API_BASE}/post/publish/creator_info/query/"
|
|
250
267
|
|
|
251
268
|
response = await self._client.post(url)
|
|
252
|
-
data =
|
|
269
|
+
data = self._parse_json_response(response)
|
|
253
270
|
|
|
254
271
|
self._check_response_error(response.status_code, data)
|
|
255
272
|
|
|
@@ -272,7 +289,7 @@ class TikTokMediaManager:
|
|
|
272
289
|
file_path: str,
|
|
273
290
|
*,
|
|
274
291
|
title: str = "",
|
|
275
|
-
privacy_level: PrivacyLevel = PrivacyLevel.
|
|
292
|
+
privacy_level: PrivacyLevel = PrivacyLevel.PRIVATE,
|
|
276
293
|
disable_duet: bool = False,
|
|
277
294
|
disable_comment: bool = False,
|
|
278
295
|
disable_stitch: bool = False,
|
|
@@ -321,7 +338,7 @@ class TikTokMediaManager:
|
|
|
321
338
|
}
|
|
322
339
|
|
|
323
340
|
response = await self._client.post(url, json=payload)
|
|
324
|
-
data =
|
|
341
|
+
data = self._parse_json_response(response)
|
|
325
342
|
|
|
326
343
|
self._check_response_error(response.status_code, data)
|
|
327
344
|
|
|
@@ -343,7 +360,7 @@ class TikTokMediaManager:
|
|
|
343
360
|
video_url: str,
|
|
344
361
|
*,
|
|
345
362
|
title: str = "",
|
|
346
|
-
privacy_level: PrivacyLevel = PrivacyLevel.
|
|
363
|
+
privacy_level: PrivacyLevel = PrivacyLevel.PRIVATE,
|
|
347
364
|
disable_duet: bool = False,
|
|
348
365
|
disable_comment: bool = False,
|
|
349
366
|
disable_stitch: bool = False,
|
|
@@ -386,7 +403,7 @@ class TikTokMediaManager:
|
|
|
386
403
|
}
|
|
387
404
|
|
|
388
405
|
response = await self._client.post(url, json=payload)
|
|
389
|
-
data =
|
|
406
|
+
data = self._parse_json_response(response)
|
|
390
407
|
|
|
391
408
|
self._check_response_error(response.status_code, data)
|
|
392
409
|
|
|
@@ -439,10 +456,10 @@ class TikTokMediaManager:
|
|
|
439
456
|
|
|
440
457
|
bytes_uploaded = 0
|
|
441
458
|
|
|
442
|
-
with open(file_path, "rb") as f:
|
|
459
|
+
async with aiofiles.open(file_path, "rb") as f:
|
|
443
460
|
chunk_index = 0
|
|
444
461
|
while True:
|
|
445
|
-
chunk_data = f.read(chunk_size)
|
|
462
|
+
chunk_data = await f.read(chunk_size)
|
|
446
463
|
if not chunk_data:
|
|
447
464
|
break
|
|
448
465
|
|
|
@@ -507,7 +524,7 @@ class TikTokMediaManager:
|
|
|
507
524
|
url = f"{TIKTOK_API_BASE}/post/publish/status/fetch/"
|
|
508
525
|
|
|
509
526
|
response = await self._client.post(url, json={"publish_id": publish_id})
|
|
510
|
-
data =
|
|
527
|
+
data = self._parse_json_response(response)
|
|
511
528
|
|
|
512
529
|
self._check_response_error(response.status_code, data)
|
|
513
530
|
|
|
@@ -614,7 +631,7 @@ class TikTokMediaManager:
|
|
|
614
631
|
file_path: str,
|
|
615
632
|
*,
|
|
616
633
|
title: str = "",
|
|
617
|
-
privacy_level: PrivacyLevel = PrivacyLevel.
|
|
634
|
+
privacy_level: PrivacyLevel = PrivacyLevel.PRIVATE,
|
|
618
635
|
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
|
619
636
|
wait_for_publish: bool = True,
|
|
620
637
|
) -> MediaUploadResult:
|
|
@@ -642,43 +659,56 @@ class TikTokMediaManager:
|
|
|
642
659
|
InvalidFileTypeError: If the file is not a supported video format.
|
|
643
660
|
MediaUploadError: If upload or processing fails.
|
|
644
661
|
"""
|
|
645
|
-
#
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
file_path
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
662
|
+
# Track if we downloaded a temp file that needs cleanup
|
|
663
|
+
temp_file_path: str | None = None
|
|
664
|
+
|
|
665
|
+
try:
|
|
666
|
+
# Handle URL downloads
|
|
667
|
+
if file_path.startswith(("http://", "https://")):
|
|
668
|
+
logger.info(f"Downloading media from URL: {file_path}")
|
|
669
|
+
file_path = await download_file(file_path)
|
|
670
|
+
temp_file_path = file_path # Mark for cleanup
|
|
671
|
+
|
|
672
|
+
if not os.path.exists(file_path):
|
|
673
|
+
raise FileNotFoundError(f"Media file not found: {file_path}")
|
|
674
|
+
|
|
675
|
+
# Validate file
|
|
676
|
+
mime_type = detect_mime_type(file_path)
|
|
677
|
+
file_size = os.path.getsize(file_path)
|
|
678
|
+
self._validate_media(mime_type, file_size)
|
|
679
|
+
|
|
680
|
+
# Initialize upload
|
|
681
|
+
init_result = await self.init_video_upload(
|
|
682
|
+
file_path,
|
|
683
|
+
title=title,
|
|
684
|
+
privacy_level=privacy_level,
|
|
685
|
+
chunk_size=chunk_size,
|
|
686
|
+
)
|
|
665
687
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
688
|
+
# Upload chunks
|
|
689
|
+
await self.upload_video_chunks(
|
|
690
|
+
init_result.upload_url,
|
|
691
|
+
file_path,
|
|
692
|
+
init_result.publish_id,
|
|
693
|
+
chunk_size=chunk_size,
|
|
694
|
+
)
|
|
673
695
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
696
|
+
# Wait for publish
|
|
697
|
+
if wait_for_publish:
|
|
698
|
+
return await self.wait_for_publish(init_result.publish_id, file_path)
|
|
677
699
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
700
|
+
return MediaUploadResult(
|
|
701
|
+
publish_id=init_result.publish_id,
|
|
702
|
+
status=PublishStatus.PROCESSING_UPLOAD,
|
|
703
|
+
)
|
|
704
|
+
finally:
|
|
705
|
+
# Clean up downloaded temp file
|
|
706
|
+
if temp_file_path and os.path.exists(temp_file_path):
|
|
707
|
+
try:
|
|
708
|
+
os.remove(temp_file_path)
|
|
709
|
+
logger.debug(f"Cleaned up temp file: {temp_file_path}")
|
|
710
|
+
except OSError as e:
|
|
711
|
+
logger.warning(f"Failed to clean up temp file {temp_file_path}: {e}")
|
|
682
712
|
|
|
683
713
|
def _normalize_chunk_size(self, chunk_size: int, file_size: int) -> int:
|
|
684
714
|
"""Normalize chunk size to TikTok's requirements.
|
|
@@ -24,7 +24,10 @@ class PrivacyLevel(StrEnum):
|
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
class TikTokPostRequest(BaseModel):
|
|
27
|
-
"""TikTok-specific post creation request.
|
|
27
|
+
"""TikTok-specific post creation request with full API options.
|
|
28
|
+
|
|
29
|
+
This model provides a type-safe way to configure TikTok-specific post settings.
|
|
30
|
+
It can be converted to the universal PostCreateRequest for use with TikTokClient.
|
|
28
31
|
|
|
29
32
|
TikTok only supports video posts. Videos must be between 3 seconds
|
|
30
33
|
and 10 minutes, and under 4GB in size.
|
|
@@ -43,30 +46,24 @@ class TikTokPostRequest(BaseModel):
|
|
|
43
46
|
schedule_time: Unix timestamp to schedule post (10 mins to 10 days ahead)
|
|
44
47
|
|
|
45
48
|
Example:
|
|
46
|
-
>>>
|
|
47
|
-
>>>
|
|
49
|
+
>>> from marqetive.core.models import PostCreateRequest
|
|
50
|
+
>>>
|
|
51
|
+
>>> # Create TikTok-specific request
|
|
52
|
+
>>> tiktok_request = TikTokPostRequest(
|
|
48
53
|
... title="Check out this dance!",
|
|
49
54
|
... video_url="https://example.com/dance.mp4",
|
|
50
|
-
... privacy_level=PrivacyLevel.PUBLIC
|
|
51
|
-
... )
|
|
52
|
-
|
|
53
|
-
>>> # Private video with features disabled
|
|
54
|
-
>>> request = TikTokPostRequest(
|
|
55
|
-
... title="Personal video",
|
|
56
|
-
... video_url="https://example.com/video.mp4",
|
|
57
|
-
... privacy_level=PrivacyLevel.PRIVATE,
|
|
58
|
-
... disable_comment=True,
|
|
55
|
+
... privacy_level=PrivacyLevel.PUBLIC,
|
|
59
56
|
... disable_duet=True,
|
|
60
|
-
... disable_stitch=True
|
|
61
57
|
... )
|
|
62
|
-
|
|
63
|
-
>>> #
|
|
64
|
-
>>> request =
|
|
65
|
-
... title
|
|
66
|
-
...
|
|
67
|
-
...
|
|
68
|
-
... brand_content_toggle=True
|
|
58
|
+
>>>
|
|
59
|
+
>>> # Convert to universal PostCreateRequest for client
|
|
60
|
+
>>> request = PostCreateRequest(
|
|
61
|
+
... content=tiktok_request.title,
|
|
62
|
+
... media_urls=[tiktok_request.video_url] if tiktok_request.video_url else [],
|
|
63
|
+
... additional_data=tiktok_request.model_dump(exclude_none=True),
|
|
69
64
|
... )
|
|
65
|
+
>>> async with TikTokClient(credentials) as client:
|
|
66
|
+
... post = await client.create_post(request)
|
|
70
67
|
"""
|
|
71
68
|
|
|
72
69
|
title: str | None = None
|
|
@@ -17,6 +17,7 @@ from dataclasses import dataclass
|
|
|
17
17
|
from enum import Enum
|
|
18
18
|
from typing import Any, Literal
|
|
19
19
|
|
|
20
|
+
import aiofiles
|
|
20
21
|
import httpx
|
|
21
22
|
|
|
22
23
|
from marqetive.core.exceptions import (
|
|
@@ -337,9 +338,9 @@ class TwitterMediaManager:
|
|
|
337
338
|
|
|
338
339
|
@retry_async(config=STANDARD_BACKOFF)
|
|
339
340
|
async def _do_upload() -> MediaUploadResult:
|
|
340
|
-
# Read file
|
|
341
|
-
with open(file_path, "rb") as f:
|
|
342
|
-
file_data = f.read()
|
|
341
|
+
# Read file asynchronously
|
|
342
|
+
async with aiofiles.open(file_path, "rb") as f:
|
|
343
|
+
file_data = await f.read()
|
|
343
344
|
|
|
344
345
|
# Prepare form data
|
|
345
346
|
files = {"media": (os.path.basename(file_path), file_data)}
|
|
@@ -465,9 +466,9 @@ class TwitterMediaManager:
|
|
|
465
466
|
bytes_uploaded = 0
|
|
466
467
|
segment_index = 0
|
|
467
468
|
|
|
468
|
-
with open(file_path, "rb") as f:
|
|
469
|
+
async with aiofiles.open(file_path, "rb") as f:
|
|
469
470
|
while True:
|
|
470
|
-
chunk_data = f.read(chunk_size)
|
|
471
|
+
chunk_data = await f.read(chunk_size)
|
|
471
472
|
if not chunk_data:
|
|
472
473
|
break
|
|
473
474
|
|
marqetive/utils/media.py
CHANGED
|
@@ -5,14 +5,19 @@ This module provides utilities for working with media files including:
|
|
|
5
5
|
- File validation (size, type, format)
|
|
6
6
|
- File chunking for large uploads
|
|
7
7
|
- File hashing for integrity verification
|
|
8
|
+
- URL validation for media URLs
|
|
8
9
|
"""
|
|
9
10
|
|
|
10
11
|
import hashlib
|
|
12
|
+
import ipaddress
|
|
11
13
|
import mimetypes
|
|
12
14
|
import os
|
|
13
15
|
from collections.abc import AsyncGenerator
|
|
14
16
|
from pathlib import Path
|
|
15
17
|
from typing import Literal
|
|
18
|
+
from urllib.parse import urlparse
|
|
19
|
+
|
|
20
|
+
from marqetive.core.exceptions import ValidationError
|
|
16
21
|
|
|
17
22
|
# Initialize mimetypes database
|
|
18
23
|
mimetypes.init()
|
|
@@ -397,3 +402,84 @@ def get_chunk_count(file_path: str, chunk_size: int) -> int:
|
|
|
397
402
|
|
|
398
403
|
file_size = os.path.getsize(file_path)
|
|
399
404
|
return (file_size + chunk_size - 1) // chunk_size # Ceiling division
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def validate_media_url(
|
|
408
|
+
url: str,
|
|
409
|
+
*,
|
|
410
|
+
allowed_schemes: list[str] | None = None,
|
|
411
|
+
block_private_ips: bool = True,
|
|
412
|
+
platform: str = "unknown",
|
|
413
|
+
) -> str:
|
|
414
|
+
"""Validate a media URL for security.
|
|
415
|
+
|
|
416
|
+
Validates that the URL uses an allowed scheme (default: http/https) and
|
|
417
|
+
optionally blocks private/internal IP addresses to prevent SSRF attacks.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
url: The URL to validate.
|
|
421
|
+
allowed_schemes: List of allowed URL schemes (default: ['http', 'https']).
|
|
422
|
+
block_private_ips: If True, block private/loopback IP addresses.
|
|
423
|
+
platform: Platform name for error messages.
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
The validated URL (unchanged if valid).
|
|
427
|
+
|
|
428
|
+
Raises:
|
|
429
|
+
ValidationError: If the URL is invalid or uses a disallowed scheme/IP.
|
|
430
|
+
|
|
431
|
+
Example:
|
|
432
|
+
>>> url = validate_media_url("https://example.com/image.jpg")
|
|
433
|
+
>>> url = validate_media_url("https://cdn.example.com/video.mp4", platform="instagram")
|
|
434
|
+
"""
|
|
435
|
+
if allowed_schemes is None:
|
|
436
|
+
allowed_schemes = ["http", "https"]
|
|
437
|
+
|
|
438
|
+
try:
|
|
439
|
+
parsed = urlparse(url)
|
|
440
|
+
except Exception as e:
|
|
441
|
+
raise ValidationError(
|
|
442
|
+
f"Invalid URL format: {e}",
|
|
443
|
+
platform=platform,
|
|
444
|
+
field="media_url",
|
|
445
|
+
) from e
|
|
446
|
+
|
|
447
|
+
# Validate scheme
|
|
448
|
+
if not parsed.scheme:
|
|
449
|
+
raise ValidationError(
|
|
450
|
+
"URL must include a scheme (e.g., https://)",
|
|
451
|
+
platform=platform,
|
|
452
|
+
field="media_url",
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
if parsed.scheme.lower() not in allowed_schemes:
|
|
456
|
+
raise ValidationError(
|
|
457
|
+
f"URL scheme '{parsed.scheme}' not allowed. "
|
|
458
|
+
f"Allowed schemes: {', '.join(allowed_schemes)}",
|
|
459
|
+
platform=platform,
|
|
460
|
+
field="media_url",
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
# Validate hostname exists
|
|
464
|
+
if not parsed.hostname:
|
|
465
|
+
raise ValidationError(
|
|
466
|
+
"URL must include a hostname",
|
|
467
|
+
platform=platform,
|
|
468
|
+
field="media_url",
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
# Block private/internal IPs if requested
|
|
472
|
+
if block_private_ips:
|
|
473
|
+
try:
|
|
474
|
+
ip = ipaddress.ip_address(parsed.hostname)
|
|
475
|
+
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
|
|
476
|
+
raise ValidationError(
|
|
477
|
+
"Private, loopback, and reserved IP addresses are not allowed",
|
|
478
|
+
platform=platform,
|
|
479
|
+
field="media_url",
|
|
480
|
+
)
|
|
481
|
+
except ValueError:
|
|
482
|
+
# Not an IP address, it's a hostname - that's fine
|
|
483
|
+
pass
|
|
484
|
+
|
|
485
|
+
return url
|
marqetive/utils/oauth.py
CHANGED
|
@@ -5,6 +5,7 @@ different social media platforms.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import logging
|
|
8
|
+
import re
|
|
8
9
|
from datetime import datetime, timedelta
|
|
9
10
|
from typing import Any
|
|
10
11
|
|
|
@@ -15,6 +16,32 @@ from marqetive.core.models import AuthCredentials
|
|
|
15
16
|
|
|
16
17
|
logger = logging.getLogger(__name__)
|
|
17
18
|
|
|
19
|
+
# Patterns for sensitive data that should be redacted from logs
|
|
20
|
+
_SENSITIVE_PATTERNS = [
|
|
21
|
+
re.compile(r'"access_token"\s*:\s*"[^"]*"', re.IGNORECASE),
|
|
22
|
+
re.compile(r'"refresh_token"\s*:\s*"[^"]*"', re.IGNORECASE),
|
|
23
|
+
re.compile(r'"client_secret"\s*:\s*"[^"]*"', re.IGNORECASE),
|
|
24
|
+
re.compile(r'"api_key"\s*:\s*"[^"]*"', re.IGNORECASE),
|
|
25
|
+
re.compile(r'"token"\s*:\s*"[^"]*"', re.IGNORECASE),
|
|
26
|
+
re.compile(r"access_token=[^&\s]+", re.IGNORECASE),
|
|
27
|
+
re.compile(r"refresh_token=[^&\s]+", re.IGNORECASE),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _sanitize_response_text(text: str) -> str:
|
|
32
|
+
"""Sanitize response text to remove sensitive credentials.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
text: Raw response text that may contain credentials.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Sanitized text with sensitive values redacted.
|
|
39
|
+
"""
|
|
40
|
+
result = text
|
|
41
|
+
for pattern in _SENSITIVE_PATTERNS:
|
|
42
|
+
result = pattern.sub("[REDACTED]", result)
|
|
43
|
+
return result
|
|
44
|
+
|
|
18
45
|
|
|
19
46
|
async def refresh_oauth2_token(
|
|
20
47
|
refresh_token: str,
|
|
@@ -73,7 +100,7 @@ async def refresh_oauth2_token(
|
|
|
73
100
|
except httpx.HTTPStatusError as e:
|
|
74
101
|
logger.error(f"HTTP error refreshing token: {e.response.status_code}")
|
|
75
102
|
raise PlatformAuthError(
|
|
76
|
-
f"Failed to refresh token: {e.response.text}",
|
|
103
|
+
f"Failed to refresh token: {_sanitize_response_text(e.response.text)}",
|
|
77
104
|
platform="oauth2",
|
|
78
105
|
status_code=e.response.status_code,
|
|
79
106
|
) from e
|
|
@@ -252,7 +279,7 @@ async def refresh_instagram_token(
|
|
|
252
279
|
except httpx.HTTPStatusError as e:
|
|
253
280
|
logger.error(f"HTTP error refreshing Instagram token: {e.response.status_code}")
|
|
254
281
|
raise PlatformAuthError(
|
|
255
|
-
f"Failed to refresh Instagram token: {e.response.text}",
|
|
282
|
+
f"Failed to refresh Instagram token: {_sanitize_response_text(e.response.text)}",
|
|
256
283
|
platform="instagram",
|
|
257
284
|
status_code=e.response.status_code,
|
|
258
285
|
) from e
|
|
@@ -312,7 +339,7 @@ async def refresh_tiktok_token(
|
|
|
312
339
|
except httpx.HTTPStatusError as e:
|
|
313
340
|
logger.error(f"HTTP error refreshing tiktok token: {e.response.status_code}")
|
|
314
341
|
raise PlatformAuthError(
|
|
315
|
-
f"Failed to refresh token: {e.response.text}",
|
|
342
|
+
f"Failed to refresh token: {_sanitize_response_text(e.response.text)}",
|
|
316
343
|
platform="tiktok",
|
|
317
344
|
status_code=e.response.status_code,
|
|
318
345
|
) from e
|
|
@@ -387,7 +414,7 @@ async def fetch_tiktok_token(
|
|
|
387
414
|
except httpx.HTTPStatusError as e:
|
|
388
415
|
logger.error(f"HTTP error fetching tiktok token: {e.response.status_code}")
|
|
389
416
|
raise PlatformAuthError(
|
|
390
|
-
f"Failed to fetch token: {e.response.text}",
|
|
417
|
+
f"Failed to fetch token: {_sanitize_response_text(e.response.text)}",
|
|
391
418
|
platform="tiktok",
|
|
392
419
|
status_code=e.response.status_code,
|
|
393
420
|
) from e
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
marqetive/__init__.py,sha256=pW77CUnzOQ0X1pb-GTcRgrrvsSaJBdVhGZLnvCD_4q4,3032
|
|
2
2
|
marqetive/core/__init__.py,sha256=0_0vzxJ619YIJkz1yzSvhnGDJRkrErs_QSg2q3Bloss,1172
|
|
3
3
|
marqetive/core/base.py,sha256=J5iYXpa2C371zVLNMx3eFnC0fdLWqTrjbVcqJQdGyrU,16147
|
|
4
|
-
marqetive/core/client.py,sha256=
|
|
4
|
+
marqetive/core/client.py,sha256=eCtvL100dkxYQHC_TJzHbs3dGjgsa_me9VTHo-CUN2M,3900
|
|
5
5
|
marqetive/core/exceptions.py,sha256=Xyj0bzNiZm5VTErmzXgVW8T6IQnOpF92-HJiKPKjIio,7076
|
|
6
6
|
marqetive/core/models.py,sha256=L2gA4FhW0feAXQFsz2ce1ttd0vScMRhatoTclhDGCU0,14727
|
|
7
7
|
marqetive/factory.py,sha256=irZ5oN8a__kXZH70UN2uI7TzqTXu66d4QZ1FoxSoiK8,14092
|
|
8
8
|
marqetive/platforms/__init__.py,sha256=RBxlQSGyELsulSnwf5uaE1ohxFc7jC61OO9CrKaZp48,1312
|
|
9
9
|
marqetive/platforms/instagram/__init__.py,sha256=c1Gs0ozG6D7Z-Uz_UQ7S3joL0qUTT9eUZPWcePyESk8,229
|
|
10
|
-
marqetive/platforms/instagram/client.py,sha256=
|
|
10
|
+
marqetive/platforms/instagram/client.py,sha256=vOx5HpgrxanBIFFC9VgmCNguH-njRGChnyp6Rr1r1Xc,26191
|
|
11
11
|
marqetive/platforms/instagram/exceptions.py,sha256=TcD_pX4eSx_k4yW8DgfA6SGPiAz3VW7cMqM8DmiXIhg,8978
|
|
12
12
|
marqetive/platforms/instagram/media.py,sha256=0ZbUbpwJ025_hccL9X8qced_-LJGoL_-NdS84Op97VE,23228
|
|
13
13
|
marqetive/platforms/instagram/models.py,sha256=20v3m1037y3b_WlsKF8zAOgV23nFu63tfmmUN1CefOI,2769
|
|
@@ -17,23 +17,23 @@ marqetive/platforms/linkedin/exceptions.py,sha256=i5fARUkZik46bS3htZBwUInVzetsZx
|
|
|
17
17
|
marqetive/platforms/linkedin/media.py,sha256=iWXUfqDYGsrTqeM6CGZ7a8xjpbdJ5qESolQL8Fv2PIg,20341
|
|
18
18
|
marqetive/platforms/linkedin/models.py,sha256=n7DqwVxYSbGYBmeEJ1woCZ6XhUIHcLx8Gpm8uCBACzI,12620
|
|
19
19
|
marqetive/platforms/tiktok/__init__.py,sha256=BqjkXTZDyBlcY3lvREy13yP9h3RcDga8E6Rl6f5KPp8,238
|
|
20
|
-
marqetive/platforms/tiktok/client.py,sha256=
|
|
20
|
+
marqetive/platforms/tiktok/client.py,sha256=wCCCFQ4mGiZrrGYjRUCUngz6_eqf4G6BUxYxw8szpig,17178
|
|
21
21
|
marqetive/platforms/tiktok/exceptions.py,sha256=vxwyAKujMGZJh0LetG1QsLF95QfUs_kR6ujsWSHGqL0,10124
|
|
22
|
-
marqetive/platforms/tiktok/media.py,sha256=
|
|
23
|
-
marqetive/platforms/tiktok/models.py,sha256=
|
|
22
|
+
marqetive/platforms/tiktok/media.py,sha256=bPQmyVL8egb4teXQDzxQvWLwg2EnBh4Ik6lz20ReFvg,27008
|
|
23
|
+
marqetive/platforms/tiktok/models.py,sha256=WWdjuFqhTIR8SnHkz-8UaNc5Mm2PrGomwQ3W7pJcQFg,2962
|
|
24
24
|
marqetive/platforms/twitter/__init__.py,sha256=dvcgVT-v-JOtjSz-OUvxGrn_43OI6w_ep42Wx_nHTSM,217
|
|
25
25
|
marqetive/platforms/twitter/client.py,sha256=08jV2hQVmGOpnG3C05u7bCqL7KapWn7bSsG0wbN_t5M,23270
|
|
26
26
|
marqetive/platforms/twitter/exceptions.py,sha256=eZ-dJKOXH_-bAMg29zWKbEqMFud29piEJ5IWfC9wFts,8926
|
|
27
|
-
marqetive/platforms/twitter/media.py,sha256=
|
|
27
|
+
marqetive/platforms/twitter/media.py,sha256=9j7JQpdlOhkMfQkDH0dLpp6HmlYkeB6SvNosRx5Oab8,27152
|
|
28
28
|
marqetive/platforms/twitter/models.py,sha256=yPQlx40SlNmz7YGasXUqdx7rEDEgrQ64aYovlPKo6oc,2126
|
|
29
29
|
marqetive/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
30
30
|
marqetive/utils/__init__.py,sha256=bSrNajbxYBSKQayrPviLz8JeGjplnyK8y_NGDtgb7yQ,977
|
|
31
31
|
marqetive/utils/file_handlers.py,sha256=4TP5kmWofNTSZmlS683CM1UYP83WvRd_NubMbqtXv-g,12568
|
|
32
32
|
marqetive/utils/helpers.py,sha256=8-ljhL47SremKcQO2GF8DIHOPODEv1rSioVNuSPCbec,2634
|
|
33
|
-
marqetive/utils/media.py,sha256=
|
|
34
|
-
marqetive/utils/oauth.py,sha256=
|
|
33
|
+
marqetive/utils/media.py,sha256=O1rISYdaP3CuuPxso7kqvxWXNfe2jjioNkaBc4cpwkY,14668
|
|
34
|
+
marqetive/utils/oauth.py,sha256=1SkYCE6dcyPvcDqbjRFSSBcKTwLJy8u3jAANPdftVmo,13108
|
|
35
35
|
marqetive/utils/retry.py,sha256=lAniJLMNWp9XsHrvU0XBNifpNEjfde4MGfd5hlFTPfA,7636
|
|
36
36
|
marqetive/utils/token_validator.py,sha256=dNvDeHs2Du5UyMMH2ZOW6ydR7OwOEKA4c9e-rG0f9-0,6698
|
|
37
|
-
marqetive_lib-0.1.
|
|
38
|
-
marqetive_lib-0.1.
|
|
39
|
-
marqetive_lib-0.1.
|
|
37
|
+
marqetive_lib-0.1.8.dist-info/METADATA,sha256=q99TsfInwfKVr_zoFnkvM3Wvb5cGZ7Nj6qQikle5EG4,7875
|
|
38
|
+
marqetive_lib-0.1.8.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
39
|
+
marqetive_lib-0.1.8.dist-info/RECORD,,
|
|
File without changes
|