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,786 @@
|
|
|
1
|
+
"""Instagram Graph API client implementation.
|
|
2
|
+
|
|
3
|
+
This module provides a concrete implementation of the SocialMediaPlatform
|
|
4
|
+
ABC for Instagram, using the Instagram Graph API.
|
|
5
|
+
|
|
6
|
+
API Documentation: https://developers.facebook.com/docs/instagram-api
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Any, Literal, cast
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from pydantic import HttpUrl
|
|
14
|
+
|
|
15
|
+
from marqetive.platforms.base import SocialMediaPlatform
|
|
16
|
+
from marqetive.platforms.exceptions import (
|
|
17
|
+
MediaUploadError,
|
|
18
|
+
PlatformAuthError,
|
|
19
|
+
PlatformError,
|
|
20
|
+
PostNotFoundError,
|
|
21
|
+
ValidationError,
|
|
22
|
+
)
|
|
23
|
+
from marqetive.platforms.instagram.media import (
|
|
24
|
+
InstagramMediaManager,
|
|
25
|
+
MediaItem,
|
|
26
|
+
)
|
|
27
|
+
from marqetive.platforms.models import (
|
|
28
|
+
AuthCredentials,
|
|
29
|
+
Comment,
|
|
30
|
+
CommentStatus,
|
|
31
|
+
MediaAttachment,
|
|
32
|
+
MediaType,
|
|
33
|
+
Post,
|
|
34
|
+
PostCreateRequest,
|
|
35
|
+
PostStatus,
|
|
36
|
+
PostUpdateRequest,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class InstagramClient(SocialMediaPlatform):
|
|
41
|
+
"""Instagram Graph API client.
|
|
42
|
+
|
|
43
|
+
This client implements the SocialMediaPlatform interface for Instagram,
|
|
44
|
+
using the Instagram Graph API. It supports posts (feed posts), stories,
|
|
45
|
+
and reels, along with comments and media management.
|
|
46
|
+
|
|
47
|
+
Note:
|
|
48
|
+
- Requires a Facebook App with Instagram Graph API permissions
|
|
49
|
+
- Requires an Instagram Business or Creator account
|
|
50
|
+
- Access tokens must have appropriate scopes (instagram_basic,
|
|
51
|
+
instagram_content_publish, etc.)
|
|
52
|
+
|
|
53
|
+
Example:
|
|
54
|
+
>>> credentials = AuthCredentials(
|
|
55
|
+
... platform="instagram",
|
|
56
|
+
... access_token="your_token",
|
|
57
|
+
... user_id="instagram_business_account_id"
|
|
58
|
+
... )
|
|
59
|
+
>>> async with InstagramClient(credentials) as client:
|
|
60
|
+
... request = PostCreateRequest(
|
|
61
|
+
... content="Check out our new product!",
|
|
62
|
+
... media_urls=["https://example.com/image.jpg"]
|
|
63
|
+
... )
|
|
64
|
+
... post = await client.create_post(request)
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
credentials: AuthCredentials,
|
|
70
|
+
timeout: float = 30.0,
|
|
71
|
+
api_version: str = "v21.0",
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Initialize Instagram client.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
credentials: Instagram authentication credentials
|
|
77
|
+
timeout: Request timeout in seconds
|
|
78
|
+
api_version: Instagram Graph API version
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
PlatformAuthError: If credentials are invalid
|
|
82
|
+
"""
|
|
83
|
+
base_url = f"https://graph.instagram.com/{api_version}"
|
|
84
|
+
super().__init__(
|
|
85
|
+
platform_name="instagram",
|
|
86
|
+
credentials=credentials,
|
|
87
|
+
base_url=base_url,
|
|
88
|
+
timeout=timeout,
|
|
89
|
+
)
|
|
90
|
+
self.instagram_account_id = credentials.user_id
|
|
91
|
+
self.api_version = api_version
|
|
92
|
+
|
|
93
|
+
# Media manager (initialized in __aenter__)
|
|
94
|
+
self._media_manager: InstagramMediaManager | None = None
|
|
95
|
+
|
|
96
|
+
async def __aenter__(self) -> "InstagramClient":
|
|
97
|
+
"""Async context manager entry."""
|
|
98
|
+
await super().__aenter__()
|
|
99
|
+
|
|
100
|
+
# Initialize media manager
|
|
101
|
+
if not self.instagram_account_id:
|
|
102
|
+
raise PlatformAuthError(
|
|
103
|
+
"Instagram account ID (user_id) is required in credentials",
|
|
104
|
+
platform=self.platform_name,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
self._media_manager = InstagramMediaManager(
|
|
108
|
+
ig_user_id=self.instagram_account_id,
|
|
109
|
+
access_token=self.credentials.access_token,
|
|
110
|
+
api_version=self.api_version,
|
|
111
|
+
timeout=self.timeout,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
117
|
+
"""Async context manager exit."""
|
|
118
|
+
# Cleanup media manager
|
|
119
|
+
if self._media_manager:
|
|
120
|
+
await self._media_manager.__aexit__(exc_type, exc_val, exc_tb)
|
|
121
|
+
self._media_manager = None
|
|
122
|
+
|
|
123
|
+
await super().__aexit__(exc_type, exc_val, exc_tb)
|
|
124
|
+
|
|
125
|
+
# ==================== Authentication Methods ====================
|
|
126
|
+
|
|
127
|
+
async def authenticate(self) -> AuthCredentials:
|
|
128
|
+
"""Perform Instagram authentication flow.
|
|
129
|
+
|
|
130
|
+
Note: Instagram uses Facebook OAuth. This method assumes you already
|
|
131
|
+
have a long-lived access token. For the full OAuth flow, use Facebook's
|
|
132
|
+
OAuth implementation.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Current credentials if valid.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
PlatformAuthError: If authentication fails.
|
|
139
|
+
"""
|
|
140
|
+
if await self.is_authenticated():
|
|
141
|
+
return self.credentials
|
|
142
|
+
|
|
143
|
+
raise PlatformAuthError(
|
|
144
|
+
"Invalid or expired credentials. Please re-authenticate via Facebook OAuth.",
|
|
145
|
+
platform=self.platform_name,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
async def refresh_token(self) -> AuthCredentials:
|
|
149
|
+
"""Refresh Instagram access token.
|
|
150
|
+
|
|
151
|
+
Instagram long-lived tokens can be refreshed to extend their validity
|
|
152
|
+
from 60 days to another 60 days.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Updated credentials with new access token.
|
|
156
|
+
|
|
157
|
+
Raises:
|
|
158
|
+
PlatformAuthError: If token refresh fails.
|
|
159
|
+
"""
|
|
160
|
+
if not self.api_client:
|
|
161
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
response = await self.api_client.get(
|
|
165
|
+
"/refresh_access_token",
|
|
166
|
+
params={
|
|
167
|
+
"grant_type": "ig_refresh_token",
|
|
168
|
+
"access_token": self.credentials.access_token,
|
|
169
|
+
},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
data = response.data
|
|
173
|
+
self.credentials.access_token = data["access_token"]
|
|
174
|
+
# Instagram tokens typically expire in 60 days
|
|
175
|
+
self.credentials.expires_at = datetime.fromtimestamp(
|
|
176
|
+
datetime.now().timestamp() + data.get("expires_in", 5184000)
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
return self.credentials
|
|
180
|
+
|
|
181
|
+
except httpx.HTTPError as e:
|
|
182
|
+
raise PlatformAuthError(
|
|
183
|
+
f"Token refresh failed: {e}",
|
|
184
|
+
platform=self.platform_name,
|
|
185
|
+
) from e
|
|
186
|
+
|
|
187
|
+
async def is_authenticated(self) -> bool:
|
|
188
|
+
"""Check if Instagram credentials are valid.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
True if authenticated and token is valid.
|
|
192
|
+
"""
|
|
193
|
+
if not self.api_client:
|
|
194
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
# Verify credentials by fetching account info
|
|
198
|
+
await self.api_client.get(
|
|
199
|
+
f"/{self.instagram_account_id}",
|
|
200
|
+
params={
|
|
201
|
+
"fields": "id,username",
|
|
202
|
+
"access_token": self.credentials.access_token,
|
|
203
|
+
},
|
|
204
|
+
)
|
|
205
|
+
return True
|
|
206
|
+
except httpx.HTTPError:
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
# ==================== Post CRUD Methods ====================
|
|
210
|
+
|
|
211
|
+
async def create_post(self, request: PostCreateRequest) -> Post:
|
|
212
|
+
"""Create and publish an Instagram post.
|
|
213
|
+
|
|
214
|
+
Instagram requires a two-step process:
|
|
215
|
+
1. Create a media container
|
|
216
|
+
2. Publish the container
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
request: Post creation request.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
Created Post object.
|
|
223
|
+
|
|
224
|
+
Raises:
|
|
225
|
+
ValidationError: If request is invalid.
|
|
226
|
+
MediaUploadError: If media upload fails.
|
|
227
|
+
"""
|
|
228
|
+
if not self.api_client:
|
|
229
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
230
|
+
|
|
231
|
+
if not request.media_urls and not request.media_ids:
|
|
232
|
+
raise ValidationError(
|
|
233
|
+
"Instagram posts require at least one media attachment",
|
|
234
|
+
platform=self.platform_name,
|
|
235
|
+
field="media",
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Step 1: Create media container
|
|
239
|
+
container_params: dict[str, Any] = {
|
|
240
|
+
"access_token": self.credentials.access_token,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if request.media_urls:
|
|
244
|
+
container_params["image_url"] = request.media_urls[0]
|
|
245
|
+
|
|
246
|
+
if request.content:
|
|
247
|
+
container_params["caption"] = request.content
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
# Create container
|
|
251
|
+
container_response = await self.api_client.post(
|
|
252
|
+
f"/{self.instagram_account_id}/media",
|
|
253
|
+
data=container_params,
|
|
254
|
+
)
|
|
255
|
+
container_id = container_response.data["id"]
|
|
256
|
+
|
|
257
|
+
# Step 2: Publish container
|
|
258
|
+
publish_response = await self.api_client.post(
|
|
259
|
+
f"/{self.instagram_account_id}/media_publish",
|
|
260
|
+
data={
|
|
261
|
+
"creation_id": container_id,
|
|
262
|
+
"access_token": self.credentials.access_token,
|
|
263
|
+
},
|
|
264
|
+
)
|
|
265
|
+
post_id = publish_response.data["id"]
|
|
266
|
+
|
|
267
|
+
# Fetch full post details
|
|
268
|
+
return await self.get_post(post_id)
|
|
269
|
+
|
|
270
|
+
except httpx.HTTPError as e:
|
|
271
|
+
raise MediaUploadError(
|
|
272
|
+
f"Failed to create Instagram post: {e}",
|
|
273
|
+
platform=self.platform_name,
|
|
274
|
+
) from e
|
|
275
|
+
|
|
276
|
+
async def get_post(self, post_id: str) -> Post:
|
|
277
|
+
"""Retrieve an Instagram post by ID.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
post_id: Instagram media ID.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
Post object with current data.
|
|
284
|
+
|
|
285
|
+
Raises:
|
|
286
|
+
PostNotFoundError: If post doesn't exist.
|
|
287
|
+
"""
|
|
288
|
+
if not self.api_client:
|
|
289
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
response = await self.api_client.get(
|
|
293
|
+
f"/{post_id}",
|
|
294
|
+
params={
|
|
295
|
+
"fields": "id,caption,media_type,media_url,permalink,timestamp,like_count,comments_count",
|
|
296
|
+
"access_token": self.credentials.access_token,
|
|
297
|
+
},
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
data = response.data
|
|
301
|
+
return self._parse_post(data)
|
|
302
|
+
|
|
303
|
+
except httpx.HTTPStatusError as e:
|
|
304
|
+
if e.response.status_code == 404:
|
|
305
|
+
raise PostNotFoundError(
|
|
306
|
+
post_id=post_id,
|
|
307
|
+
platform=self.platform_name,
|
|
308
|
+
status_code=404,
|
|
309
|
+
) from e
|
|
310
|
+
raise PlatformError(
|
|
311
|
+
f"Failed to fetch post: {e}",
|
|
312
|
+
platform=self.platform_name,
|
|
313
|
+
) from e
|
|
314
|
+
except httpx.HTTPError as e:
|
|
315
|
+
raise PlatformError(
|
|
316
|
+
f"Failed to fetch post: {e}",
|
|
317
|
+
platform=self.platform_name,
|
|
318
|
+
) from e
|
|
319
|
+
|
|
320
|
+
async def update_post(
|
|
321
|
+
self,
|
|
322
|
+
post_id: str, # noqa: ARG002
|
|
323
|
+
request: PostUpdateRequest, # noqa: ARG002
|
|
324
|
+
) -> Post:
|
|
325
|
+
"""Update an Instagram post.
|
|
326
|
+
|
|
327
|
+
Note: Instagram does not support editing published posts. This method
|
|
328
|
+
will raise an error.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
post_id: Instagram media ID.
|
|
332
|
+
request: Post update request.
|
|
333
|
+
|
|
334
|
+
Raises:
|
|
335
|
+
PlatformError: Instagram doesn't support post editing.
|
|
336
|
+
"""
|
|
337
|
+
raise PlatformError(
|
|
338
|
+
"Instagram does not support editing published posts",
|
|
339
|
+
platform=self.platform_name,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
async def delete_post(self, post_id: str) -> bool:
|
|
343
|
+
"""Delete an Instagram post.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
post_id: Instagram media ID.
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
True if deletion was successful.
|
|
350
|
+
|
|
351
|
+
Raises:
|
|
352
|
+
PostNotFoundError: If post doesn't exist.
|
|
353
|
+
"""
|
|
354
|
+
if not self.api_client:
|
|
355
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
356
|
+
|
|
357
|
+
try:
|
|
358
|
+
await self.api_client.post(
|
|
359
|
+
f"/{post_id}",
|
|
360
|
+
data={
|
|
361
|
+
"access_token": self.credentials.access_token,
|
|
362
|
+
},
|
|
363
|
+
)
|
|
364
|
+
return True
|
|
365
|
+
|
|
366
|
+
except httpx.HTTPStatusError as e:
|
|
367
|
+
if e.response.status_code == 404:
|
|
368
|
+
raise PostNotFoundError(
|
|
369
|
+
post_id=post_id,
|
|
370
|
+
platform=self.platform_name,
|
|
371
|
+
status_code=404,
|
|
372
|
+
) from e
|
|
373
|
+
raise PlatformError(
|
|
374
|
+
f"Failed to delete post: {e}",
|
|
375
|
+
platform=self.platform_name,
|
|
376
|
+
) from e
|
|
377
|
+
except httpx.HTTPError as e:
|
|
378
|
+
raise PlatformError(
|
|
379
|
+
f"Failed to delete post: {e}",
|
|
380
|
+
platform=self.platform_name,
|
|
381
|
+
) from e
|
|
382
|
+
|
|
383
|
+
# ==================== Comment Methods ====================
|
|
384
|
+
|
|
385
|
+
async def get_comments(
|
|
386
|
+
self,
|
|
387
|
+
post_id: str,
|
|
388
|
+
limit: int = 50,
|
|
389
|
+
offset: int = 0, # noqa: ARG002
|
|
390
|
+
) -> list[Comment]:
|
|
391
|
+
"""Retrieve comments for an Instagram post.
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
post_id: Instagram media ID.
|
|
395
|
+
limit: Maximum number of comments to retrieve.
|
|
396
|
+
offset: Number of comments to skip.
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
List of Comment objects.
|
|
400
|
+
"""
|
|
401
|
+
if not self.api_client:
|
|
402
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
403
|
+
|
|
404
|
+
try:
|
|
405
|
+
response = await self.api_client.get(
|
|
406
|
+
f"/{post_id}/comments",
|
|
407
|
+
params={
|
|
408
|
+
"fields": "id,text,username,timestamp,like_count",
|
|
409
|
+
"access_token": self.credentials.access_token,
|
|
410
|
+
"limit": limit,
|
|
411
|
+
},
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
comments = []
|
|
415
|
+
for comment_data in response.data.get("data", []):
|
|
416
|
+
comments.append(self._parse_comment(comment_data, post_id))
|
|
417
|
+
|
|
418
|
+
return comments
|
|
419
|
+
|
|
420
|
+
except httpx.HTTPError as e:
|
|
421
|
+
raise PlatformError(
|
|
422
|
+
f"Failed to fetch comments: {e}",
|
|
423
|
+
platform=self.platform_name,
|
|
424
|
+
) from e
|
|
425
|
+
|
|
426
|
+
async def create_comment(self, post_id: str, content: str) -> Comment:
|
|
427
|
+
"""Add a comment to an Instagram post.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
post_id: Instagram media ID.
|
|
431
|
+
content: Text content of the comment.
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
Created Comment object.
|
|
435
|
+
|
|
436
|
+
Raises:
|
|
437
|
+
ValidationError: If comment content is invalid.
|
|
438
|
+
"""
|
|
439
|
+
if not self.api_client:
|
|
440
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
441
|
+
|
|
442
|
+
if not content or len(content) == 0:
|
|
443
|
+
raise ValidationError(
|
|
444
|
+
"Comment content cannot be empty",
|
|
445
|
+
platform=self.platform_name,
|
|
446
|
+
field="content",
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
try:
|
|
450
|
+
response = await self.api_client.post(
|
|
451
|
+
f"/{post_id}/comments",
|
|
452
|
+
data={
|
|
453
|
+
"message": content,
|
|
454
|
+
"access_token": self.credentials.access_token,
|
|
455
|
+
},
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
comment_id = response.data["id"]
|
|
459
|
+
|
|
460
|
+
# Fetch full comment details
|
|
461
|
+
comment_response = await self.api_client.get(
|
|
462
|
+
f"/{comment_id}",
|
|
463
|
+
params={
|
|
464
|
+
"fields": "id,text,username,timestamp,like_count",
|
|
465
|
+
"access_token": self.credentials.access_token,
|
|
466
|
+
},
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
return self._parse_comment(comment_response.data, post_id)
|
|
470
|
+
|
|
471
|
+
except httpx.HTTPError as e:
|
|
472
|
+
raise PlatformError(
|
|
473
|
+
f"Failed to create comment: {e}",
|
|
474
|
+
platform=self.platform_name,
|
|
475
|
+
) from e
|
|
476
|
+
|
|
477
|
+
async def delete_comment(self, comment_id: str) -> bool:
|
|
478
|
+
"""Delete an Instagram comment.
|
|
479
|
+
|
|
480
|
+
Args:
|
|
481
|
+
comment_id: Instagram comment ID.
|
|
482
|
+
|
|
483
|
+
Returns:
|
|
484
|
+
True if deletion was successful.
|
|
485
|
+
"""
|
|
486
|
+
if not self.api_client:
|
|
487
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
488
|
+
|
|
489
|
+
try:
|
|
490
|
+
await self.api_client.post(
|
|
491
|
+
f"/{comment_id}",
|
|
492
|
+
data={
|
|
493
|
+
"access_token": self.credentials.access_token,
|
|
494
|
+
},
|
|
495
|
+
)
|
|
496
|
+
return True
|
|
497
|
+
|
|
498
|
+
except httpx.HTTPError as e:
|
|
499
|
+
raise PlatformError(
|
|
500
|
+
f"Failed to delete comment: {e}",
|
|
501
|
+
platform=self.platform_name,
|
|
502
|
+
) from e
|
|
503
|
+
|
|
504
|
+
# ==================== Media Methods ====================
|
|
505
|
+
|
|
506
|
+
async def upload_media(
|
|
507
|
+
self,
|
|
508
|
+
media_url: str,
|
|
509
|
+
media_type: str,
|
|
510
|
+
alt_text: str | None = None,
|
|
511
|
+
) -> MediaAttachment:
|
|
512
|
+
"""Upload media to Instagram.
|
|
513
|
+
|
|
514
|
+
Note: Instagram requires media to be hosted on a publicly accessible
|
|
515
|
+
URL. This method creates a media container that can be used for
|
|
516
|
+
publishing.
|
|
517
|
+
|
|
518
|
+
Args:
|
|
519
|
+
media_url: Public URL of the media file.
|
|
520
|
+
media_type: Type of media (image or video).
|
|
521
|
+
alt_text: Alternative text for accessibility.
|
|
522
|
+
|
|
523
|
+
Returns:
|
|
524
|
+
MediaAttachment object with container ID.
|
|
525
|
+
|
|
526
|
+
Raises:
|
|
527
|
+
MediaUploadError: If upload fails.
|
|
528
|
+
"""
|
|
529
|
+
if not self.api_client:
|
|
530
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
531
|
+
|
|
532
|
+
params: dict[str, Any] = {
|
|
533
|
+
"access_token": self.credentials.access_token,
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if media_type.lower() == "image":
|
|
537
|
+
params["image_url"] = media_url
|
|
538
|
+
elif media_type.lower() == "video":
|
|
539
|
+
params["video_url"] = media_url
|
|
540
|
+
params["media_type"] = "VIDEO"
|
|
541
|
+
else:
|
|
542
|
+
raise ValidationError(
|
|
543
|
+
f"Unsupported media type: {media_type}",
|
|
544
|
+
platform=self.platform_name,
|
|
545
|
+
field="media_type",
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
try:
|
|
549
|
+
response = await self.api_client.post(
|
|
550
|
+
f"/{self.instagram_account_id}/media",
|
|
551
|
+
data=params,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
container_id = response.data["id"]
|
|
555
|
+
|
|
556
|
+
return MediaAttachment(
|
|
557
|
+
media_id=container_id,
|
|
558
|
+
media_type=(
|
|
559
|
+
MediaType.IMAGE
|
|
560
|
+
if media_type.lower() == "image"
|
|
561
|
+
else MediaType.VIDEO
|
|
562
|
+
),
|
|
563
|
+
url=cast(HttpUrl, media_url),
|
|
564
|
+
alt_text=alt_text,
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
except httpx.HTTPError as e:
|
|
568
|
+
raise MediaUploadError(
|
|
569
|
+
f"Failed to upload media: {e}",
|
|
570
|
+
platform=self.platform_name,
|
|
571
|
+
media_type=media_type,
|
|
572
|
+
) from e
|
|
573
|
+
|
|
574
|
+
async def create_carousel(
|
|
575
|
+
self,
|
|
576
|
+
media_urls: list[str],
|
|
577
|
+
caption: str | None = None,
|
|
578
|
+
*,
|
|
579
|
+
alt_texts: list[str] | None = None,
|
|
580
|
+
location_id: str | None = None,
|
|
581
|
+
) -> Post:
|
|
582
|
+
"""Create an Instagram carousel post (2-10 images).
|
|
583
|
+
|
|
584
|
+
Args:
|
|
585
|
+
media_urls: List of image URLs (2-10 items).
|
|
586
|
+
caption: Post caption.
|
|
587
|
+
alt_texts: Optional alt texts for each image.
|
|
588
|
+
location_id: Optional location ID.
|
|
589
|
+
|
|
590
|
+
Returns:
|
|
591
|
+
Published Post object.
|
|
592
|
+
|
|
593
|
+
Raises:
|
|
594
|
+
ValidationError: If inputs are invalid.
|
|
595
|
+
MediaUploadError: If creation fails.
|
|
596
|
+
RuntimeError: If client not used as context manager.
|
|
597
|
+
|
|
598
|
+
Example:
|
|
599
|
+
>>> async with InstagramClient(credentials) as client:
|
|
600
|
+
... post = await client.create_carousel(
|
|
601
|
+
... media_urls=[
|
|
602
|
+
... "https://example.com/img1.jpg",
|
|
603
|
+
... "https://example.com/img2.jpg",
|
|
604
|
+
... ],
|
|
605
|
+
... caption="Beautiful carousel post!"
|
|
606
|
+
... )
|
|
607
|
+
"""
|
|
608
|
+
if not self._media_manager:
|
|
609
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
610
|
+
|
|
611
|
+
# Convert to MediaItem objects
|
|
612
|
+
media_items = []
|
|
613
|
+
for idx, url in enumerate(media_urls):
|
|
614
|
+
alt_text = None
|
|
615
|
+
if alt_texts and idx < len(alt_texts):
|
|
616
|
+
alt_text = alt_texts[idx]
|
|
617
|
+
media_items.append(MediaItem(url=url, type="image", alt_text=alt_text))
|
|
618
|
+
|
|
619
|
+
# Create containers
|
|
620
|
+
container_ids = await self._media_manager.create_feed_containers(
|
|
621
|
+
media_items,
|
|
622
|
+
caption=caption,
|
|
623
|
+
location_id=location_id,
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
# Publish
|
|
627
|
+
result = await self._media_manager.publish_container(container_ids[0])
|
|
628
|
+
|
|
629
|
+
# Fetch and return post
|
|
630
|
+
return await self.get_post(result.media_id)
|
|
631
|
+
|
|
632
|
+
async def create_reel(
|
|
633
|
+
self,
|
|
634
|
+
video_url: str,
|
|
635
|
+
caption: str | None = None,
|
|
636
|
+
*,
|
|
637
|
+
cover_url: str | None = None,
|
|
638
|
+
share_to_feed: bool = True,
|
|
639
|
+
) -> Post:
|
|
640
|
+
"""Create an Instagram Reel (video).
|
|
641
|
+
|
|
642
|
+
Args:
|
|
643
|
+
video_url: URL of video file.
|
|
644
|
+
caption: Reel caption.
|
|
645
|
+
cover_url: Optional thumbnail image URL.
|
|
646
|
+
share_to_feed: Share reel to main feed.
|
|
647
|
+
|
|
648
|
+
Returns:
|
|
649
|
+
Published Post object.
|
|
650
|
+
|
|
651
|
+
Raises:
|
|
652
|
+
MediaUploadError: If creation fails.
|
|
653
|
+
RuntimeError: If client not used as context manager.
|
|
654
|
+
|
|
655
|
+
Example:
|
|
656
|
+
>>> async with InstagramClient(credentials) as client:
|
|
657
|
+
... reel = await client.create_reel(
|
|
658
|
+
... video_url="https://example.com/video.mp4",
|
|
659
|
+
... caption="Check out this reel!",
|
|
660
|
+
... share_to_feed=True
|
|
661
|
+
... )
|
|
662
|
+
"""
|
|
663
|
+
if not self._media_manager:
|
|
664
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
665
|
+
|
|
666
|
+
# Create reel container
|
|
667
|
+
container_id = await self._media_manager.create_reel_container(
|
|
668
|
+
video_url,
|
|
669
|
+
caption=caption,
|
|
670
|
+
cover_url=cover_url,
|
|
671
|
+
share_to_feed=share_to_feed,
|
|
672
|
+
wait_for_processing=True,
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
# Publish
|
|
676
|
+
result = await self._media_manager.publish_container(container_id)
|
|
677
|
+
|
|
678
|
+
# Fetch and return post
|
|
679
|
+
return await self.get_post(result.media_id)
|
|
680
|
+
|
|
681
|
+
async def create_story(
|
|
682
|
+
self,
|
|
683
|
+
media_url: str,
|
|
684
|
+
media_type: Literal["image", "video"],
|
|
685
|
+
) -> Post:
|
|
686
|
+
"""Create an Instagram Story.
|
|
687
|
+
|
|
688
|
+
Args:
|
|
689
|
+
media_url: URL of media file.
|
|
690
|
+
media_type: Type of media ("image" or "video").
|
|
691
|
+
|
|
692
|
+
Returns:
|
|
693
|
+
Published Post object.
|
|
694
|
+
|
|
695
|
+
Raises:
|
|
696
|
+
MediaUploadError: If creation fails.
|
|
697
|
+
RuntimeError: If client not used as context manager.
|
|
698
|
+
|
|
699
|
+
Example:
|
|
700
|
+
>>> async with InstagramClient(credentials) as client:
|
|
701
|
+
... story = await client.create_story(
|
|
702
|
+
... media_url="https://example.com/story.jpg",
|
|
703
|
+
... media_type="image"
|
|
704
|
+
... )
|
|
705
|
+
"""
|
|
706
|
+
if not self._media_manager:
|
|
707
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
708
|
+
|
|
709
|
+
# Create story container
|
|
710
|
+
container_id = await self._media_manager.create_story_container(
|
|
711
|
+
media_url,
|
|
712
|
+
media_type,
|
|
713
|
+
wait_for_processing=(media_type == "video"),
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
# Publish
|
|
717
|
+
result = await self._media_manager.publish_container(container_id)
|
|
718
|
+
|
|
719
|
+
# Fetch and return post
|
|
720
|
+
return await self.get_post(result.media_id)
|
|
721
|
+
|
|
722
|
+
# ==================== Helper Methods ====================
|
|
723
|
+
|
|
724
|
+
def _parse_post(self, data: dict[str, Any]) -> Post:
|
|
725
|
+
"""Parse Instagram API response into Post model.
|
|
726
|
+
|
|
727
|
+
Args:
|
|
728
|
+
data: Raw API response data.
|
|
729
|
+
|
|
730
|
+
Returns:
|
|
731
|
+
Post object.
|
|
732
|
+
"""
|
|
733
|
+
media_type_map = {
|
|
734
|
+
"IMAGE": MediaType.IMAGE,
|
|
735
|
+
"VIDEO": MediaType.VIDEO,
|
|
736
|
+
"CAROUSEL_ALBUM": MediaType.CAROUSEL,
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
media = []
|
|
740
|
+
if data.get("media_url"):
|
|
741
|
+
media.append(
|
|
742
|
+
MediaAttachment(
|
|
743
|
+
media_id=data["id"],
|
|
744
|
+
media_type=media_type_map.get(
|
|
745
|
+
data.get("media_type", "IMAGE"),
|
|
746
|
+
MediaType.IMAGE,
|
|
747
|
+
),
|
|
748
|
+
url=data["media_url"],
|
|
749
|
+
)
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
return Post(
|
|
753
|
+
post_id=data["id"],
|
|
754
|
+
platform=self.platform_name,
|
|
755
|
+
content=data.get("caption"),
|
|
756
|
+
media=media,
|
|
757
|
+
status=PostStatus.PUBLISHED,
|
|
758
|
+
url=data.get("permalink"),
|
|
759
|
+
created_at=datetime.fromisoformat(data["timestamp"].replace("Z", "+00:00")),
|
|
760
|
+
likes_count=data.get("like_count", 0),
|
|
761
|
+
comments_count=data.get("comments_count", 0),
|
|
762
|
+
raw_data=data,
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
def _parse_comment(self, data: dict[str, Any], post_id: str) -> Comment:
|
|
766
|
+
"""Parse Instagram API response into Comment model.
|
|
767
|
+
|
|
768
|
+
Args:
|
|
769
|
+
data: Raw API response data.
|
|
770
|
+
post_id: ID of the post this comment belongs to.
|
|
771
|
+
|
|
772
|
+
Returns:
|
|
773
|
+
Comment object.
|
|
774
|
+
"""
|
|
775
|
+
return Comment(
|
|
776
|
+
comment_id=data["id"],
|
|
777
|
+
post_id=post_id,
|
|
778
|
+
platform=self.platform_name,
|
|
779
|
+
content=data.get("text", ""),
|
|
780
|
+
author_username=data.get("username"),
|
|
781
|
+
author_id=data.get("user", {}).get("id", ""),
|
|
782
|
+
created_at=datetime.fromisoformat(data["timestamp"].replace("Z", "+00:00")),
|
|
783
|
+
likes_count=data.get("like_count", 0),
|
|
784
|
+
status=CommentStatus.VISIBLE,
|
|
785
|
+
raw_data=data,
|
|
786
|
+
)
|