upload-post 0.1.1__py3-none-any.whl → 2.0.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.
upload_post/api_client.py CHANGED
@@ -1,6 +1,12 @@
1
- from pathlib import Path
2
- from typing import Dict, List, Union
1
+ """
2
+ Upload-Post API Client
3
+
4
+ Cross-platform social media upload for TikTok, Instagram, YouTube, LinkedIn,
5
+ Facebook, Pinterest, Threads, Reddit, Bluesky, and X (Twitter).
6
+ """
3
7
 
8
+ from pathlib import Path
9
+ from typing import Dict, List, Union, Optional, Any, Literal
4
10
  import requests
5
11
 
6
12
 
@@ -8,65 +14,932 @@ class UploadPostError(Exception):
8
14
  """Base exception for Upload-Post API errors"""
9
15
  pass
10
16
 
17
+
11
18
  class UploadPostClient:
19
+ """
20
+ Upload-Post API Client
21
+
22
+ Supports uploading to: TikTok, Instagram, YouTube, LinkedIn, Facebook,
23
+ Pinterest, Threads, Reddit, Bluesky, X (Twitter)
24
+
25
+ Example:
26
+ >>> client = UploadPostClient("YOUR_API_KEY")
27
+ >>> response = client.upload_video(
28
+ ... "video.mp4",
29
+ ... title="My awesome video",
30
+ ... user="my-profile",
31
+ ... platforms=["tiktok", "instagram"]
32
+ ... )
33
+ """
34
+
12
35
  BASE_URL = "https://api.upload-post.com/api"
13
36
 
14
37
  def __init__(self, api_key: str):
38
+ """
39
+ Initialize the Upload-Post client.
40
+
41
+ Args:
42
+ api_key: Your API key from Upload-Post
43
+ """
15
44
  self.api_key = api_key
16
45
  self.session = requests.Session()
17
46
  self.session.headers.update({
18
47
  "Authorization": f"Apikey {self.api_key}",
19
- "User-Agent": f"upload-post-python-client/0.1.0"
48
+ "User-Agent": "upload-post-python-client/2.0.0"
20
49
  })
21
50
 
