marqetive-lib 0.1.6__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/__init__.py +13 -7
- marqetive/core/__init__.py +6 -4
- marqetive/core/base.py +92 -13
- marqetive/core/client.py +15 -0
- marqetive/core/models.py +111 -7
- marqetive/platforms/instagram/__init__.py +2 -1
- marqetive/platforms/instagram/client.py +29 -8
- marqetive/platforms/instagram/media.py +79 -13
- marqetive/platforms/instagram/models.py +74 -0
- marqetive/platforms/linkedin/__init__.py +51 -2
- marqetive/platforms/linkedin/client.py +978 -94
- marqetive/platforms/linkedin/media.py +156 -47
- marqetive/platforms/linkedin/models.py +413 -0
- marqetive/platforms/tiktok/__init__.py +2 -1
- marqetive/platforms/tiktok/client.py +5 -4
- marqetive/platforms/tiktok/media.py +193 -102
- marqetive/platforms/tiktok/models.py +79 -0
- marqetive/platforms/twitter/__init__.py +2 -1
- marqetive/platforms/twitter/client.py +86 -0
- marqetive/platforms/twitter/media.py +139 -70
- marqetive/platforms/twitter/models.py +58 -0
- marqetive/utils/media.py +86 -0
- marqetive/utils/oauth.py +31 -4
- {marqetive_lib-0.1.6.dist-info → marqetive_lib-0.1.8.dist-info}/METADATA +1 -9
- marqetive_lib-0.1.8.dist-info/RECORD +39 -0
- marqetive_lib-0.1.6.dist-info/RECORD +0 -35
- {marqetive_lib-0.1.6.dist-info → marqetive_lib-0.1.8.dist-info}/WHEEL +0 -0
marqetive/__init__.py
CHANGED
|
@@ -62,6 +62,8 @@ from marqetive.core.models import (
|
|
|
62
62
|
PostCreateRequest,
|
|
63
63
|
PostStatus,
|
|
64
64
|
PostUpdateRequest,
|
|
65
|
+
ProgressEvent,
|
|
66
|
+
ProgressStatus,
|
|
65
67
|
)
|
|
66
68
|
|
|
67
69
|
# Factory
|
|
@@ -85,17 +87,21 @@ __all__ = [
|
|
|
85
87
|
"BackoffConfig",
|
|
86
88
|
"STANDARD_BACKOFF",
|
|
87
89
|
"retry_async",
|
|
88
|
-
#
|
|
89
|
-
"AuthCredentials",
|
|
90
|
+
# Enums
|
|
90
91
|
"AccountStatus",
|
|
91
|
-
"
|
|
92
|
+
"CommentStatus",
|
|
93
|
+
"MediaType",
|
|
92
94
|
"PostStatus",
|
|
93
|
-
"
|
|
94
|
-
|
|
95
|
+
"ProgressStatus",
|
|
96
|
+
# Core Models
|
|
97
|
+
"AuthCredentials",
|
|
95
98
|
"Comment",
|
|
96
|
-
"CommentStatus",
|
|
97
99
|
"MediaAttachment",
|
|
98
|
-
"
|
|
100
|
+
"Post",
|
|
101
|
+
"ProgressEvent",
|
|
102
|
+
# Base Request Models
|
|
103
|
+
"PostCreateRequest",
|
|
104
|
+
"PostUpdateRequest",
|
|
99
105
|
# Exceptions
|
|
100
106
|
"PlatformError",
|
|
101
107
|
"PlatformAuthError",
|
marqetive/core/__init__.py
CHANGED
|
@@ -31,17 +31,19 @@ __all__ = [
|
|
|
31
31
|
# Base class
|
|
32
32
|
"SocialMediaPlatform",
|
|
33
33
|
"ProgressCallback",
|
|
34
|
-
#
|
|
34
|
+
# Enums
|
|
35
35
|
"AccountStatus",
|
|
36
|
+
"CommentStatus",
|
|
37
|
+
"MediaType",
|
|
38
|
+
"PostStatus",
|
|
39
|
+
# Core Models
|
|
36
40
|
"AuthCredentials",
|
|
37
41
|
"Comment",
|
|
38
|
-
"CommentStatus",
|
|
39
42
|
"MediaAttachment",
|
|
40
|
-
"MediaType",
|
|
41
43
|
"PlatformResponse",
|
|
42
44
|
"Post",
|
|
45
|
+
# Base Request Models
|
|
43
46
|
"PostCreateRequest",
|
|
44
|
-
"PostStatus",
|
|
45
47
|
"PostUpdateRequest",
|
|
46
48
|
# Exceptions
|
|
47
49
|
"InvalidFileTypeError",
|
marqetive/core/base.py
CHANGED
|
@@ -5,8 +5,9 @@ for implementing platform-specific clients (Instagram, Twitter, LinkedIn, etc.).
|
|
|
5
5
|
All concrete implementations must implement the abstract methods defined here.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
import inspect
|
|
8
9
|
from abc import ABC, abstractmethod
|
|
9
|
-
from collections.abc import Callable
|
|
10
|
+
from collections.abc import Awaitable, Callable
|
|
10
11
|
from datetime import datetime
|
|
11
12
|
from traceback import TracebackException
|
|
12
13
|
from typing import Any
|
|
@@ -24,11 +25,15 @@ from marqetive.core.models import (
|
|
|
24
25
|
Post,
|
|
25
26
|
PostCreateRequest,
|
|
26
27
|
PostUpdateRequest,
|
|
28
|
+
ProgressEvent,
|
|
29
|
+
ProgressStatus,
|
|
27
30
|
)
|
|
28
31
|
|
|
29
|
-
# Type
|
|
30
|
-
#
|
|
31
|
-
type
|
|
32
|
+
# Type aliases for progress callbacks
|
|
33
|
+
# Supports both sync and async callbacks using ProgressEvent
|
|
34
|
+
type SyncProgressCallback = Callable[[ProgressEvent], None]
|
|
35
|
+
type AsyncProgressCallback = Callable[[ProgressEvent], Awaitable[None]]
|
|
36
|
+
type ProgressCallback = SyncProgressCallback | AsyncProgressCallback
|
|
32
37
|
|
|
33
38
|
|
|
34
39
|
class SocialMediaPlatform(ABC):
|
|
@@ -162,28 +167,66 @@ class SocialMediaPlatform(ABC):
|
|
|
162
167
|
self._rate_limit_remaining = remaining
|
|
163
168
|
self._rate_limit_reset = reset_time
|
|
164
169
|
|
|
165
|
-
def _emit_progress(
|
|
170
|
+
async def _emit_progress(
|
|
166
171
|
self,
|
|
167
172
|
operation: str,
|
|
173
|
+
status: ProgressStatus,
|
|
168
174
|
progress: int,
|
|
169
175
|
total: int,
|
|
170
176
|
message: str | None = None,
|
|
177
|
+
*,
|
|
178
|
+
entity_id: str | None = None,
|
|
179
|
+
file_path: str | None = None,
|
|
180
|
+
bytes_uploaded: int | None = None,
|
|
181
|
+
total_bytes: int | None = None,
|
|
171
182
|
) -> None:
|
|
172
183
|
"""Emit a progress update if a callback is registered.
|
|
173
184
|
|
|
174
|
-
This method
|
|
185
|
+
This method supports both synchronous and asynchronous callbacks.
|
|
186
|
+
It is safe to call even if no callback is registered.
|
|
175
187
|
|
|
176
188
|
Args:
|
|
177
|
-
operation: Name of the operation (e.g., "upload_media", "create_post")
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
189
|
+
operation: Name of the operation (e.g., "upload_media", "create_post", "create_thread")
|
|
190
|
+
status: Current status of the operation
|
|
191
|
+
progress: Current progress value (0-100 for percentage, or bytes)
|
|
192
|
+
total: Total value for completion (100 for percentage, or total bytes)
|
|
193
|
+
message: Optional human-readable status message
|
|
194
|
+
entity_id: Optional platform-specific ID (media_id, post_id, etc.)
|
|
195
|
+
file_path: Optional file path for upload operations
|
|
196
|
+
bytes_uploaded: Optional bytes uploaded so far
|
|
197
|
+
total_bytes: Optional total bytes to upload
|
|
181
198
|
|
|
182
199
|
Example:
|
|
183
|
-
>>> self._emit_progress(
|
|
200
|
+
>>> await self._emit_progress(
|
|
201
|
+
... operation="upload_media",
|
|
202
|
+
... status=ProgressStatus.UPLOADING,
|
|
203
|
+
... progress=50,
|
|
204
|
+
... total=100,
|
|
205
|
+
... message="Uploading image 1 of 3",
|
|
206
|
+
... entity_id="media_123",
|
|
207
|
+
... )
|
|
184
208
|
"""
|
|
185
|
-
if self._progress_callback is
|
|
186
|
-
|
|
209
|
+
if self._progress_callback is None:
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
event = ProgressEvent(
|
|
213
|
+
operation=operation,
|
|
214
|
+
platform=self.platform_name,
|
|
215
|
+
status=status,
|
|
216
|
+
progress=progress,
|
|
217
|
+
total=total,
|
|
218
|
+
message=message,
|
|
219
|
+
entity_id=entity_id,
|
|
220
|
+
file_path=file_path,
|
|
221
|
+
bytes_uploaded=bytes_uploaded,
|
|
222
|
+
total_bytes=total_bytes,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
result = self._progress_callback(event)
|
|
226
|
+
|
|
227
|
+
# If callback returned a coroutine, await it
|
|
228
|
+
if inspect.iscoroutine(result):
|
|
229
|
+
await result
|
|
187
230
|
|
|
188
231
|
# ==================== Abstract Authentication Methods ====================
|
|
189
232
|
|
|
@@ -307,6 +350,42 @@ class SocialMediaPlatform(ABC):
|
|
|
307
350
|
"""
|
|
308
351
|
pass
|
|
309
352
|
|
|
353
|
+
async def create_thread(
|
|
354
|
+
self,
|
|
355
|
+
posts: list[PostCreateRequest],
|
|
356
|
+
) -> list[Post]:
|
|
357
|
+
"""Create a thread of connected posts.
|
|
358
|
+
|
|
359
|
+
Not all platforms support threads. The default implementation raises
|
|
360
|
+
NotImplementedError. Platforms that support threads (like Twitter)
|
|
361
|
+
should override this method.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
posts: List of post requests to create as a thread.
|
|
365
|
+
Each post can have its own content, media, etc.
|
|
366
|
+
First post is the head of the thread.
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
List of Post objects for each post in the thread.
|
|
370
|
+
|
|
371
|
+
Raises:
|
|
372
|
+
NotImplementedError: If platform doesn't support threads.
|
|
373
|
+
ValidationError: If posts list is empty.
|
|
374
|
+
PlatformAuthError: If not authenticated.
|
|
375
|
+
|
|
376
|
+
Example:
|
|
377
|
+
>>> # Twitter thread example
|
|
378
|
+
>>> posts = [
|
|
379
|
+
... TwitterPostRequest(content="Thread start! 1/3"),
|
|
380
|
+
... TwitterPostRequest(content="Middle 2/3", media_urls=[...]),
|
|
381
|
+
... TwitterPostRequest(content="End 3/3"),
|
|
382
|
+
... ]
|
|
383
|
+
>>> thread = await client.create_thread(posts)
|
|
384
|
+
"""
|
|
385
|
+
raise NotImplementedError(
|
|
386
|
+
f"{self.platform_name} does not support thread creation"
|
|
387
|
+
)
|
|
388
|
+
|
|
310
389
|
# ==================== Abstract Comment Methods ====================
|
|
311
390
|
|
|
312
391
|
@abstractmethod
|
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
|
|
marqetive/core/models.py
CHANGED
|
@@ -6,10 +6,10 @@ and type safety.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from datetime import UTC, datetime, timedelta
|
|
9
|
-
from enum import Enum
|
|
9
|
+
from enum import Enum, StrEnum
|
|
10
10
|
from typing import Any
|
|
11
11
|
|
|
12
|
-
from pydantic import BaseModel, Field, HttpUrl
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field, HttpUrl
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
class MediaType(str, Enum):
|
|
@@ -19,6 +19,8 @@ class MediaType(str, Enum):
|
|
|
19
19
|
VIDEO = "video"
|
|
20
20
|
CAROUSEL = "carousel"
|
|
21
21
|
DOCUMENT = "document"
|
|
22
|
+
REEL = "reel"
|
|
23
|
+
STORY = "story"
|
|
22
24
|
|
|
23
25
|
|
|
24
26
|
class PostStatus(str, Enum):
|
|
@@ -55,6 +57,29 @@ class AccountStatus(str, Enum):
|
|
|
55
57
|
ERROR = "error"
|
|
56
58
|
|
|
57
59
|
|
|
60
|
+
class ProgressStatus(StrEnum):
|
|
61
|
+
"""Standard progress status for operations across all platforms.
|
|
62
|
+
|
|
63
|
+
Used with ProgressEvent to provide consistent progress tracking
|
|
64
|
+
for long-running operations like media uploads and post creation.
|
|
65
|
+
|
|
66
|
+
Attributes:
|
|
67
|
+
INITIALIZING: Operation is starting, preparing resources.
|
|
68
|
+
UPLOADING: Actively transferring data (e.g., uploading media).
|
|
69
|
+
PROCESSING: Server-side processing (e.g., video transcoding).
|
|
70
|
+
FINALIZING: Completing the operation (e.g., publishing post).
|
|
71
|
+
COMPLETED: Operation finished successfully.
|
|
72
|
+
FAILED: Operation failed with an error.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
INITIALIZING = "initializing"
|
|
76
|
+
UPLOADING = "uploading"
|
|
77
|
+
PROCESSING = "processing"
|
|
78
|
+
FINALIZING = "finalizing"
|
|
79
|
+
COMPLETED = "completed"
|
|
80
|
+
FAILED = "failed"
|
|
81
|
+
|
|
82
|
+
|
|
58
83
|
class MediaAttachment(BaseModel):
|
|
59
84
|
"""Represents a media attachment (image, video, etc.).
|
|
60
85
|
|
|
@@ -294,8 +319,82 @@ class PlatformResponse(BaseModel):
|
|
|
294
319
|
rate_limit_reset: datetime | None = None
|
|
295
320
|
|
|
296
321
|
|
|
322
|
+
class ProgressEvent(BaseModel):
|
|
323
|
+
"""Unified progress event for tracking long-running operations.
|
|
324
|
+
|
|
325
|
+
Provides a consistent interface for progress callbacks across all platforms.
|
|
326
|
+
Supports both synchronous and asynchronous callbacks.
|
|
327
|
+
|
|
328
|
+
Attributes:
|
|
329
|
+
operation: Name of the operation (e.g., "upload_media", "create_post", "create_thread").
|
|
330
|
+
platform: Platform name (e.g., "twitter", "linkedin", "instagram", "tiktok").
|
|
331
|
+
status: Current status of the operation.
|
|
332
|
+
progress: Current progress value (0-100 for percentage, or bytes uploaded).
|
|
333
|
+
total: Total value for completion (100 for percentage, or total bytes).
|
|
334
|
+
message: Optional human-readable status message.
|
|
335
|
+
entity_id: Optional platform-specific ID (media_id, post_id, container_id).
|
|
336
|
+
file_path: Optional file path for upload operations.
|
|
337
|
+
bytes_uploaded: Optional bytes uploaded so far.
|
|
338
|
+
total_bytes: Optional total bytes to upload.
|
|
339
|
+
|
|
340
|
+
Example:
|
|
341
|
+
>>> # Progress callback for media upload
|
|
342
|
+
>>> def on_progress(event: ProgressEvent) -> None:
|
|
343
|
+
... print(f"{event.operation}: {event.percentage:.1f}% - {event.message}")
|
|
344
|
+
...
|
|
345
|
+
>>> # Async progress callback
|
|
346
|
+
>>> async def on_progress_async(event: ProgressEvent) -> None:
|
|
347
|
+
... await log_to_database(event)
|
|
348
|
+
|
|
349
|
+
>>> event = ProgressEvent(
|
|
350
|
+
... operation="upload_media",
|
|
351
|
+
... platform="twitter",
|
|
352
|
+
... status=ProgressStatus.UPLOADING,
|
|
353
|
+
... progress=50,
|
|
354
|
+
... total=100,
|
|
355
|
+
... message="Uploading image 1 of 2",
|
|
356
|
+
... bytes_uploaded=524288,
|
|
357
|
+
... total_bytes=1048576,
|
|
358
|
+
... )
|
|
359
|
+
>>> print(event.percentage) # 50.0
|
|
360
|
+
"""
|
|
361
|
+
|
|
362
|
+
operation: str
|
|
363
|
+
platform: str
|
|
364
|
+
status: ProgressStatus
|
|
365
|
+
progress: int
|
|
366
|
+
total: int
|
|
367
|
+
message: str | None = None
|
|
368
|
+
|
|
369
|
+
# Optional detailed info
|
|
370
|
+
entity_id: str | None = None
|
|
371
|
+
file_path: str | None = None
|
|
372
|
+
bytes_uploaded: int | None = None
|
|
373
|
+
total_bytes: int | None = None
|
|
374
|
+
|
|
375
|
+
@property
|
|
376
|
+
def percentage(self) -> float:
|
|
377
|
+
"""Calculate progress as a percentage.
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
Progress percentage (0.0 to 100.0).
|
|
381
|
+
"""
|
|
382
|
+
if self.total == 0:
|
|
383
|
+
return 0.0
|
|
384
|
+
return (self.progress / self.total) * 100
|
|
385
|
+
|
|
386
|
+
model_config = ConfigDict(frozen=True)
|
|
387
|
+
|
|
388
|
+
|
|
297
389
|
class PostCreateRequest(BaseModel):
|
|
298
|
-
"""
|
|
390
|
+
"""Base request model for creating a new post.
|
|
391
|
+
|
|
392
|
+
This is a minimal base model with universal fields that work across all platforms.
|
|
393
|
+
For platform-specific features, use the dedicated request models:
|
|
394
|
+
- TwitterPostRequest
|
|
395
|
+
- LinkedInPostRequest
|
|
396
|
+
- InstagramPostRequest
|
|
397
|
+
- TikTokPostRequest
|
|
299
398
|
|
|
300
399
|
Attributes:
|
|
301
400
|
content: Text content of the post
|
|
@@ -305,7 +404,7 @@ class PostCreateRequest(BaseModel):
|
|
|
305
404
|
link: URL to include in the post
|
|
306
405
|
tags: List of hashtags or user tags
|
|
307
406
|
location: Location/place tag for the post
|
|
308
|
-
additional_data: Platform-specific data
|
|
407
|
+
additional_data: Platform-specific data for backward compatibility
|
|
309
408
|
|
|
310
409
|
Example:
|
|
311
410
|
>>> request = PostCreateRequest(
|
|
@@ -326,10 +425,15 @@ class PostCreateRequest(BaseModel):
|
|
|
326
425
|
|
|
327
426
|
|
|
328
427
|
class PostUpdateRequest(BaseModel):
|
|
329
|
-
"""
|
|
428
|
+
"""Base request model for updating an existing post.
|
|
429
|
+
|
|
430
|
+
Note: Not all platforms support editing posts:
|
|
431
|
+
- Twitter: No editing support (for most users)
|
|
432
|
+
- Instagram: No editing support
|
|
433
|
+
- TikTok: No editing support
|
|
434
|
+
- LinkedIn: Supports updating content, CTA, and landing page
|
|
330
435
|
|
|
331
|
-
|
|
332
|
-
updated vary by platform.
|
|
436
|
+
For LinkedIn-specific update features, use LinkedInPostUpdateRequest.
|
|
333
437
|
|
|
334
438
|
Attributes:
|
|
335
439
|
content: Updated text content
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Instagram platform integration."""
|
|
2
2
|
|
|
3
3
|
from marqetive.platforms.instagram.client import InstagramClient
|
|
4
|
+
from marqetive.platforms.instagram.models import InstagramPostRequest
|
|
4
5
|
|
|
5
|
-
__all__ = ["InstagramClient"]
|
|
6
|
+
__all__ = ["InstagramClient", "InstagramPostRequest"]
|
|
@@ -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
|
)
|
|
@@ -11,8 +11,9 @@ This module provides comprehensive media management for:
|
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
13
|
import asyncio
|
|
14
|
+
import inspect
|
|
14
15
|
import logging
|
|
15
|
-
from collections.abc import Callable
|
|
16
|
+
from collections.abc import Awaitable, Callable
|
|
16
17
|
from dataclasses import dataclass
|
|
17
18
|
from enum import Enum
|
|
18
19
|
from typing import Any, Literal
|
|
@@ -23,8 +24,14 @@ from marqetive.core.exceptions import (
|
|
|
23
24
|
MediaUploadError,
|
|
24
25
|
ValidationError,
|
|
25
26
|
)
|
|
27
|
+
from marqetive.core.models import ProgressEvent, ProgressStatus
|
|
26
28
|
from marqetive.utils.retry import STANDARD_BACKOFF, retry_async
|
|
27
29
|
|
|
30
|
+
# Type aliases for progress callbacks
|
|
31
|
+
type SyncProgressCallback = Callable[[ProgressEvent], None]
|
|
32
|
+
type AsyncProgressCallback = Callable[[ProgressEvent], Awaitable[None]]
|
|
33
|
+
type ProgressCallback = SyncProgressCallback | AsyncProgressCallback
|
|
34
|
+
|
|
28
35
|
logger = logging.getLogger(__name__)
|
|
29
36
|
|
|
30
37
|
# Instagram API limits
|
|
@@ -136,7 +143,7 @@ class InstagramMediaManager:
|
|
|
136
143
|
*,
|
|
137
144
|
api_version: str = "v21.0",
|
|
138
145
|
timeout: float = 30.0,
|
|
139
|
-
progress_callback:
|
|
146
|
+
progress_callback: ProgressCallback | None = None,
|
|
140
147
|
) -> None:
|
|
141
148
|
"""Initialize Instagram media manager.
|
|
142
149
|
|
|
@@ -145,7 +152,8 @@ class InstagramMediaManager:
|
|
|
145
152
|
access_token: Instagram/Facebook access token.
|
|
146
153
|
api_version: Instagram Graph API version.
|
|
147
154
|
timeout: Request timeout in seconds.
|
|
148
|
-
progress_callback: Optional callback
|
|
155
|
+
progress_callback: Optional callback for progress updates.
|
|
156
|
+
Receives ProgressEvent objects with upload status and metrics.
|
|
149
157
|
"""
|
|
150
158
|
self.ig_user_id = ig_user_id
|
|
151
159
|
self.access_token = access_token
|
|
@@ -167,6 +175,44 @@ class InstagramMediaManager:
|
|
|
167
175
|
"""Exit async context and cleanup."""
|
|
168
176
|
await self.client.aclose()
|
|
169
177
|
|
|
178
|
+
async def _emit_progress(
|
|
179
|
+
self,
|
|
180
|
+
status: ProgressStatus,
|
|
181
|
+
progress: int,
|
|
182
|
+
total: int,
|
|
183
|
+
message: str | None = None,
|
|
184
|
+
*,
|
|
185
|
+
entity_id: str | None = None,
|
|
186
|
+
file_path: str | None = None,
|
|
187
|
+
bytes_uploaded: int | None = None,
|
|
188
|
+
total_bytes: int | None = None,
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Emit a progress update if a callback is registered.
|
|
191
|
+
|
|
192
|
+
Supports both sync and async callbacks.
|
|
193
|
+
"""
|
|
194
|
+
if self.progress_callback is None:
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
event = ProgressEvent(
|
|
198
|
+
operation="upload_media",
|
|
199
|
+
platform="instagram",
|
|
200
|
+
status=status,
|
|
201
|
+
progress=progress,
|
|
202
|
+
total=total,
|
|
203
|
+
message=message,
|
|
204
|
+
entity_id=entity_id,
|
|
205
|
+
file_path=file_path,
|
|
206
|
+
bytes_uploaded=bytes_uploaded,
|
|
207
|
+
total_bytes=total_bytes,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
result = self.progress_callback(event)
|
|
211
|
+
|
|
212
|
+
# If callback returned a coroutine, await it
|
|
213
|
+
if inspect.iscoroutine(result):
|
|
214
|
+
await result
|
|
215
|
+
|
|
170
216
|
async def create_feed_containers(
|
|
171
217
|
self,
|
|
172
218
|
media_items: list[MediaItem],
|
|
@@ -260,9 +306,14 @@ class InstagramMediaManager:
|
|
|
260
306
|
container_ids.append(container_id)
|
|
261
307
|
|
|
262
308
|
# Notify progress
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
309
|
+
progress = int(((idx + 1) / len(media_items)) * 100)
|
|
310
|
+
await self._emit_progress(
|
|
311
|
+
status=ProgressStatus.COMPLETED,
|
|
312
|
+
progress=progress,
|
|
313
|
+
total=100,
|
|
314
|
+
message=f"Created container {idx + 1}/{len(media_items)}",
|
|
315
|
+
entity_id=container_id,
|
|
316
|
+
)
|
|
266
317
|
|
|
267
318
|
# If carousel, create parent container
|
|
268
319
|
if is_carousel:
|
|
@@ -355,8 +406,13 @@ class InstagramMediaManager:
|
|
|
355
406
|
media_type="reel",
|
|
356
407
|
)
|
|
357
408
|
|
|
358
|
-
|
|
359
|
-
|
|
409
|
+
await self._emit_progress(
|
|
410
|
+
status=ProgressStatus.COMPLETED,
|
|
411
|
+
progress=100,
|
|
412
|
+
total=100,
|
|
413
|
+
message="Reel container ready for publishing",
|
|
414
|
+
entity_id=container_id,
|
|
415
|
+
)
|
|
360
416
|
|
|
361
417
|
return container_id
|
|
362
418
|
|
|
@@ -426,8 +482,13 @@ class InstagramMediaManager:
|
|
|
426
482
|
media_type="story",
|
|
427
483
|
)
|
|
428
484
|
|
|
429
|
-
|
|
430
|
-
|
|
485
|
+
await self._emit_progress(
|
|
486
|
+
status=ProgressStatus.COMPLETED,
|
|
487
|
+
progress=100,
|
|
488
|
+
total=100,
|
|
489
|
+
message="Story container ready for publishing",
|
|
490
|
+
entity_id=container_id,
|
|
491
|
+
)
|
|
431
492
|
|
|
432
493
|
return container_id
|
|
433
494
|
|
|
@@ -549,9 +610,14 @@ class InstagramMediaManager:
|
|
|
549
610
|
)
|
|
550
611
|
|
|
551
612
|
# Notify progress
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
613
|
+
progress = min(int((elapsed / timeout) * 90), 90) # Cap at 90%
|
|
614
|
+
await self._emit_progress(
|
|
615
|
+
status=ProgressStatus.PROCESSING,
|
|
616
|
+
progress=progress,
|
|
617
|
+
total=100,
|
|
618
|
+
message=f"Processing {media_type} container",
|
|
619
|
+
entity_id=container_id,
|
|
620
|
+
)
|
|
555
621
|
|
|
556
622
|
await asyncio.sleep(check_interval)
|
|
557
623
|
elapsed += check_interval
|