marqetive-lib 0.1.0__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 +113 -0
- marqetive/core/__init__.py +5 -0
- marqetive/core/account_factory.py +212 -0
- marqetive/core/base_manager.py +303 -0
- marqetive/core/client.py +108 -0
- marqetive/core/progress.py +291 -0
- marqetive/core/registry.py +257 -0
- marqetive/platforms/__init__.py +55 -0
- marqetive/platforms/base.py +390 -0
- marqetive/platforms/exceptions.py +238 -0
- marqetive/platforms/instagram/__init__.py +7 -0
- marqetive/platforms/instagram/client.py +786 -0
- marqetive/platforms/instagram/exceptions.py +311 -0
- marqetive/platforms/instagram/factory.py +106 -0
- marqetive/platforms/instagram/manager.py +112 -0
- marqetive/platforms/instagram/media.py +669 -0
- marqetive/platforms/linkedin/__init__.py +7 -0
- marqetive/platforms/linkedin/client.py +733 -0
- marqetive/platforms/linkedin/exceptions.py +335 -0
- marqetive/platforms/linkedin/factory.py +130 -0
- marqetive/platforms/linkedin/manager.py +119 -0
- marqetive/platforms/linkedin/media.py +549 -0
- marqetive/platforms/models.py +345 -0
- marqetive/platforms/tiktok/__init__.py +0 -0
- marqetive/platforms/twitter/__init__.py +7 -0
- marqetive/platforms/twitter/client.py +647 -0
- marqetive/platforms/twitter/exceptions.py +311 -0
- marqetive/platforms/twitter/factory.py +151 -0
- marqetive/platforms/twitter/manager.py +121 -0
- marqetive/platforms/twitter/media.py +779 -0
- marqetive/platforms/twitter/threads.py +442 -0
- marqetive/py.typed +0 -0
- marqetive/registry_init.py +66 -0
- marqetive/utils/__init__.py +45 -0
- marqetive/utils/file_handlers.py +438 -0
- marqetive/utils/helpers.py +99 -0
- marqetive/utils/media.py +399 -0
- marqetive/utils/oauth.py +265 -0
- marqetive/utils/retry.py +239 -0
- marqetive/utils/token_validator.py +240 -0
- marqetive_lib-0.1.0.dist-info/METADATA +261 -0
- marqetive_lib-0.1.0.dist-info/RECORD +43 -0
- marqetive_lib-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
"""Instagram media manager for container-based publishing.
|
|
2
|
+
|
|
3
|
+
Instagram uses a two-step publishing process:
|
|
4
|
+
1. Create media containers (upload and process media)
|
|
5
|
+
2. Publish containers to make content live
|
|
6
|
+
|
|
7
|
+
This module provides comprehensive media management for:
|
|
8
|
+
- Feed posts (single image or carousel)
|
|
9
|
+
- Reels (videos)
|
|
10
|
+
- Stories (images and videos)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import logging
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from typing import Any, Literal
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from marqetive.platforms.exceptions import (
|
|
23
|
+
MediaUploadError,
|
|
24
|
+
ValidationError,
|
|
25
|
+
)
|
|
26
|
+
from marqetive.utils.retry import STANDARD_BACKOFF, retry_async
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# Instagram API limits
|
|
31
|
+
MAX_CAROUSEL_ITEMS = 10
|
|
32
|
+
MAX_CAPTION_LENGTH = 2200
|
|
33
|
+
MAX_VIDEO_DURATION_FEED = 60 # seconds
|
|
34
|
+
MAX_VIDEO_DURATION_REEL = 90 # seconds
|
|
35
|
+
MAX_VIDEO_DURATION_STORY = 60 # seconds
|
|
36
|
+
|
|
37
|
+
# Processing timeouts
|
|
38
|
+
DEFAULT_VIDEO_TIMEOUT = 300 # 5 minutes
|
|
39
|
+
REEL_VIDEO_TIMEOUT = 420 # 7 minutes
|
|
40
|
+
STORY_VIDEO_TIMEOUT = 180 # 3 minutes
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MediaType(str, Enum):
|
|
44
|
+
"""Instagram media types."""
|
|
45
|
+
|
|
46
|
+
IMAGE = "IMAGE"
|
|
47
|
+
VIDEO = "VIDEO"
|
|
48
|
+
CAROUSEL = "CAROUSEL"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PublishStatus(str, Enum):
|
|
52
|
+
"""Container publishing status."""
|
|
53
|
+
|
|
54
|
+
EXPIRED = "EXPIRED"
|
|
55
|
+
ERROR = "ERROR"
|
|
56
|
+
FINISHED = "FINISHED"
|
|
57
|
+
IN_PROGRESS = "IN_PROGRESS"
|
|
58
|
+
PUBLISHED = "PUBLISHED"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class MediaItem:
|
|
63
|
+
"""Media item for Instagram posts.
|
|
64
|
+
|
|
65
|
+
Attributes:
|
|
66
|
+
url: URL of the media file (must be publicly accessible).
|
|
67
|
+
type: Type of media ("image" or "video").
|
|
68
|
+
alt_text: Alternative text for accessibility.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
url: str
|
|
72
|
+
type: Literal["image", "video"]
|
|
73
|
+
alt_text: str | None = None
|
|
74
|
+
|
|
75
|
+
def __post_init__(self) -> None:
|
|
76
|
+
"""Validate media item."""
|
|
77
|
+
if not self.url:
|
|
78
|
+
raise ValidationError("Media URL cannot be empty", platform="instagram")
|
|
79
|
+
|
|
80
|
+
if self.type not in ("image", "video"):
|
|
81
|
+
raise ValidationError(
|
|
82
|
+
f"Invalid media type: {self.type}. Must be 'image' or 'video'",
|
|
83
|
+
platform="instagram",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class ContainerResult:
|
|
89
|
+
"""Result of creating a media container.
|
|
90
|
+
|
|
91
|
+
Attributes:
|
|
92
|
+
container_id: Instagram container ID.
|
|
93
|
+
status: Current status of the container.
|
|
94
|
+
media_type: Type of media in container.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
container_id: str
|
|
98
|
+
status: str
|
|
99
|
+
media_type: str
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class PublishResult:
|
|
104
|
+
"""Result of publishing a container.
|
|
105
|
+
|
|
106
|
+
Attributes:
|
|
107
|
+
media_id: Instagram media ID (post/reel/story ID).
|
|
108
|
+
permalink: Direct link to the published content.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
media_id: str
|
|
112
|
+
permalink: str | None = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class InstagramMediaManager:
|
|
116
|
+
"""Manager for Instagram container-based media publishing.
|
|
117
|
+
|
|
118
|
+
Instagram requires a two-step process:
|
|
119
|
+
1. Create container (uploads and processes media)
|
|
120
|
+
2. Publish container (makes it live)
|
|
121
|
+
|
|
122
|
+
Example:
|
|
123
|
+
>>> manager = InstagramMediaManager(ig_user_id, access_token)
|
|
124
|
+
>>> # Single image post
|
|
125
|
+
>>> media = [MediaItem(url="https://...", type="image")]
|
|
126
|
+
>>> container_ids = await manager.create_feed_containers(
|
|
127
|
+
... media, caption="Hello Instagram!"
|
|
128
|
+
... )
|
|
129
|
+
>>> result = await manager.publish_container(container_ids[0])
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def __init__(
|
|
133
|
+
self,
|
|
134
|
+
ig_user_id: str,
|
|
135
|
+
access_token: str,
|
|
136
|
+
*,
|
|
137
|
+
api_version: str = "v21.0",
|
|
138
|
+
timeout: float = 30.0,
|
|
139
|
+
progress_callback: Callable[[str, str, int], None] | None = None,
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Initialize Instagram media manager.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
ig_user_id: Instagram Business Account ID.
|
|
145
|
+
access_token: Instagram/Facebook access token.
|
|
146
|
+
api_version: Instagram Graph API version.
|
|
147
|
+
timeout: Request timeout in seconds.
|
|
148
|
+
progress_callback: Optional callback(container_id, status, progress_pct).
|
|
149
|
+
"""
|
|
150
|
+
self.ig_user_id = ig_user_id
|
|
151
|
+
self.access_token = access_token
|
|
152
|
+
self.api_version = api_version
|
|
153
|
+
self.timeout = timeout
|
|
154
|
+
self.progress_callback = progress_callback
|
|
155
|
+
|
|
156
|
+
self.base_url = f"https://graph.instagram.com/{api_version}"
|
|
157
|
+
self.client = httpx.AsyncClient(
|
|
158
|
+
timeout=httpx.Timeout(timeout),
|
|
159
|
+
params={"access_token": access_token},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
async def __aenter__(self) -> "InstagramMediaManager":
|
|
163
|
+
"""Enter async context."""
|
|
164
|
+
return self
|
|
165
|
+
|
|
166
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
167
|
+
"""Exit async context and cleanup."""
|
|
168
|
+
await self.client.aclose()
|
|
169
|
+
|
|
170
|
+
async def create_feed_containers(
|
|
171
|
+
self,
|
|
172
|
+
media_items: list[MediaItem],
|
|
173
|
+
*,
|
|
174
|
+
caption: str | None = None,
|
|
175
|
+
location_id: str | None = None,
|
|
176
|
+
share_to_feed: bool = True,
|
|
177
|
+
) -> list[str]:
|
|
178
|
+
"""Create containers for Instagram feed post (single or carousel).
|
|
179
|
+
|
|
180
|
+
Note: Instagram deprecated video feed posts. Use create_reel_container instead.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
media_items: List of media items (images only, 1-10 items).
|
|
184
|
+
caption: Post caption (max 2200 characters).
|
|
185
|
+
location_id: Optional location ID.
|
|
186
|
+
share_to_feed: Whether to share to feed (default: True).
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
List of container IDs.
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
ValidationError: If validation fails.
|
|
193
|
+
MediaUploadError: If container creation fails.
|
|
194
|
+
|
|
195
|
+
Example:
|
|
196
|
+
>>> media = [
|
|
197
|
+
... MediaItem("https://example.com/img1.jpg", "image"),
|
|
198
|
+
... MediaItem("https://example.com/img2.jpg", "image"),
|
|
199
|
+
... ]
|
|
200
|
+
>>> containers = await manager.create_feed_containers(
|
|
201
|
+
... media, caption="Carousel post!"
|
|
202
|
+
... )
|
|
203
|
+
"""
|
|
204
|
+
# Validate inputs
|
|
205
|
+
if not media_items:
|
|
206
|
+
raise ValidationError(
|
|
207
|
+
"At least one media item required",
|
|
208
|
+
platform="instagram",
|
|
209
|
+
field="media_items",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
if len(media_items) > MAX_CAROUSEL_ITEMS:
|
|
213
|
+
raise ValidationError(
|
|
214
|
+
f"Maximum {MAX_CAROUSEL_ITEMS} media items allowed",
|
|
215
|
+
platform="instagram",
|
|
216
|
+
field="media_items",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Check for videos (not allowed in feed posts)
|
|
220
|
+
video_items = [m for m in media_items if m.type == "video"]
|
|
221
|
+
if video_items:
|
|
222
|
+
raise ValidationError(
|
|
223
|
+
"Instagram feed posts no longer support videos. Use create_reel_container instead.",
|
|
224
|
+
platform="instagram",
|
|
225
|
+
field="media_items",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
if caption and len(caption) > MAX_CAPTION_LENGTH:
|
|
229
|
+
raise ValidationError(
|
|
230
|
+
f"Caption exceeds {MAX_CAPTION_LENGTH} characters",
|
|
231
|
+
platform="instagram",
|
|
232
|
+
field="caption",
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
is_carousel = len(media_items) > 1
|
|
236
|
+
container_ids: list[str] = []
|
|
237
|
+
|
|
238
|
+
logger.info(
|
|
239
|
+
f"Creating {'carousel' if is_carousel else 'single'} "
|
|
240
|
+
f"feed containers for {len(media_items)} items"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# Create individual containers
|
|
244
|
+
for idx, media_item in enumerate(media_items):
|
|
245
|
+
if is_carousel:
|
|
246
|
+
# Carousel item containers
|
|
247
|
+
container_id = await self._create_carousel_item_container(
|
|
248
|
+
media_item.url,
|
|
249
|
+
media_item.alt_text,
|
|
250
|
+
)
|
|
251
|
+
else:
|
|
252
|
+
# Single post container
|
|
253
|
+
container_id = await self._create_single_image_container(
|
|
254
|
+
media_item.url,
|
|
255
|
+
caption=caption,
|
|
256
|
+
location_id=location_id,
|
|
257
|
+
share_to_feed=share_to_feed,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
container_ids.append(container_id)
|
|
261
|
+
|
|
262
|
+
# Notify progress
|
|
263
|
+
if self.progress_callback:
|
|
264
|
+
progress = int(((idx + 1) / len(media_items)) * 100)
|
|
265
|
+
self.progress_callback(container_id, "created", progress)
|
|
266
|
+
|
|
267
|
+
# If carousel, create parent container
|
|
268
|
+
if is_carousel:
|
|
269
|
+
parent_container = await self._create_carousel_parent_container(
|
|
270
|
+
container_ids,
|
|
271
|
+
caption=caption,
|
|
272
|
+
location_id=location_id,
|
|
273
|
+
share_to_feed=share_to_feed,
|
|
274
|
+
)
|
|
275
|
+
# Return only parent container for publishing
|
|
276
|
+
return [parent_container]
|
|
277
|
+
|
|
278
|
+
return container_ids
|
|
279
|
+
|
|
280
|
+
async def create_reel_container(
|
|
281
|
+
self,
|
|
282
|
+
video_url: str,
|
|
283
|
+
*,
|
|
284
|
+
caption: str | None = None,
|
|
285
|
+
cover_url: str | None = None,
|
|
286
|
+
share_to_feed: bool = True,
|
|
287
|
+
audio_name: str | None = None,
|
|
288
|
+
wait_for_processing: bool = True,
|
|
289
|
+
) -> str:
|
|
290
|
+
"""Create container for Instagram Reel (video).
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
video_url: URL of video file (publicly accessible).
|
|
294
|
+
caption: Reel caption (max 2200 characters).
|
|
295
|
+
cover_url: Optional thumbnail image URL.
|
|
296
|
+
share_to_feed: Share reel to main feed.
|
|
297
|
+
audio_name: Optional audio/music name attribution.
|
|
298
|
+
wait_for_processing: Wait for video processing to complete.
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
Container ID ready for publishing.
|
|
302
|
+
|
|
303
|
+
Raises:
|
|
304
|
+
ValidationError: If validation fails.
|
|
305
|
+
MediaUploadError: If container creation or processing fails.
|
|
306
|
+
|
|
307
|
+
Example:
|
|
308
|
+
>>> container_id = await manager.create_reel_container(
|
|
309
|
+
... "https://example.com/video.mp4",
|
|
310
|
+
... caption="Check out this reel!",
|
|
311
|
+
... share_to_feed=True
|
|
312
|
+
... )
|
|
313
|
+
>>> result = await manager.publish_container(container_id)
|
|
314
|
+
"""
|
|
315
|
+
if caption and len(caption) > MAX_CAPTION_LENGTH:
|
|
316
|
+
raise ValidationError(
|
|
317
|
+
f"Caption exceeds {MAX_CAPTION_LENGTH} characters",
|
|
318
|
+
platform="instagram",
|
|
319
|
+
field="caption",
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
@retry_async(config=STANDARD_BACKOFF)
|
|
323
|
+
async def _create() -> str:
|
|
324
|
+
params: dict[str, Any] = {
|
|
325
|
+
"media_type": "REELS",
|
|
326
|
+
"video_url": video_url,
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if caption:
|
|
330
|
+
params["caption"] = caption
|
|
331
|
+
if cover_url:
|
|
332
|
+
params["cover_url"] = cover_url
|
|
333
|
+
if not share_to_feed:
|
|
334
|
+
params["share_to_feed"] = "false"
|
|
335
|
+
if audio_name:
|
|
336
|
+
params["audio_name"] = audio_name
|
|
337
|
+
|
|
338
|
+
response = await self.client.post(
|
|
339
|
+
f"{self.base_url}/{self.ig_user_id}/media",
|
|
340
|
+
params=params,
|
|
341
|
+
)
|
|
342
|
+
response.raise_for_status()
|
|
343
|
+
result = response.json()
|
|
344
|
+
return result["id"]
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
container_id = await _create()
|
|
348
|
+
logger.info(f"Created reel container: {container_id}")
|
|
349
|
+
|
|
350
|
+
# Wait for video processing if requested
|
|
351
|
+
if wait_for_processing:
|
|
352
|
+
await self._wait_for_container_ready(
|
|
353
|
+
container_id,
|
|
354
|
+
timeout=REEL_VIDEO_TIMEOUT,
|
|
355
|
+
media_type="reel",
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
if self.progress_callback:
|
|
359
|
+
self.progress_callback(container_id, "ready", 100)
|
|
360
|
+
|
|
361
|
+
return container_id
|
|
362
|
+
|
|
363
|
+
except httpx.HTTPError as e:
|
|
364
|
+
raise MediaUploadError(
|
|
365
|
+
f"Failed to create reel container: {e}",
|
|
366
|
+
platform="instagram",
|
|
367
|
+
media_type="video",
|
|
368
|
+
) from e
|
|
369
|
+
|
|
370
|
+
async def create_story_container(
|
|
371
|
+
self,
|
|
372
|
+
media_url: str,
|
|
373
|
+
media_type: Literal["image", "video"],
|
|
374
|
+
*,
|
|
375
|
+
wait_for_processing: bool = True,
|
|
376
|
+
) -> str:
|
|
377
|
+
"""Create container for Instagram Story.
|
|
378
|
+
|
|
379
|
+
Args:
|
|
380
|
+
media_url: URL of media file.
|
|
381
|
+
media_type: Type of media ("image" or "video").
|
|
382
|
+
wait_for_processing: Wait for video processing (if video).
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
Container ID ready for publishing.
|
|
386
|
+
|
|
387
|
+
Raises:
|
|
388
|
+
MediaUploadError: If container creation fails.
|
|
389
|
+
|
|
390
|
+
Example:
|
|
391
|
+
>>> container_id = await manager.create_story_container(
|
|
392
|
+
... "https://example.com/story.jpg",
|
|
393
|
+
... "image"
|
|
394
|
+
... )
|
|
395
|
+
>>> result = await manager.publish_container(container_id)
|
|
396
|
+
"""
|
|
397
|
+
|
|
398
|
+
@retry_async(config=STANDARD_BACKOFF)
|
|
399
|
+
async def _create() -> str:
|
|
400
|
+
params: dict[str, Any] = {
|
|
401
|
+
"media_type": "STORIES",
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if media_type == "image":
|
|
405
|
+
params["image_url"] = media_url
|
|
406
|
+
else:
|
|
407
|
+
params["video_url"] = media_url
|
|
408
|
+
|
|
409
|
+
response = await self.client.post(
|
|
410
|
+
f"{self.base_url}/{self.ig_user_id}/media",
|
|
411
|
+
params=params,
|
|
412
|
+
)
|
|
413
|
+
response.raise_for_status()
|
|
414
|
+
result = response.json()
|
|
415
|
+
return result["id"]
|
|
416
|
+
|
|
417
|
+
try:
|
|
418
|
+
container_id = await _create()
|
|
419
|
+
logger.info(f"Created story container: {container_id}")
|
|
420
|
+
|
|
421
|
+
# Wait for video processing if needed
|
|
422
|
+
if media_type == "video" and wait_for_processing:
|
|
423
|
+
await self._wait_for_container_ready(
|
|
424
|
+
container_id,
|
|
425
|
+
timeout=STORY_VIDEO_TIMEOUT,
|
|
426
|
+
media_type="story",
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
if self.progress_callback:
|
|
430
|
+
self.progress_callback(container_id, "ready", 100)
|
|
431
|
+
|
|
432
|
+
return container_id
|
|
433
|
+
|
|
434
|
+
except httpx.HTTPError as e:
|
|
435
|
+
raise MediaUploadError(
|
|
436
|
+
f"Failed to create story container: {e}",
|
|
437
|
+
platform="instagram",
|
|
438
|
+
media_type=media_type,
|
|
439
|
+
) from e
|
|
440
|
+
|
|
441
|
+
async def publish_container(
|
|
442
|
+
self,
|
|
443
|
+
container_id: str,
|
|
444
|
+
) -> PublishResult:
|
|
445
|
+
"""Publish a media container to make it live.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
container_id: Container ID to publish.
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
PublishResult with media ID and permalink.
|
|
452
|
+
|
|
453
|
+
Raises:
|
|
454
|
+
MediaUploadError: If publishing fails.
|
|
455
|
+
|
|
456
|
+
Example:
|
|
457
|
+
>>> result = await manager.publish_container(container_id)
|
|
458
|
+
>>> print(f"Published: {result.media_id}")
|
|
459
|
+
>>> print(f"Link: {result.permalink}")
|
|
460
|
+
"""
|
|
461
|
+
|
|
462
|
+
@retry_async(config=STANDARD_BACKOFF)
|
|
463
|
+
async def _publish() -> PublishResult:
|
|
464
|
+
response = await self.client.post(
|
|
465
|
+
f"{self.base_url}/{self.ig_user_id}/media_publish",
|
|
466
|
+
params={"creation_id": container_id},
|
|
467
|
+
)
|
|
468
|
+
response.raise_for_status()
|
|
469
|
+
result = response.json()
|
|
470
|
+
|
|
471
|
+
media_id = result["id"]
|
|
472
|
+
|
|
473
|
+
# Fetch permalink
|
|
474
|
+
permalink = await self._get_media_permalink(media_id)
|
|
475
|
+
|
|
476
|
+
return PublishResult(media_id=media_id, permalink=permalink)
|
|
477
|
+
|
|
478
|
+
try:
|
|
479
|
+
result = await _publish()
|
|
480
|
+
logger.info(f"Published container {container_id} -> {result.media_id}")
|
|
481
|
+
return result
|
|
482
|
+
|
|
483
|
+
except httpx.HTTPError as e:
|
|
484
|
+
raise MediaUploadError(
|
|
485
|
+
f"Failed to publish container: {e}",
|
|
486
|
+
platform="instagram",
|
|
487
|
+
) from e
|
|
488
|
+
|
|
489
|
+
async def get_container_status(self, container_id: str) -> dict[str, Any]:
|
|
490
|
+
"""Get status of a media container.
|
|
491
|
+
|
|
492
|
+
Args:
|
|
493
|
+
container_id: Container ID to check.
|
|
494
|
+
|
|
495
|
+
Returns:
|
|
496
|
+
Dictionary with container status information.
|
|
497
|
+
|
|
498
|
+
Example:
|
|
499
|
+
>>> status = await manager.get_container_status(container_id)
|
|
500
|
+
>>> print(f"Status: {status['status_code']}")
|
|
501
|
+
"""
|
|
502
|
+
|
|
503
|
+
@retry_async(config=STANDARD_BACKOFF)
|
|
504
|
+
async def _get_status() -> dict[str, Any]:
|
|
505
|
+
response = await self.client.get(
|
|
506
|
+
f"{self.base_url}/{container_id}",
|
|
507
|
+
params={"fields": "status_code,status"},
|
|
508
|
+
)
|
|
509
|
+
response.raise_for_status()
|
|
510
|
+
return response.json()
|
|
511
|
+
|
|
512
|
+
return await _get_status()
|
|
513
|
+
|
|
514
|
+
async def _wait_for_container_ready(
|
|
515
|
+
self,
|
|
516
|
+
container_id: str,
|
|
517
|
+
*,
|
|
518
|
+
timeout: int = DEFAULT_VIDEO_TIMEOUT,
|
|
519
|
+
check_interval: int = 5,
|
|
520
|
+
media_type: str = "media",
|
|
521
|
+
) -> None:
|
|
522
|
+
"""Wait for container to finish processing.
|
|
523
|
+
|
|
524
|
+
Args:
|
|
525
|
+
container_id: Container ID to monitor.
|
|
526
|
+
timeout: Maximum wait time in seconds.
|
|
527
|
+
check_interval: Seconds between status checks.
|
|
528
|
+
media_type: Type of media for logging.
|
|
529
|
+
|
|
530
|
+
Raises:
|
|
531
|
+
MediaUploadError: If processing fails or times out.
|
|
532
|
+
"""
|
|
533
|
+
elapsed = 0
|
|
534
|
+
logger.info(f"Waiting for {media_type} container {container_id} to process...")
|
|
535
|
+
|
|
536
|
+
while elapsed < timeout:
|
|
537
|
+
status_data = await self.get_container_status(container_id)
|
|
538
|
+
status = status_data.get("status_code")
|
|
539
|
+
|
|
540
|
+
if status == PublishStatus.FINISHED.value:
|
|
541
|
+
logger.info(f"Container {container_id} processing complete")
|
|
542
|
+
return
|
|
543
|
+
|
|
544
|
+
if status in (PublishStatus.ERROR.value, PublishStatus.EXPIRED.value):
|
|
545
|
+
error_msg = status_data.get("status", "Unknown error")
|
|
546
|
+
raise MediaUploadError(
|
|
547
|
+
f"Container processing failed: {error_msg}",
|
|
548
|
+
platform="instagram",
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
# Notify progress
|
|
552
|
+
if self.progress_callback:
|
|
553
|
+
progress = min(int((elapsed / timeout) * 90), 90) # Cap at 90%
|
|
554
|
+
self.progress_callback(container_id, "processing", progress)
|
|
555
|
+
|
|
556
|
+
await asyncio.sleep(check_interval)
|
|
557
|
+
elapsed += check_interval
|
|
558
|
+
|
|
559
|
+
raise MediaUploadError(
|
|
560
|
+
f"Container processing timeout after {timeout}s",
|
|
561
|
+
platform="instagram",
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
async def _create_single_image_container(
|
|
565
|
+
self,
|
|
566
|
+
image_url: str,
|
|
567
|
+
*,
|
|
568
|
+
caption: str | None = None,
|
|
569
|
+
location_id: str | None = None,
|
|
570
|
+
share_to_feed: bool = True,
|
|
571
|
+
) -> str:
|
|
572
|
+
"""Create container for single image post."""
|
|
573
|
+
|
|
574
|
+
@retry_async(config=STANDARD_BACKOFF)
|
|
575
|
+
async def _create() -> str:
|
|
576
|
+
params: dict[str, Any] = {
|
|
577
|
+
"image_url": image_url,
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if caption:
|
|
581
|
+
params["caption"] = caption
|
|
582
|
+
if location_id:
|
|
583
|
+
params["location_id"] = location_id
|
|
584
|
+
if not share_to_feed:
|
|
585
|
+
params["share_to_feed"] = "false"
|
|
586
|
+
|
|
587
|
+
response = await self.client.post(
|
|
588
|
+
f"{self.base_url}/{self.ig_user_id}/media",
|
|
589
|
+
params=params,
|
|
590
|
+
)
|
|
591
|
+
response.raise_for_status()
|
|
592
|
+
result = response.json()
|
|
593
|
+
return result["id"]
|
|
594
|
+
|
|
595
|
+
return await _create()
|
|
596
|
+
|
|
597
|
+
async def _create_carousel_item_container(
|
|
598
|
+
self,
|
|
599
|
+
image_url: str,
|
|
600
|
+
alt_text: str | None = None,
|
|
601
|
+
) -> str:
|
|
602
|
+
"""Create container for carousel item."""
|
|
603
|
+
|
|
604
|
+
@retry_async(config=STANDARD_BACKOFF)
|
|
605
|
+
async def _create() -> str:
|
|
606
|
+
params: dict[str, Any] = {
|
|
607
|
+
"image_url": image_url,
|
|
608
|
+
"is_carousel_item": "true",
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if alt_text:
|
|
612
|
+
params["caption"] = alt_text # Alt text goes in caption for items
|
|
613
|
+
|
|
614
|
+
response = await self.client.post(
|
|
615
|
+
f"{self.base_url}/{self.ig_user_id}/media",
|
|
616
|
+
params=params,
|
|
617
|
+
)
|
|
618
|
+
response.raise_for_status()
|
|
619
|
+
result = response.json()
|
|
620
|
+
return result["id"]
|
|
621
|
+
|
|
622
|
+
return await _create()
|
|
623
|
+
|
|
624
|
+
async def _create_carousel_parent_container(
|
|
625
|
+
self,
|
|
626
|
+
children_ids: list[str],
|
|
627
|
+
*,
|
|
628
|
+
caption: str | None = None,
|
|
629
|
+
location_id: str | None = None,
|
|
630
|
+
share_to_feed: bool = True,
|
|
631
|
+
) -> str:
|
|
632
|
+
"""Create parent container for carousel post."""
|
|
633
|
+
|
|
634
|
+
@retry_async(config=STANDARD_BACKOFF)
|
|
635
|
+
async def _create() -> str:
|
|
636
|
+
params: dict[str, Any] = {
|
|
637
|
+
"media_type": "CAROUSEL",
|
|
638
|
+
"children": ",".join(children_ids),
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if caption:
|
|
642
|
+
params["caption"] = caption
|
|
643
|
+
if location_id:
|
|
644
|
+
params["location_id"] = location_id
|
|
645
|
+
if not share_to_feed:
|
|
646
|
+
params["share_to_feed"] = "false"
|
|
647
|
+
|
|
648
|
+
response = await self.client.post(
|
|
649
|
+
f"{self.base_url}/{self.ig_user_id}/media",
|
|
650
|
+
params=params,
|
|
651
|
+
)
|
|
652
|
+
response.raise_for_status()
|
|
653
|
+
result = response.json()
|
|
654
|
+
return result["id"]
|
|
655
|
+
|
|
656
|
+
return await _create()
|
|
657
|
+
|
|
658
|
+
async def _get_media_permalink(self, media_id: str) -> str | None:
|
|
659
|
+
"""Get permalink for published media."""
|
|
660
|
+
try:
|
|
661
|
+
response = await self.client.get(
|
|
662
|
+
f"{self.base_url}/{media_id}",
|
|
663
|
+
params={"fields": "permalink"},
|
|
664
|
+
)
|
|
665
|
+
response.raise_for_status()
|
|
666
|
+
result = response.json()
|
|
667
|
+
return result.get("permalink")
|
|
668
|
+
except httpx.HTTPError:
|
|
669
|
+
return None
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""LinkedIn platform integration."""
|
|
2
|
+
|
|
3
|
+
from marqetive.platforms.linkedin.client import LinkedInClient
|
|
4
|
+
from marqetive.platforms.linkedin.factory import LinkedInAccountFactory
|
|
5
|
+
from marqetive.platforms.linkedin.manager import LinkedInPostManager
|
|
6
|
+
|
|
7
|
+
__all__ = ["LinkedInClient", "LinkedInAccountFactory", "LinkedInPostManager"]
|