51
+ def _request(
52
+ self,
53
+ endpoint: str,
54
+ method: str = "GET",
55
+ data: Optional[List[tuple]] = None,
56
+ files: Optional[List[tuple]] = None,
57
+ json_data: Optional[Dict] = None,
58
+ params: Optional[Dict] = None
59
+ ) -> Dict:
60
+ """Make an API request."""
61
+ url = f"{self.BASE_URL}{endpoint}"
62
+
63
+ try:
64
+ if method == "GET":
65
+ response = self.session.get(url, params=params)
66
+ elif method == "POST":
67
+ if json_data:
68
+ response = self.session.post(url, json=json_data)
69
+ else:
70
+ response = self.session.post(url, data=data, files=files)
71
+ elif method == "DELETE":
72
+ if json_data:
73
+ response = self.session.delete(url, json=json_data)
74
+ else:
75
+ response = self.session.delete(url)
76
+ else:
77
+ raise UploadPostError(f"Unsupported HTTP method: {method}")
78
+
79
+ response.raise_for_status()
80
+ return response.json()
81
+
82
+ except requests.exceptions.RequestException as e:
83
+ error_msg = str(e)
84
+ if hasattr(e, 'response') and e.response is not None:
85
+ try:
86
+ error_data = e.response.json()
87
+ error_msg = error_data.get('message') or error_data.get('detail') or str(error_data)
88
+ except:
89
+ pass
90
+ raise UploadPostError(f"API request failed: {error_msg}") from e
91
+
92
+ def _add_common_params(
93
+ self,
94
+ data: List[tuple],
95
+ user: str,
96
+ title: str,
97
+ platforms: List[str],
98
+ first_comment: Optional[str] = None,
99
+ alt_text: Optional[str] = None,
100
+ scheduled_date: Optional[str] = None,
101
+ timezone: Optional[str] = None,
102
+ add_to_queue: Optional[bool] = None,
103
+ async_upload: Optional[bool] = None,
104
+ **kwargs
105
+ ):
106
+ """Add common upload parameters."""
107
+ data.append(("user", user))
108
+ data.append(("title", title))
109
+ for p in platforms:
110
+ data.append(("platform[]", p))
111
+
112
+ if first_comment:
113
+ data.append(("first_comment", first_comment))
114
+ if alt_text:
115
+ data.append(("alt_text", alt_text))
116
+ if scheduled_date:
117
+ data.append(("scheduled_date", scheduled_date))
118
+ if timezone:
119
+ data.append(("timezone", timezone))
120
+ if add_to_queue is not None:
121
+ data.append(("add_to_queue", str(add_to_queue).lower()))
122
+ if async_upload is not None:
123
+ data.append(("async_upload", str(async_upload).lower()))
124
+
125
+ # Platform-specific title overrides
126
+ title_overrides = [
127
+ "bluesky_title", "instagram_title", "facebook_title", "tiktok_title",
128
+ "linkedin_title", "x_title", "youtube_title", "pinterest_title", "threads_title"
129
+ ]
130
+ for key in title_overrides:
131
+ if kwargs.get(key):
132
+ data.append((key, kwargs[key]))
133
+
134
+ # Platform-specific description overrides
135
+ desc_overrides = [
136
+ "description", "linkedin_description", "youtube_description",
137
+ "facebook_description", "tiktok_description", "pinterest_description"
138
+ ]
139
+ for key in desc_overrides:
140
+ if kwargs.get(key):
141
+ data.append((key, kwargs[key]))
142
+
143
+ # Platform-specific first comment overrides
144
+ comment_overrides = [
145
+ "instagram_first_comment", "facebook_first_comment", "x_first_comment",
146
+ "threads_first_comment", "youtube_first_comment", "reddit_first_comment",
147
+ "bluesky_first_comment"
148
+ ]
149
+ for key in comment_overrides:
150
+ if kwargs.get(key):
151
+ data.append((key, kwargs[key]))
152
+
153
+ def _add_tiktok_params(self, data: List[tuple], is_video: bool = True, **kwargs):
154
+ """Add TikTok-specific parameters."""
155
+ if kwargs.get("disable_comment") is not None:
156
+ data.append(("disable_comment", str(kwargs["disable_comment"]).lower()))
157
+ if kwargs.get("brand_content_toggle") is not None:
158
+ data.append(("brand_content_toggle", str(kwargs["brand_content_toggle"]).lower()))
159
+ if kwargs.get("brand_organic_toggle") is not None:
160
+ data.append(("brand_organic_toggle", str(kwargs["brand_organic_toggle"]).lower()))
161
+
162
+ if is_video:
163
+ if kwargs.get("privacy_level"):
164
+ data.append(("privacy_level", kwargs["privacy_level"]))
165
+ if kwargs.get("disable_duet") is not None:
166
+ data.append(("disable_duet", str(kwargs["disable_duet"]).lower()))
167
+ if kwargs.get("disable_stitch") is not None:
168
+ data.append(("disable_stitch", str(kwargs["disable_stitch"]).lower()))
169
+ if kwargs.get("cover_timestamp") is not None:
170
+ data.append(("cover_timestamp", str(kwargs["cover_timestamp"])))
171
+ if kwargs.get("is_aigc") is not None:
172
+ data.append(("is_aigc", str(kwargs["is_aigc"]).lower()))
173
+ if kwargs.get("post_mode"):
174
+ data.append(("post_mode", kwargs["post_mode"]))
175
+ else:
176
+ if kwargs.get("auto_add_music") is not None:
177
+ data.append(("auto_add_music", str(kwargs["auto_add_music"]).lower()))
178
+ if kwargs.get("photo_cover_index") is not None:
179
+ data.append(("photo_cover_index", str(kwargs["photo_cover_index"])))
180
+
181
+ def _add_instagram_params(self, data: List[tuple], is_video: bool = True, **kwargs):
182
+ """Add Instagram-specific parameters."""
183
+ if kwargs.get("media_type"):
184
+ data.append(("media_type", kwargs["media_type"]))
185
+ if kwargs.get("collaborators"):
186
+ data.append(("collaborators", kwargs["collaborators"]))
187
+ if kwargs.get("user_tags"):
188
+ data.append(("user_tags", kwargs["user_tags"]))
189
+ if kwargs.get("location_id"):
190
+ data.append(("location_id", kwargs["location_id"]))
191
+
192
+ if is_video:
193
+ if kwargs.get("share_to_feed") is not None:
194
+ data.append(("share_to_feed", str(kwargs["share_to_feed"]).lower()))
195
+ if kwargs.get("cover_url"):
196
+ data.append(("cover_url", kwargs["cover_url"]))
197
+ if kwargs.get("audio_name"):
198
+ data.append(("audio_name", kwargs["audio_name"]))
199
+ if kwargs.get("thumb_offset"):
200
+ data.append(("thumb_offset", kwargs["thumb_offset"]))
201
+
202
+ def _add_youtube_params(self, data: List[tuple], **kwargs):
203
+ """Add YouTube-specific parameters."""
204
+ if kwargs.get("tags"):
205
+ tags = kwargs["tags"]
206
+ if isinstance(tags, str):
207
+ tags = [t.strip() for t in tags.split(",")]
208
+ for tag in tags:
209
+ data.append(("tags[]", tag))
210
+ if kwargs.get("categoryId"):
211
+ data.append(("categoryId", kwargs["categoryId"]))
212
+ if kwargs.get("privacyStatus"):
213
+ data.append(("privacyStatus", kwargs["privacyStatus"]))
214
+ if kwargs.get("embeddable") is not None:
215
+ data.append(("embeddable", str(kwargs["embeddable"]).lower()))
216
+ if kwargs.get("license"):
217
+ data.append(("license", kwargs["license"]))
218
+ if kwargs.get("publicStatsViewable") is not None:
219
+ data.append(("publicStatsViewable", str(kwargs["publicStatsViewable"]).lower()))
220
+ if kwargs.get("thumbnail_url"):
221
+ data.append(("thumbnail_url", kwargs["thumbnail_url"]))
222
+ if kwargs.get("selfDeclaredMadeForKids") is not None:
223
+ data.append(("selfDeclaredMadeForKids", str(kwargs["selfDeclaredMadeForKids"]).lower()))
224
+ if kwargs.get("containsSyntheticMedia") is not None:
225
+ data.append(("containsSyntheticMedia", str(kwargs["containsSyntheticMedia"]).lower()))
226
+ if kwargs.get("defaultLanguage"):
227
+ data.append(("defaultLanguage", kwargs["defaultLanguage"]))
228
+ if kwargs.get("defaultAudioLanguage"):
229
+ data.append(("defaultAudioLanguage", kwargs["defaultAudioLanguage"]))
230
+ if kwargs.get("allowedCountries"):
231
+ data.append(("allowedCountries", kwargs["allowedCountries"]))
232
+ if kwargs.get("blockedCountries"):
233
+ data.append(("blockedCountries", kwargs["blockedCountries"]))
234
+ if kwargs.get("hasPaidProductPlacement") is not None:
235
+ data.append(("hasPaidProductPlacement", str(kwargs["hasPaidProductPlacement"]).lower()))
236
+ if kwargs.get("recordingDate"):
237
+ data.append(("recordingDate", kwargs["recordingDate"]))
238
+
239
+ def _add_linkedin_params(self, data: List[tuple], **kwargs):
240
+ """Add LinkedIn-specific parameters."""
241
+ if kwargs.get("visibility"):
242
+ data.append(("visibility", kwargs["visibility"]))
243
+ if kwargs.get("target_linkedin_page_id"):
244
+ data.append(("target_linkedin_page_id", kwargs["target_linkedin_page_id"]))
245
+
246
+ def _add_facebook_params(self, data: List[tuple], is_video: bool = False, is_text: bool = False, **kwargs):
247
+ """Add Facebook-specific parameters."""
248
+ if kwargs.get("facebook_page_id"):
249
+ data.append(("facebook_page_id", kwargs["facebook_page_id"]))
250
+
251
+ if is_video:
252
+ if kwargs.get("video_state"):
253
+ data.append(("video_state", kwargs["video_state"]))
254
+ if kwargs.get("facebook_media_type"):
255
+ data.append(("facebook_media_type", kwargs["facebook_media_type"]))
256
+
257
+ if is_text and kwargs.get("facebook_link_url"):
258
+ data.append(("facebook_link_url", kwargs["facebook_link_url"]))
259
+
260
+ def _add_pinterest_params(self, data: List[tuple], is_video: bool = False, **kwargs):
261
+ """Add Pinterest-specific parameters."""
262
+ if kwargs.get("pinterest_board_id"):
263
+ data.append(("pinterest_board_id", kwargs["pinterest_board_id"]))
264
+ if kwargs.get("pinterest_alt_text"):
265
+ data.append(("pinterest_alt_text", kwargs["pinterest_alt_text"]))
266
+ if kwargs.get("pinterest_link"):
267
+ data.append(("pinterest_link", kwargs["pinterest_link"]))
268
+
269
+ if is_video:
270
+ if kwargs.get("pinterest_cover_image_url"):
271
+ data.append(("pinterest_cover_image_url", kwargs["pinterest_cover_image_url"]))
272
+ if kwargs.get("pinterest_cover_image_content_type"):
273
+ data.append(("pinterest_cover_image_content_type", kwargs["pinterest_cover_image_content_type"]))
274
+ if kwargs.get("pinterest_cover_image_data"):
275
+ data.append(("pinterest_cover_image_data", kwargs["pinterest_cover_image_data"]))
276
+ if kwargs.get("pinterest_cover_image_key_frame_time") is not None:
277
+ data.append(("pinterest_cover_image_key_frame_time", str(kwargs["pinterest_cover_image_key_frame_time"])))
278
+
279
+ def _add_x_params(self, data: List[tuple], is_text: bool = False, **kwargs):
280
+ """Add X (Twitter) specific parameters."""
281
+ reply_settings = kwargs.get("reply_settings")
282
+ if reply_settings and reply_settings != "everyone":
283
+ data.append(("reply_settings", reply_settings))
284
+ if kwargs.get("nullcast") is not None:
285
+ data.append(("nullcast", str(kwargs["nullcast"]).lower()))
286
+ if kwargs.get("quote_tweet_id"):
287
+ data.append(("quote_tweet_id", kwargs["quote_tweet_id"]))
288
+ if kwargs.get("geo_place_id"):
289
+ data.append(("geo_place_id", kwargs["geo_place_id"]))
290
+ if kwargs.get("for_super_followers_only") is not None:
291
+ data.append(("for_super_followers_only", str(kwargs["for_super_followers_only"]).lower()))
292
+ if kwargs.get("community_id"):
293
+ data.append(("community_id", kwargs["community_id"]))
294
+ if kwargs.get("share_with_followers") is not None:
295
+ data.append(("share_with_followers", str(kwargs["share_with_followers"]).lower()))
296
+ if kwargs.get("direct_message_deep_link"):
297
+ data.append(("direct_message_deep_link", kwargs["direct_message_deep_link"]))
298
+ if kwargs.get("x_long_text_as_post") is not None:
299
+ data.append(("x_long_text_as_post", str(kwargs["x_long_text_as_post"]).lower()))
300
+
301
+ if not is_text:
302
+ if kwargs.get("tagged_user_ids"):
303
+ ids = kwargs["tagged_user_ids"]
304
+ if isinstance(ids, str):
305
+ ids = [t.strip() for t in ids.split(",")]
306
+ for uid in ids:
307
+ data.append(("tagged_user_ids[]", uid))
308
+ if kwargs.get("place_id"):
309
+ data.append(("place_id", kwargs["place_id"]))
310
+ else:
311
+ if kwargs.get("post_url"):
312
+ data.append(("post_url", kwargs["post_url"]))
313
+ if kwargs.get("card_uri"):
314
+ data.append(("card_uri", kwargs["card_uri"]))
315
+
316
+ if kwargs.get("poll_options"):
317
+ poll_opts = kwargs["poll_options"]
318
+ if isinstance(poll_opts, str):
319
+ poll_opts = [o.strip() for o in poll_opts.split(",")]
320
+ for opt in poll_opts:
321
+ data.append(("poll_options[]", opt))
322
+ if kwargs.get("poll_duration"):
323
+ data.append(("poll_duration", str(kwargs["poll_duration"])))
324
+ if kwargs.get("poll_reply_settings"):
325
+ data.append(("poll_reply_settings", kwargs["poll_reply_settings"]))
326
+
327
+ def _add_threads_params(self, data: List[tuple], **kwargs):
328
+ """Add Threads-specific parameters."""
329
+ if kwargs.get("threads_long_text_as_post") is not None:
330
+ data.append(("threads_long_text_as_post", str(kwargs["threads_long_text_as_post"]).lower()))
331
+
332
+ def _add_reddit_params(self, data: List[tuple], **kwargs):
333
+ """Add Reddit-specific parameters."""
334
+ if kwargs.get("subreddit"):
335
+ data.append(("subreddit", kwargs["subreddit"]))
336
+ if kwargs.get("flair_id"):
337
+ data.append(("flair_id", kwargs["flair_id"]))
338
+
22
339
  def upload_video(
23
340
  self,
24
341
  video_path: Union[str, Path],
25
342
  title: str,
26
343
  user: str,
27
- platforms: List[str]
344
+ platforms: List[str],
345
+ **kwargs
28
346
  ) -> Dict:
29
347
  """
30
- Upload a video to specified social media platforms
348
+ Upload a video to social media platforms.
349
+
350
+ Args:
351
+ video_path: Path to video file or video URL.
352
+ title: Video title/caption.
353
+ user: User identifier (profile name).
354
+ platforms: Target platforms. Supported: tiktok, instagram, youtube,
355
+ linkedin, facebook, pinterest, threads, bluesky, x
356
+
357
+ Keyword Args:
358
+ description: Video description
359
+ first_comment: First comment to post
360
+ scheduled_date: ISO date for scheduling (e.g., "2024-12-25T10:00:00Z")
361
+ timezone: Timezone for scheduled date (e.g., "Europe/Madrid")
362
+ add_to_queue: Add to posting queue
363
+ async_upload: Process asynchronously (default: True)
364
+
365
+ TikTok:
366
+ privacy_level: PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS,
367
+ FOLLOWER_OF_CREATOR, SELF_ONLY
368
+ disable_duet: Disable duet
369
+ disable_comment: Disable comments
370
+ disable_stitch: Disable stitch
371
+ cover_timestamp: Timestamp in ms for cover
372
+ is_aigc: AI-generated content flag
373
+ post_mode: DIRECT_POST or MEDIA_UPLOAD
374
+ brand_content_toggle: Branded content toggle
375
+ brand_organic_toggle: Brand organic toggle
376
+
377
+ Instagram:
378
+ media_type: REELS or STORIES
379
+ share_to_feed: Share to feed
380
+ collaborators: Comma-separated collaborator usernames
381
+ cover_url: Custom cover URL
382
+ audio_name: Audio track name
383
+ user_tags: Comma-separated user tags
384
+ location_id: Location ID
385
+ thumb_offset: Thumbnail offset
386
+
387
+ YouTube:
388
+ tags: List or comma-separated tags
389
+ categoryId: Category ID (default: "22")
390
+ privacyStatus: public, unlisted, private
391
+ embeddable: Allow embedding
392
+ license: youtube, creativeCommon
393
+ publicStatsViewable: Show public stats
394
+ thumbnail_url: Custom thumbnail URL
395
+ selfDeclaredMadeForKids: Made for kids (COPPA)
396
+ containsSyntheticMedia: AI/synthetic content flag
397
+ defaultLanguage: Title/description language (BCP-47)
398
+ defaultAudioLanguage: Audio language (BCP-47)
399
+ allowedCountries: Comma-separated country codes
400
+ blockedCountries: Comma-separated country codes
401
+ hasPaidProductPlacement: Paid placement flag
402
+ recordingDate: Recording date (ISO 8601)
403
+
404
+ LinkedIn:
405
+ visibility: PUBLIC, CONNECTIONS, LOGGED_IN, CONTAINER
406
+ target_linkedin_page_id: Page ID for organization posts
407
+
408
+ Facebook:
409
+ facebook_page_id: Facebook Page ID
410
+ video_state: PUBLISHED or DRAFT
411
+ facebook_media_type: REELS or STORIES
412
+
413
+ Pinterest:
414
+ pinterest_board_id: Board ID
415
+ pinterest_link: Destination link
416
+ pinterest_cover_image_url: Cover image URL
417
+ pinterest_cover_image_key_frame_time: Key frame time in ms
418
+
419
+ X (Twitter):
420
+ reply_settings: everyone, following, mentionedUsers, subscribers, verified
421
+ nullcast: Promoted-only post
422
+ tagged_user_ids: User IDs to tag
423
+ place_id: Location place ID
424
+ geo_place_id: Geographic place ID
425
+ for_super_followers_only: Exclusive for super followers
426
+ community_id: Community ID
427
+ share_with_followers: Share community post with followers
428
+ x_long_text_as_post: Post long text as single post
429
+
430
+ Threads:
431
+ threads_long_text_as_post: Post long text as single post
432
+
433
+ Returns:
434
+ API response with request_id for async uploads.
435
+
436
+ Raises:
437
+ UploadPostError: If upload fails or video file not found.
438
+ """
439
+ data: List[tuple] = []
440
+ files: List[tuple] = []
441
+ video_file = None
31
442
 
443
+ try:
444
+ video_str = str(video_path)
445
+ if video_str.lower().startswith(("http://", "https://")):
446
+ data.append(("video", video_str))
447
+ else:
448
+ video_p = Path(video_path)
449
+ if not video_p.exists():
450
+ raise UploadPostError(f"Video file not found: {video_p}")
451
+ video_file = video_p.open("rb")
452
+ files.append(("video", (video_p.name, video_file)))
453
+
454
+ self._add_common_params(data, user, title, platforms, **kwargs)
455
+
456
+ if "tiktok" in platforms:
457
+ self._add_tiktok_params(data, is_video=True, **kwargs)
458
+ if "instagram" in platforms:
459
+ self._add_instagram_params(data, is_video=True, **kwargs)
460
+ if "youtube" in platforms:
461
+ self._add_youtube_params(data, **kwargs)
462
+ if "linkedin" in platforms:
463
+ self._add_linkedin_params(data, **kwargs)
464
+ if "facebook" in platforms:
465
+ self._add_facebook_params(data, is_video=True, **kwargs)
466
+ if "pinterest" in platforms:
467
+ self._add_pinterest_params(data, is_video=True, **kwargs)
468
+ if "x" in platforms:
469
+ self._add_x_params(data, is_text=False, **kwargs)
470
+ if "threads" in platforms:
471
+ self._add_threads_params(data, **kwargs)
472
+
473
+ return self._request("/upload", "POST", data=data, files=files if files else None)
474
+
475
+ finally:
476
+ if video_file:
477
+ video_file.close()
478
+
479
+ def upload_photos(
480
+ self,
481
+ photos: List[Union[str, Path]],
482
+ title: str,
483
+ user: str,
484
+ platforms: List[str],
485
+ **kwargs
486
+ ) -> Dict:
487
+ """
488
+ Upload photos to social media platforms.
489
+
32
490
  Args:
33
- video_path: Path to video file
34
- title: Video title
35
- user: User identifier
36
- platforms: List of platforms (e.g. ["tiktok", "instagram"])
491
+ photos: List of photo file paths or URLs.
492
+ title: Post title/caption.
493
+ user: User identifier (profile name).
494
+ platforms: Target platforms. Supported: tiktok, instagram, linkedin,
495
+ facebook, pinterest, threads, reddit, bluesky, x
496
+
497
+ Keyword Args:
498
+ description: Photo description
499
+ first_comment: First comment to post
500
+ alt_text: Alt text for accessibility
501
+ scheduled_date: ISO date for scheduling
502
+ timezone: Timezone for scheduled date
503
+ add_to_queue: Add to posting queue
504
+ async_upload: Process asynchronously
37
505
 
506
+ TikTok:
507
+ auto_add_music: Auto add music
508
+ disable_comment: Disable comments
509
+ photo_cover_index: Index of photo for cover (0-based)
510
+ brand_content_toggle: Branded content toggle
511
+ brand_organic_toggle: Brand organic toggle
512
+
513
+ Instagram:
514
+ media_type: IMAGE or STORIES
515
+ collaborators: Comma-separated collaborator usernames
516
+ user_tags: Comma-separated user tags
517
+ location_id: Location ID
518
+
519
+ LinkedIn:
520
+ visibility: PUBLIC (only PUBLIC supported for photos)
521
+ target_linkedin_page_id: Page ID for organization posts
522
+
523
+ Facebook:
524
+ facebook_page_id: Facebook Page ID
525
+
526
+ Pinterest:
527
+ pinterest_board_id: Board ID
528
+ pinterest_alt_text: Alt text
529
+ pinterest_link: Destination link
530
+
531
+ X (Twitter):
532
+ reply_settings: Who can reply
533
+ nullcast: Promoted-only post
534
+ tagged_user_ids: User IDs to tag
535
+ x_long_text_as_post: Post long text as single post
536
+
537
+ Threads:
538
+ threads_long_text_as_post: Post long text as single post
539
+
540
+ Reddit:
541
+ subreddit: Subreddit name (without r/)
542
+ flair_id: Flair template ID
543
+
38
544
  Returns:
39
- API response JSON
545
+ API response.
546
+
547
+ Raises:
548
+ UploadPostError: If upload fails or photo file not found.
549
+ """
550
+ data: List[tuple] = []
551
+ files: List[tuple] = []
552
+ opened_files: List = []
553
+
554
+ try:
555
+ for photo in photos:
556
+ photo_str = str(photo)
557
+ if photo_str.lower().startswith(("http://", "https://")):
558
+ data.append(("photos[]", photo_str))
559
+ else:
560
+ photo_p = Path(photo)
561
+ if not photo_p.exists():
562
+ raise UploadPostError(f"Photo file not found: {photo_p}")
563
+ photo_file = photo_p.open("rb")
564
+ opened_files.append(photo_file)
565
+ files.append(("photos[]", (photo_p.name, photo_file)))
566
+
567
+ self._add_common_params(data, user, title, platforms, **kwargs)
40
568
 
569
+ if "tiktok" in platforms:
570
+ self._add_tiktok_params(data, is_video=False, **kwargs)
571
+ if "instagram" in platforms:
572
+ self._add_instagram_params(data, is_video=False, **kwargs)
573
+ if "linkedin" in platforms:
574
+ self._add_linkedin_params(data, **kwargs)
575
+ if "facebook" in platforms:
576
+ self._add_facebook_params(data, is_video=False, **kwargs)
577
+ if "pinterest" in platforms:
578
+ self._add_pinterest_params(data, is_video=False, **kwargs)
579
+ if "x" in platforms:
580
+ self._add_x_params(data, is_text=False, **kwargs)
581
+ if "threads" in platforms:
582
+ self._add_threads_params(data, **kwargs)
583
+ if "reddit" in platforms:
584
+ self._add_reddit_params(data, **kwargs)
585
+
586
+ return self._request("/upload_photos", "POST", data=data, files=files if files else None)
587
+
588
+ finally:
589
+ for f in opened_files:
590
+ f.close()
591
+
592
+ def upload_text(
593
+ self,
594
+ title: str,
595
+ user: str,
596
+ platforms: List[str],
597
+ **kwargs
598
+ ) -> Dict:
599
+ """
600
+ Upload text posts to social media platforms.
601
+
602
+ Args:
603
+ title: Text content for the post.
604
+ user: User identifier (profile name).
605
+ platforms: Target platforms. Supported: x, linkedin, facebook,
606
+ threads, reddit, bluesky
607
+
608
+ Keyword Args:
609
+ first_comment: First comment to post
610
+ scheduled_date: ISO date for scheduling
611
+ timezone: Timezone for scheduled date
612
+ add_to_queue: Add to posting queue
613
+ async_upload: Process asynchronously
614
+
615
+ LinkedIn:
616
+ target_linkedin_page_id: Page ID for organization posts
617
+
618
+ Facebook:
619
+ facebook_page_id: Facebook Page ID
620
+ facebook_link_url: URL to attach as link preview
621
+
622
+ X (Twitter):
623
+ reply_settings: Who can reply
624
+ post_url: URL to attach
625
+ quote_tweet_id: Tweet ID to quote
626
+ poll_options: Poll options (2-4 options)
627
+ poll_duration: Poll duration in minutes (5-10080)
628
+ poll_reply_settings: Who can reply to poll
629
+ card_uri: Card URI for Twitter Cards
630
+ x_long_text_as_post: Post long text as single post
631
+
632
+ Threads:
633
+ threads_long_text_as_post: Post long text as single post
634
+
635
+ Reddit:
636
+ subreddit: Subreddit name (without r/)
637
+ flair_id: Flair template ID
638
+
639
+ Returns:
640
+ API response.
641
+
41
642
  Raises:
42
- UploadPostError: If upload fails
643
+ UploadPostError: If upload fails.
644
+ """
645
+ data: List[tuple] = []
646
+
647
+ self._add_common_params(data, user, title, platforms, **kwargs)
648
+
649
+ if "linkedin" in platforms:
650
+ self._add_linkedin_params(data, **kwargs)
651
+ if "facebook" in platforms:
652
+ self._add_facebook_params(data, is_video=False, is_text=True, **kwargs)
653
+ if "x" in platforms:
654
+ self._add_x_params(data, is_text=True, **kwargs)
655
+ if "threads" in platforms:
656
+ self._add_threads_params(data, **kwargs)
657
+ if "reddit" in platforms:
658
+ self._add_reddit_params(data, **kwargs)
659
+
660
+ return self._request("/upload_text", "POST", data=data)
661
+
662
+ def upload_document(
663
+ self,
664
+ document_path: Union[str, Path],
665
+ title: str,
666
+ user: str,
667
+ description: Optional[str] = None,
668
+ visibility: Optional[str] = None,
669
+ target_linkedin_page_id: Optional[str] = None,
670
+ scheduled_date: Optional[str] = None,
671
+ timezone: Optional[str] = None,
672
+ add_to_queue: Optional[bool] = None,
673
+ async_upload: Optional[bool] = None,
674
+ ) -> Dict:
43
675
  """
44
- video_path = Path(video_path)
45
- if not video_path.exists():
46
- raise UploadPostError(f"Video file not found: {video_path}")
676
+ Upload a document to LinkedIn (PDF, PPT, PPTX, DOC, DOCX).
47
677
 
678
+ Args:
679
+ document_path: Path to document file or document URL.
680
+ title: Post title/caption.
681
+ user: User identifier (profile name).
682
+ description: Document description/commentary.
683
+ visibility: PUBLIC, CONNECTIONS, LOGGED_IN, CONTAINER
684
+ target_linkedin_page_id: Page ID for organization posts.
685
+ scheduled_date: ISO date for scheduling.
686
+ timezone: Timezone for scheduled date.
687
+ add_to_queue: Add to posting queue.
688
+ async_upload: Process asynchronously.
689
+
690
+ Returns:
691
+ API response.
692
+
693
+ Raises:
694
+ UploadPostError: If upload fails or document file not found.
695
+ """
696
+ data: List[tuple] = []
697
+ files: List[tuple] = []
698
+ doc_file = None
699
+
48
700
  try:
49
- with video_path.open("rb") as video_file:
50
- files = {"video": video_file}
51
- data = {
52
- "title": title,
53
- "user": user,
54
- "platform[]": platforms
55
- }
56
-
57
- response = self.session.post(
58
- f"{self.BASE_URL}/upload",
59
- files=files,
60
- data=data
61
- )
62
- response.raise_for_status()
63
- return response.json()
64
-
65
- except requests.exceptions.RequestException as e:
66
- raise UploadPostError(
67
- f"API request failed: {str(e)}"
68
- ) from e
69
- except (ValueError, TypeError) as e:
70
- raise UploadPostError(
71
- f"Invalid response format: {str(e)}"
72
- ) from e
701
+ doc_str = str(document_path)
702
+ if doc_str.lower().startswith(("http://", "https://")):
703
+ data.append(("document", doc_str))
704
+ else:
705
+ doc_p = Path(document_path)
706
+ if not doc_p.exists():
707
+ raise UploadPostError(f"Document file not found: {doc_p}")
708
+ doc_file = doc_p.open("rb")
709
+ files.append(("document", (doc_p.name, doc_file)))
710
+
711
+ data.append(("user", user))
712
+ data.append(("title", title))
713
+ data.append(("platform[]", "linkedin"))
714
+
715
+ if description:
716
+ data.append(("description", description))
717
+ if visibility:
718
+ data.append(("visibility", visibility))
719
+ if target_linkedin_page_id:
720
+ data.append(("target_linkedin_page_id", target_linkedin_page_id))
721
+ if scheduled_date:
722
+ data.append(("scheduled_date", scheduled_date))
723
+ if timezone:
724
+ data.append(("timezone", timezone))
725
+ if add_to_queue is not None:
726
+ data.append(("add_to_queue", str(add_to_queue).lower()))
727
+ if async_upload is not None:
728
+ data.append(("async_upload", str(async_upload).lower()))
729
+
730
+ return self._request("/upload_document", "POST", data=data, files=files if files else None)
731
+
732
+ finally:
733
+ if doc_file:
734
+ doc_file.close()
735
+
736
+ # ==================== Status & History ====================
737
+
738
+ def get_status(self, request_id: str) -> Dict:
739
+ """
740
+ Get the status of an async upload.
741
+
742
+ Args:
743
+ request_id: The request_id from an async upload.
744
+
745
+ Returns:
746
+ Upload status.
747
+ """
748
+ return self._request("/uploadposts/status", "GET", params={"request_id": request_id})
749
+
750
+ def get_history(self, page: int = 1, limit: int = 20) -> Dict:
751
+ """
752
+ Get upload history.
753
+
754
+ Args:
755
+ page: Page number.
756
+ limit: Items per page (20, 50, or 100).
757
+
758
+ Returns:
759
+ Upload history.
760
+ """
761
+ return self._request("/uploadposts/history", "GET", params={"page": page, "limit": limit})
762
+
763
+ def get_analytics(self, profile_username: str, platforms: Optional[List[str]] = None) -> Dict:
764
+ """
765
+ Get analytics for a profile.
766
+
767
+ Args:
768
+ profile_username: Profile username.
769
+ platforms: Filter by platforms (instagram, linkedin, facebook, x).
770
+
771
+ Returns:
772
+ Analytics data.
773
+ """
774
+ params = {}
775
+ if platforms:
776
+ params["platforms"] = ",".join(platforms)
777
+ return self._request(f"/analytics/{profile_username}", "GET", params=params if params else None)
778
+
779
+ # ==================== Scheduled Posts ====================
780
+
781
+ def list_scheduled(self) -> Dict:
782
+ """
783
+ List scheduled posts.
784
+
785
+ Returns:
786
+ List of scheduled posts.
787
+ """
788
+ return self._request("/uploadposts/schedule", "GET")
789
+
790
+ def cancel_scheduled(self, job_id: str) -> Dict:
791
+ """
792
+ Cancel a scheduled post.
793
+
794
+ Args:
795
+ job_id: Scheduled job ID.
796
+
797
+ Returns:
798
+ Cancellation result.
799
+ """
800
+ return self._request(f"/uploadposts/schedule/{job_id}", "DELETE")
801
+
802
+ def edit_scheduled(
803
+ self,
804
+ job_id: str,
805
+ scheduled_date: Optional[str] = None,
806
+ timezone: Optional[str] = None
807
+ ) -> Dict:
808
+ """
809
+ Edit a scheduled post.
810
+
811
+ Args:
812
+ job_id: Scheduled job ID.
813
+ scheduled_date: New scheduled date (ISO 8601).
814
+ timezone: New timezone.
815
+
816
+ Returns:
817
+ Edit result.
818
+ """
819
+ body = {}
820
+ if scheduled_date:
821
+ body["scheduled_date"] = scheduled_date
822
+ if timezone:
823
+ body["timezone"] = timezone
824
+ return self._request(f"/uploadposts/schedule/{job_id}", "POST", json_data=body)
825
+
826
+ # ==================== User Management ====================
827
+
828
+ def list_users(self) -> Dict:
829
+ """
830
+ List all users/profiles.
831
+
832
+ Returns:
833
+ List of users.
834
+ """
835
+ return self._request("/uploadposts/users", "GET")
836
+
837
+ def create_user(self, username: str) -> Dict:
838
+ """
839
+ Create a new user/profile.
840
+
841
+ Args:
842
+ username: Profile name to create.
843
+
844
+ Returns:
845
+ Created user.
846
+ """
847
+ return self._request("/uploadposts/users", "POST", json_data={"username": username})
848
+
849
+ def delete_user(self, username: str) -> Dict:
850
+ """
851
+ Delete a user/profile.
852
+
853
+ Args:
854
+ username: Profile name to delete.
855
+
856
+ Returns:
857
+ Deletion result.
858
+ """
859
+ return self._request("/uploadposts/users", "DELETE", json_data={"username": username})
860
+
861
+ def generate_jwt(
862
+ self,
863
+ username: str,
864
+ redirect_url: Optional[str] = None,
865
+ logo_image: Optional[str] = None,
866
+ redirect_button_text: Optional[str] = None,
867
+ platforms: Optional[List[str]] = None
868
+ ) -> Dict:
869
+ """
870
+ Generate a JWT for platform integration.
871
+ Used when integrating Upload-Post into your own platform.
872
+
873
+ Args:
874
+ username: Profile username.
875
+ redirect_url: URL to redirect after linking.
876
+ logo_image: Logo image URL for the linking page.
877
+ redirect_button_text: Text for redirect button.
878
+ platforms: Platforms to show for connection.
879
+
880
+ Returns:
881
+ JWT and connection URL.
882
+ """
883
+ body: Dict[str, Any] = {"username": username}
884
+ if redirect_url:
885
+ body["redirect_url"] = redirect_url
886
+ if logo_image:
887
+ body["logo_image"] = logo_image
888
+ if redirect_button_text:
889
+ body["redirect_button_text"] = redirect_button_text
890
+ if platforms:
891
+ body["platforms"] = platforms
892
+ return self._request("/uploadposts/users/generate-jwt", "POST", json_data=body)
893
+
894
+ def validate_jwt(self, jwt: str) -> Dict:
895
+ """
896
+ Validate a JWT token.
897
+
898
+ Args:
899
+ jwt: JWT token to validate.
900
+
901
+ Returns:
902
+ Validation result.
903
+ """
904
+ return self._request("/uploadposts/users/validate-jwt", "POST", json_data={"jwt": jwt})
905
+
906
+ # ==================== Helper Endpoints ====================
907
+
908
+ def get_facebook_pages(self, profile: Optional[str] = None) -> Dict:
909
+ """
910
+ Get Facebook pages for a profile.
911
+
912
+ Args:
913
+ profile: Profile username.
914
+
915
+ Returns:
916
+ List of Facebook pages.
917
+ """
918
+ params = {"profile": profile} if profile else None
919
+ return self._request("/uploadposts/facebook/pages", "GET", params=params)
920
+
921
+ def get_linkedin_pages(self, profile: Optional[str] = None) -> Dict:
922
+ """
923
+ Get LinkedIn pages for a profile.
924
+
925
+ Args:
926
+ profile: Profile username.
927
+
928
+ Returns:
929
+ List of LinkedIn pages.
930
+ """
931
+ params = {"profile": profile} if profile else None
932
+ return self._request("/uploadposts/linkedin/pages", "GET", params=params)
933
+
934
+ def get_pinterest_boards(self, profile: Optional[str] = None) -> Dict:
935
+ """
936
+ Get Pinterest boards for a profile.
937
+
938
+ Args:
939
+ profile: Profile username.
940
+
941
+ Returns:
942
+ List of Pinterest boards.
943
+ """
944
+ params = {"profile": profile} if profile else None
945
+ return self._request("/uploadposts/pinterest/boards", "GET", params=params)