upload-post 2.14.0__tar.gz → 2.15.0__tar.gz
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-2.14.0 → upload_post-2.15.0}/PKG-INFO +205 -3
- {upload_post-2.14.0 → upload_post-2.15.0}/README.md +204 -2
- {upload_post-2.14.0 → upload_post-2.15.0}/setup.py +1 -1
- upload_post-2.15.0/tests/test_messaging_webhooks.py +293 -0
- {upload_post-2.14.0 → upload_post-2.15.0}/upload_post/__init__.py +1 -1
- {upload_post-2.14.0 → upload_post-2.15.0}/upload_post/api_client.py +586 -26
- {upload_post-2.14.0 → upload_post-2.15.0}/upload_post.egg-info/PKG-INFO +205 -3
- {upload_post-2.14.0 → upload_post-2.15.0}/upload_post.egg-info/SOURCES.txt +1 -0
- {upload_post-2.14.0 → upload_post-2.15.0}/setup.cfg +0 -0
- {upload_post-2.14.0 → upload_post-2.15.0}/tests/test_comments_audience.py +0 -0
- {upload_post-2.14.0 → upload_post-2.15.0}/tests/test_edit_scheduled.py +0 -0
- {upload_post-2.14.0 → upload_post-2.15.0}/tests/test_parity_fields.py +0 -0
- {upload_post-2.14.0 → upload_post-2.15.0}/upload_post/cli.py +0 -0
- {upload_post-2.14.0 → upload_post-2.15.0}/upload_post.egg-info/dependency_links.txt +0 -0
- {upload_post-2.14.0 → upload_post-2.15.0}/upload_post.egg-info/requires.txt +0 -0
- {upload_post-2.14.0 → upload_post-2.15.0}/upload_post.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: upload-post
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.15.0
|
|
4
4
|
Summary: Cross-platform social media upload for TikTok, Instagram, YouTube, LinkedIn, Facebook, Pinterest, Threads, Reddit, Bluesky, Discord, Telegram, and X (Twitter)
|
|
5
5
|
Home-page: https://www.upload-post.com/
|
|
6
6
|
Author: Upload-Post
|
|
@@ -78,6 +78,10 @@ print(response)
|
|
|
78
78
|
- ✅ **Analytics** - Get engagement metrics
|
|
79
79
|
- ✅ **Audience** - Who follows a profile, per platform
|
|
80
80
|
- ✅ **Suggestions** - Hashtags and searches to post about, per platform
|
|
81
|
+
- ✅ **Comments** - Read, reply, moderate; replies under a comment on TikTok, Threads, YouTube, Instagram, Facebook and LinkedIn
|
|
82
|
+
- ✅ **Direct Messages** - Instagram, Messenger, WhatsApp and Bluesky
|
|
83
|
+
- ✅ **AutoDM rules** - Account-wide Instagram comment/story/keyword DMs
|
|
84
|
+
- ✅ **Webhook deliveries** - 7-day delivery log and redelivery
|
|
81
85
|
- ✅ **Full Type Hints**
|
|
82
86
|
|
|
83
87
|
## API Reference
|
|
@@ -256,6 +260,33 @@ print(analytics["analytics"]["instagram"]["follower_demographics"])
|
|
|
256
260
|
print(analytics["analytics"]["instagram"]["engaged_audience_demographics"])
|
|
257
261
|
```
|
|
258
262
|
|
|
263
|
+
### Post Analytics and TikTok drafts
|
|
264
|
+
|
|
265
|
+
```python
|
|
266
|
+
# Every platform the upload went to, or just one of them
|
|
267
|
+
stats = client.get_post_analytics("REQUEST_ID")
|
|
268
|
+
tiktok = client.get_post_analytics("REQUEST_ID", platform="tiktok")
|
|
269
|
+
|
|
270
|
+
# Any post of the account by its native id (e.g. one returned by get_media)
|
|
271
|
+
client.get_post_analytics_by_platform_id("7412345678901234567", "tiktok", "my-profile")
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
A TikTok upload sent to drafts (`post_mode="MEDIA_UPLOAD"` or
|
|
275
|
+
`tiktok_upload_to_draft=True`) has no post until the creator publishes it from
|
|
276
|
+
the TikTok app. Call `get_post_analytics(request_id)` after that: Upload-Post
|
|
277
|
+
asks TikTok which post came from the draft, then returns its metrics and fills
|
|
278
|
+
in `platform_post_id` and `post_url`.
|
|
279
|
+
|
|
280
|
+
- While the draft is still in the inbox, `post_metrics_error` says so. That
|
|
281
|
+
answer is reused for a while (about 5 minutes for a fresh upload, up to 6
|
|
282
|
+
hours for one several days old); after 30 days without a post Upload-Post
|
|
283
|
+
stops asking.
|
|
284
|
+
- TikTok only gives the post id once the post is **public** and moderated. A
|
|
285
|
+
draft published as private never gets per-post analytics.
|
|
286
|
+
- TikTok rows carry `original_publish_id` (the upload's TikTok `publish_id`),
|
|
287
|
+
here and in `get_cached_post_analytics`. `post_url` is empty when the
|
|
288
|
+
account's @username is not known.
|
|
289
|
+
|
|
259
290
|
### Cached Post Analytics
|
|
260
291
|
|
|
261
292
|
Replays per-post metrics already fetched, instead of calling the platforms again. Only contains posts previously fetched through a live per-post endpoint; there is no background refresh, so captured_at is the last time that post was read live. Not subject to the live
|
|
@@ -324,6 +355,12 @@ fb_pages = client.get_facebook_pages("my-profile")
|
|
|
324
355
|
# Get LinkedIn pages for a profile
|
|
325
356
|
li_pages = client.get_linkedin_pages("my-profile")
|
|
326
357
|
|
|
358
|
+
# Resolve any LinkedIn company Page to the urn + exact name a mention needs
|
|
359
|
+
org = client.resolve_linkedin_organization(
|
|
360
|
+
"my-profile", "https://www.linkedin.com/company/linkedin/"
|
|
361
|
+
)
|
|
362
|
+
print(org["urn"], org["name"], org["mention"]) # urn:li:organization:1337 LinkedIn @[LinkedIn](urn:li:organization:1337)
|
|
363
|
+
|
|
327
364
|
# Get Pinterest boards for a profile
|
|
328
365
|
boards = client.get_pinterest_boards("my-profile")
|
|
329
366
|
|
|
@@ -362,6 +399,15 @@ replies = client.get_post_comments(
|
|
|
362
399
|
comment_id="7412345678909999999",
|
|
363
400
|
)
|
|
364
401
|
|
|
402
|
+
# Same on Instagram, Facebook and LinkedIn (company Pages). On LinkedIn pass the
|
|
403
|
+
# comment_urn from the listing; a bare id is only looked up in the first 250.
|
|
404
|
+
replies = client.get_post_comments(
|
|
405
|
+
user="my-profile",
|
|
406
|
+
platform="linkedin",
|
|
407
|
+
post_id="urn:li:share:7500811262105264139",
|
|
408
|
+
comment_id="urn:li:comment:(urn:li:activity:7500811262105264139,7503818768586981376)",
|
|
409
|
+
)
|
|
410
|
+
|
|
365
411
|
# Comment on the post, or reply to a comment
|
|
366
412
|
client.create_comment(
|
|
367
413
|
user="my-profile",
|
|
@@ -389,6 +435,20 @@ On TikTok all of this needs the `comments` capability, which the account grants
|
|
|
389
435
|
when it connects — see
|
|
390
436
|
[What a TikTok connection can do](#what-a-tiktok-connection-can-do-capabilities).
|
|
391
437
|
|
|
438
|
+
Replies (`comment_id`) work on TikTok, Threads, YouTube, Instagram, Facebook and
|
|
439
|
+
LinkedIn (company Pages only); `post_id` is still required. X and Bluesky answer
|
|
440
|
+
`platform_not_supported`. Error codes you can branch on with
|
|
441
|
+
`UploadPostError.error_code`:
|
|
442
|
+
|
|
443
|
+
| `error_code` | Status | Meaning |
|
|
444
|
+
| --- | --- | --- |
|
|
445
|
+
| `invalid_comment_id` | 400 | Wrong format (Instagram: digits; Facebook: `{post}_{comment}`; LinkedIn: digits or the `comment_urn`) |
|
|
446
|
+
| `invalid_cursor` | 400 | LinkedIn replies: `after` must be the number from `next_cursor` |
|
|
447
|
+
| `comment_not_found` | 404 | Instagram/Facebook do not know the comment (one without replies is a 200 with an empty list) |
|
|
448
|
+
| `linkedin_comment_not_found` | 404 | Bare LinkedIn id not among the post's first 250 comments: pass the `comment_urn` |
|
|
449
|
+
| `linkedin_personal_not_supported` | 403 | Personal LinkedIn profile: LinkedIn only lets us read company Pages |
|
|
450
|
+
| `linkedin_page_access_denied` | 403 | The member who connected LinkedIn is no longer an admin of the Page |
|
|
451
|
+
|
|
392
452
|
#### Moderating a comment: hide, like, pin
|
|
393
453
|
|
|
394
454
|
`comment_action()` does the three and undoes them, on any platform that
|
|
@@ -416,6 +476,98 @@ client.comment_action(
|
|
|
416
476
|
| `like` | `unlike` | not sent |
|
|
417
477
|
| `pin` | `unpin` | required |
|
|
418
478
|
|
|
479
|
+
### Direct Messages
|
|
480
|
+
|
|
481
|
+
One pair of methods for every messaging platform: `instagram`, `facebook`
|
|
482
|
+
(Messenger), `whatsapp` and `bluesky`.
|
|
483
|
+
|
|
484
|
+
```python
|
|
485
|
+
# Instagram: by the person's Instagram-scoped id, inside the 24-hour window
|
|
486
|
+
client.send_dm("my-profile", "instagram", recipient_id="1784...", message="Thanks!")
|
|
487
|
+
|
|
488
|
+
# Bluesky: by handle, DID or profile link. Links, #hashtags and @mentions are clickable
|
|
489
|
+
sent = client.send_dm(
|
|
490
|
+
"my-profile", "bluesky",
|
|
491
|
+
recipient_id="alice.bsky.social",
|
|
492
|
+
message="Our new guide: https://example.com/guide",
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
# Share a Bluesky post, reply to a message, or act on one (no DM quota used)
|
|
496
|
+
client.send_dm("my-profile", "bluesky", recipient_id="alice.bsky.social",
|
|
497
|
+
message="Look at this",
|
|
498
|
+
attachments={"type": "post", "url": "https://bsky.app/profile/alice.bsky.social/post/3l5t..."})
|
|
499
|
+
client.send_dm("my-profile", "bluesky", conversation_id=sent["conversation_id"],
|
|
500
|
+
sender_action="react", message_id=sent["message_id"], reaction="🔥")
|
|
501
|
+
client.send_dm("my-profile", "bluesky", conversation_id=sent["conversation_id"],
|
|
502
|
+
sender_action="mark_read")
|
|
503
|
+
|
|
504
|
+
# Read conversations (Bluesky: at most 25 per page)
|
|
505
|
+
page = client.get_dm_conversations("my-profile", "bluesky", messages_limit=5)
|
|
506
|
+
for convo in page["conversations"]:
|
|
507
|
+
print(convo["id"], convo.get("status"), convo.get("unread_count"))
|
|
508
|
+
|
|
509
|
+
# Bluesky-only filters: one conversation and its older messages, requests, unread
|
|
510
|
+
client.get_dm_conversations("my-profile", "bluesky", conversation_id="3l5ttwb6ycx2f",
|
|
511
|
+
messages_cursor="...")
|
|
512
|
+
client.get_dm_conversations("my-profile", "bluesky", status="request", read_state="unread")
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
Any other `/dms/send` field (WhatsApp `template`, `location`, `contacts`,
|
|
516
|
+
`list`, `cta_url`; Messenger `tag`, `template`...) goes through as a keyword
|
|
517
|
+
argument. **Bluesky DMs need an app password created with "Allow access to your
|
|
518
|
+
direct messages"** (Bluesky > Settings > Privacy and security > App passwords),
|
|
519
|
+
then reconnect Bluesky with it; otherwise the API answers 403
|
|
520
|
+
`bluesky_dm_scope_missing`. Other Bluesky codes:
|
|
521
|
+
|
|
522
|
+
| `error_code` | Status | Meaning |
|
|
523
|
+
| --- | --- | --- |
|
|
524
|
+
| `bluesky_recipient_unavailable` | 403 | The recipient does not accept your DM (`reason`: `MessagesDisabled`, `NotFollowedBySender`, `BlockedActor`...). No quota used |
|
|
525
|
+
| `bluesky_recipient_not_found` / `bluesky_conversation_not_found` / `bluesky_post_not_found` | 404 | Unknown handle, conversation or shared post |
|
|
526
|
+
| `bluesky_message_too_long` | 400 | More than 1000 characters. Nothing sent |
|
|
527
|
+
| `bluesky_rejected` | 400 | Bluesky refused it (e.g. a reaction that is not a single emoji) |
|
|
528
|
+
| `bluesky_reauth_required` | 401 | Reconnect Bluesky |
|
|
529
|
+
| `bluesky_rate_limited` | 429 | Wait `retry_after` seconds |
|
|
530
|
+
| `bluesky_chat_unavailable` / `bluesky_edge_rate_limited` | 502 / 503 | Nothing was sent; retry later |
|
|
531
|
+
| `bluesky_send_unconfirmed` | 504 | **It may have been sent.** Do not retry automatically: read the conversation first |
|
|
532
|
+
|
|
533
|
+
New DMs (Instagram, Messenger, WhatsApp and Bluesky) can also reach you as the
|
|
534
|
+
opt-in `dm_received` webhook. Bluesky has no push, so Upload-Post checks every
|
|
535
|
+
5 minutes.
|
|
536
|
+
|
|
537
|
+
### AutoDM rules (whole Instagram account)
|
|
538
|
+
|
|
539
|
+
A rule answers comments on **every** post and reel of the account (present and
|
|
540
|
+
future), plus story replies, story mentions and keyword DMs. Rules never expire
|
|
541
|
+
and work only from Meta's real-time notifications.
|
|
542
|
+
|
|
543
|
+
```python
|
|
544
|
+
rule = client.create_autodm_rule(
|
|
545
|
+
profile_username="my-profile",
|
|
546
|
+
reply_message="Here is your free guide!",
|
|
547
|
+
triggers=["comment", "story_reply", "story_mention", "dm_keyword"],
|
|
548
|
+
trigger_keywords=["guide"], # required for dm_keyword
|
|
549
|
+
buttons=[{"title": "Get the guide", "url": "https://example.com/guide"}],
|
|
550
|
+
per_user_cooldown_hours=24, # 0-720; never < 24 for story/DM triggers
|
|
551
|
+
)["rule"]
|
|
552
|
+
|
|
553
|
+
for r in client.list_autodm_rules("my-profile")["rules"]:
|
|
554
|
+
print(r["rule_id"], r["status"], r["webhook"]["receiving_events"], r["webhook"].get("hint"))
|
|
555
|
+
|
|
556
|
+
client.pause_autodm_rule(rule["rule_id"])
|
|
557
|
+
client.resume_autodm_rule(rule["rule_id"])
|
|
558
|
+
client.delete_autodm_rule(rule["rule_id"])
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
- Up to 10 rules per Instagram account; they do not count towards the
|
|
562
|
+
2-monitors-per-day limit. A post with its own monitor is answered by that
|
|
563
|
+
monitor only.
|
|
564
|
+
- Pace: 25 DMs per account every 15 minutes plus the plan's daily DM allowance;
|
|
565
|
+
what does not fit is queued for up to 24 hours.
|
|
566
|
+
- Errors: 409 `duplicate_rule` / `rule_limit_reached` /
|
|
567
|
+
`rule_creation_in_progress`, 502 `webhook_subscription_failed` (reconnect
|
|
568
|
+
Instagram), 503 `account_rules_disabled`, 409 `rule_stopped` on resume when
|
|
569
|
+
the account left its profile.
|
|
570
|
+
|
|
419
571
|
### Audience
|
|
420
572
|
|
|
421
573
|
Where the analytics methods answer *how did my posts do*, `get_audience()`
|
|
@@ -484,6 +636,52 @@ vs non-followers), `new_followers`, `reach` and the watch times.
|
|
|
484
636
|
Asking a platform a question it cannot answer fails with
|
|
485
637
|
`platform_not_supported` and the list of the ones that can.
|
|
486
638
|
|
|
639
|
+
## Webhooks: configuration, delivery log and redelivery
|
|
640
|
+
|
|
641
|
+
```python
|
|
642
|
+
# Turn the webhook on and opt into the inbound events
|
|
643
|
+
client.update_notification_config(
|
|
644
|
+
webhook_url="https://example.com/upload-post",
|
|
645
|
+
webhook_events=["upload_completed", "dm_received", "comment_received"],
|
|
646
|
+
channels={"webhook": True},
|
|
647
|
+
)
|
|
648
|
+
print(client.get_notification_config()["notifications"]["webhook_secret"])
|
|
649
|
+
|
|
650
|
+
# Did an event reach my server? (7-day history, newest first)
|
|
651
|
+
failed = client.list_webhook_deliveries(status="failed", limit=20, include_payload=True)
|
|
652
|
+
for d in failed["deliveries"]:
|
|
653
|
+
print(d["delivery_id"], d["event"], d["attempt_count"], d["last_status_code"], d["last_error"])
|
|
654
|
+
|
|
655
|
+
# One delivery with its exact payload, and send it again
|
|
656
|
+
one = client.get_webhook_delivery(failed["deliveries"][0]["delivery_id"])
|
|
657
|
+
again = client.redeliver_webhook(one["delivery"]["delivery_id"])
|
|
658
|
+
print(again["delivery"]["delivery_id"], again["delivery"]["redelivery_of"])
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
Webhooks are delivered **at least once**: a timeout, connection error, `5xx`,
|
|
662
|
+
`408` or `429` is retried with the same body and the same
|
|
663
|
+
`X-Upload-Post-Delivery` id (6 attempts over about 8.5 hours, header
|
|
664
|
+
`X-Upload-Post-Attempt` 1-6); other `4xx` answers are final. Deduplicate by
|
|
665
|
+
`X-Upload-Post-Delivery`. A redelivery is a new delivery with its own id and
|
|
666
|
+
`X-Upload-Post-Redelivery-Of`. Filters of `list_webhook_deliveries`: `event`,
|
|
667
|
+
`status` (`pending`, `delivered`, `failed`, `cancelled`), `request_id`,
|
|
668
|
+
`job_id`, `profile_username`, `limit` (1-100), `page` (1-50). `redeliver_webhook`
|
|
669
|
+
answers 409 `webhook_removed` / `event_unsubscribed` / `blocked_url` when there
|
|
670
|
+
is nowhere to send it.
|
|
671
|
+
|
|
672
|
+
## Team members (acting on the owner's account)
|
|
673
|
+
|
|
674
|
+
```python
|
|
675
|
+
client = UploadPostClient("MEMBER_API_KEY", act_as_user="owner@example.com")
|
|
676
|
+
client.list_users() # the owner's profiles
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
Every request carries `X-Act-As-User`. If the owner's current plan no longer
|
|
680
|
+
covers your seat (the last invited members are suspended after a downgrade,
|
|
681
|
+
never removed), requests fail with `status_code` 403 and `error_code`
|
|
682
|
+
`"TEAM_SEAT_SUSPENDED"`; access comes back by itself when the owner upgrades or
|
|
683
|
+
frees a seat.
|
|
684
|
+
|
|
487
685
|
## What a TikTok connection can do (`capabilities`)
|
|
488
686
|
|
|
489
687
|
Not every TikTok connection can do the same things. `client.list_users()`
|
|
@@ -696,7 +894,7 @@ for the music, location, cover and draft options.
|
|
|
696
894
|
- `x_alt_text` - Alt text (string, `x_alt_text[]`, or JSON array; ≤1000 chars)
|
|
697
895
|
- `x_subtitles` / `x_subtitles_url` / `x_subtitles_language` / `x_subtitles_name` - Video SRT captions
|
|
698
896
|
- `x_paid_partnership` - Paid partnership flag on the first post
|
|
699
|
-
- `x_article_title` / `x_article_body` / `x_article_content_state` / `x_article_draft` / `x_article_cover_media` - X Articles (text; Premium)
|
|
897
|
+
- `x_article_title` / `x_article_body` / `x_article_content_state` / `x_article_draft` / `x_article_cover_media` - X Articles (text; Premium). Articles have two heading levels: a `### Heading` in the body, or a `header-three` block in `x_article_content_state`, is published as `header-two`
|
|
700
898
|
|
|
701
899
|
### Threads
|
|
702
900
|
- `threads_long_text_as_post` - Post long text as single post (vs thread). Over 500 UTF-8 bytes this sends an official long post instead of truncating.
|
|
@@ -749,7 +947,7 @@ client.upload_photos(
|
|
|
749
947
|
- `bluesky_alt_text` - Alt text per image/video (JSON array, `||`-separated, or string)
|
|
750
948
|
- `bluesky_langs` - Comma list, max 3 BCP-47 codes
|
|
751
949
|
- `bluesky_labels` - `porn`, `sexual`, `nudity`, `graphic-media`
|
|
752
|
-
- `bluesky_gallery` - Publish up to
|
|
950
|
+
- `bluesky_gallery` - Publish up to 10 images as a gallery (`false` keeps the first 4). With more than 10, the first 10 are published and the response carries a `warnings` entry
|
|
753
951
|
- `bluesky_threadgate` / `bluesky_reply_settings` - Who can reply
|
|
754
952
|
- `bluesky_postgate` / `bluesky_quote_settings` - `disable_quotes` to block quotes
|
|
755
953
|
- `bluesky_quote_uri` / `bluesky_quote_id` / `bluesky_quote_url` - Quote another post
|
|
@@ -834,6 +1032,10 @@ try:
|
|
|
834
1032
|
print("Upload successful:", response)
|
|
835
1033
|
except UploadPostError as e:
|
|
836
1034
|
print("Upload failed:", str(e))
|
|
1035
|
+
print(e.status_code) # HTTP status, or None if no answer arrived
|
|
1036
|
+
print(e.error_code) # stable code, e.g. "bluesky_dm_scope_missing", "TEAM_SEAT_SUSPENDED"
|
|
1037
|
+
print(e.response) # decoded JSON body of the error
|
|
1038
|
+
print(e.retry_after) # Retry-After seconds, when the API sent it
|
|
837
1039
|
```
|
|
838
1040
|
|
|
839
1041
|
## Links
|
|
@@ -40,6 +40,10 @@ print(response)
|
|
|
40
40
|
- ✅ **Analytics** - Get engagement metrics
|
|
41
41
|
- ✅ **Audience** - Who follows a profile, per platform
|
|
42
42
|
- ✅ **Suggestions** - Hashtags and searches to post about, per platform
|
|
43
|
+
- ✅ **Comments** - Read, reply, moderate; replies under a comment on TikTok, Threads, YouTube, Instagram, Facebook and LinkedIn
|
|
44
|
+
- ✅ **Direct Messages** - Instagram, Messenger, WhatsApp and Bluesky
|
|
45
|
+
- ✅ **AutoDM rules** - Account-wide Instagram comment/story/keyword DMs
|
|
46
|
+
- ✅ **Webhook deliveries** - 7-day delivery log and redelivery
|
|
43
47
|
- ✅ **Full Type Hints**
|
|
44
48
|
|
|
45
49
|
## API Reference
|
|
@@ -218,6 +222,33 @@ print(analytics["analytics"]["instagram"]["follower_demographics"])
|
|
|
218
222
|
print(analytics["analytics"]["instagram"]["engaged_audience_demographics"])
|
|
219
223
|
```
|
|
220
224
|
|
|
225
|
+
### Post Analytics and TikTok drafts
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
# Every platform the upload went to, or just one of them
|
|
229
|
+
stats = client.get_post_analytics("REQUEST_ID")
|
|
230
|
+
tiktok = client.get_post_analytics("REQUEST_ID", platform="tiktok")
|
|
231
|
+
|
|
232
|
+
# Any post of the account by its native id (e.g. one returned by get_media)
|
|
233
|
+
client.get_post_analytics_by_platform_id("7412345678901234567", "tiktok", "my-profile")
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
A TikTok upload sent to drafts (`post_mode="MEDIA_UPLOAD"` or
|
|
237
|
+
`tiktok_upload_to_draft=True`) has no post until the creator publishes it from
|
|
238
|
+
the TikTok app. Call `get_post_analytics(request_id)` after that: Upload-Post
|
|
239
|
+
asks TikTok which post came from the draft, then returns its metrics and fills
|
|
240
|
+
in `platform_post_id` and `post_url`.
|
|
241
|
+
|
|
242
|
+
- While the draft is still in the inbox, `post_metrics_error` says so. That
|
|
243
|
+
answer is reused for a while (about 5 minutes for a fresh upload, up to 6
|
|
244
|
+
hours for one several days old); after 30 days without a post Upload-Post
|
|
245
|
+
stops asking.
|
|
246
|
+
- TikTok only gives the post id once the post is **public** and moderated. A
|
|
247
|
+
draft published as private never gets per-post analytics.
|
|
248
|
+
- TikTok rows carry `original_publish_id` (the upload's TikTok `publish_id`),
|
|
249
|
+
here and in `get_cached_post_analytics`. `post_url` is empty when the
|
|
250
|
+
account's @username is not known.
|
|
251
|
+
|
|
221
252
|
### Cached Post Analytics
|
|
222
253
|
|
|
223
254
|
Replays per-post metrics already fetched, instead of calling the platforms again. Only contains posts previously fetched through a live per-post endpoint; there is no background refresh, so captured_at is the last time that post was read live. Not subject to the live
|
|
@@ -286,6 +317,12 @@ fb_pages = client.get_facebook_pages("my-profile")
|
|
|
286
317
|
# Get LinkedIn pages for a profile
|
|
287
318
|
li_pages = client.get_linkedin_pages("my-profile")
|
|
288
319
|
|
|
320
|
+
# Resolve any LinkedIn company Page to the urn + exact name a mention needs
|
|
321
|
+
org = client.resolve_linkedin_organization(
|
|
322
|
+
"my-profile", "https://www.linkedin.com/company/linkedin/"
|
|
323
|
+
)
|
|
324
|
+
print(org["urn"], org["name"], org["mention"]) # urn:li:organization:1337 LinkedIn @[LinkedIn](urn:li:organization:1337)
|
|
325
|
+
|
|
289
326
|
# Get Pinterest boards for a profile
|
|
290
327
|
boards = client.get_pinterest_boards("my-profile")
|
|
291
328
|
|
|
@@ -324,6 +361,15 @@ replies = client.get_post_comments(
|
|
|
324
361
|
comment_id="7412345678909999999",
|
|
325
362
|
)
|
|
326
363
|
|
|
364
|
+
# Same on Instagram, Facebook and LinkedIn (company Pages). On LinkedIn pass the
|
|
365
|
+
# comment_urn from the listing; a bare id is only looked up in the first 250.
|
|
366
|
+
replies = client.get_post_comments(
|
|
367
|
+
user="my-profile",
|
|
368
|
+
platform="linkedin",
|
|
369
|
+
post_id="urn:li:share:7500811262105264139",
|
|
370
|
+
comment_id="urn:li:comment:(urn:li:activity:7500811262105264139,7503818768586981376)",
|
|
371
|
+
)
|
|
372
|
+
|
|
327
373
|
# Comment on the post, or reply to a comment
|
|
328
374
|
client.create_comment(
|
|
329
375
|
user="my-profile",
|
|
@@ -351,6 +397,20 @@ On TikTok all of this needs the `comments` capability, which the account grants
|
|
|
351
397
|
when it connects — see
|
|
352
398
|
[What a TikTok connection can do](#what-a-tiktok-connection-can-do-capabilities).
|
|
353
399
|
|
|
400
|
+
Replies (`comment_id`) work on TikTok, Threads, YouTube, Instagram, Facebook and
|
|
401
|
+
LinkedIn (company Pages only); `post_id` is still required. X and Bluesky answer
|
|
402
|
+
`platform_not_supported`. Error codes you can branch on with
|
|
403
|
+
`UploadPostError.error_code`:
|
|
404
|
+
|
|
405
|
+
| `error_code` | Status | Meaning |
|
|
406
|
+
| --- | --- | --- |
|
|
407
|
+
| `invalid_comment_id` | 400 | Wrong format (Instagram: digits; Facebook: `{post}_{comment}`; LinkedIn: digits or the `comment_urn`) |
|
|
408
|
+
| `invalid_cursor` | 400 | LinkedIn replies: `after` must be the number from `next_cursor` |
|
|
409
|
+
| `comment_not_found` | 404 | Instagram/Facebook do not know the comment (one without replies is a 200 with an empty list) |
|
|
410
|
+
| `linkedin_comment_not_found` | 404 | Bare LinkedIn id not among the post's first 250 comments: pass the `comment_urn` |
|
|
411
|
+
| `linkedin_personal_not_supported` | 403 | Personal LinkedIn profile: LinkedIn only lets us read company Pages |
|
|
412
|
+
| `linkedin_page_access_denied` | 403 | The member who connected LinkedIn is no longer an admin of the Page |
|
|
413
|
+
|
|
354
414
|
#### Moderating a comment: hide, like, pin
|
|
355
415
|
|
|
356
416
|
`comment_action()` does the three and undoes them, on any platform that
|
|
@@ -378,6 +438,98 @@ client.comment_action(
|
|
|
378
438
|
| `like` | `unlike` | not sent |
|
|
379
439
|
| `pin` | `unpin` | required |
|
|
380
440
|
|
|
441
|
+
### Direct Messages
|
|
442
|
+
|
|
443
|
+
One pair of methods for every messaging platform: `instagram`, `facebook`
|
|
444
|
+
(Messenger), `whatsapp` and `bluesky`.
|
|
445
|
+
|
|
446
|
+
```python
|
|
447
|
+
# Instagram: by the person's Instagram-scoped id, inside the 24-hour window
|
|
448
|
+
client.send_dm("my-profile", "instagram", recipient_id="1784...", message="Thanks!")
|
|
449
|
+
|
|
450
|
+
# Bluesky: by handle, DID or profile link. Links, #hashtags and @mentions are clickable
|
|
451
|
+
sent = client.send_dm(
|
|
452
|
+
"my-profile", "bluesky",
|
|
453
|
+
recipient_id="alice.bsky.social",
|
|
454
|
+
message="Our new guide: https://example.com/guide",
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
# Share a Bluesky post, reply to a message, or act on one (no DM quota used)
|
|
458
|
+
client.send_dm("my-profile", "bluesky", recipient_id="alice.bsky.social",
|
|
459
|
+
message="Look at this",
|
|
460
|
+
attachments={"type": "post", "url": "https://bsky.app/profile/alice.bsky.social/post/3l5t..."})
|
|
461
|
+
client.send_dm("my-profile", "bluesky", conversation_id=sent["conversation_id"],
|
|
462
|
+
sender_action="react", message_id=sent["message_id"], reaction="🔥")
|
|
463
|
+
client.send_dm("my-profile", "bluesky", conversation_id=sent["conversation_id"],
|
|
464
|
+
sender_action="mark_read")
|
|
465
|
+
|
|
466
|
+
# Read conversations (Bluesky: at most 25 per page)
|
|
467
|
+
page = client.get_dm_conversations("my-profile", "bluesky", messages_limit=5)
|
|
468
|
+
for convo in page["conversations"]:
|
|
469
|
+
print(convo["id"], convo.get("status"), convo.get("unread_count"))
|
|
470
|
+
|
|
471
|
+
# Bluesky-only filters: one conversation and its older messages, requests, unread
|
|
472
|
+
client.get_dm_conversations("my-profile", "bluesky", conversation_id="3l5ttwb6ycx2f",
|
|
473
|
+
messages_cursor="...")
|
|
474
|
+
client.get_dm_conversations("my-profile", "bluesky", status="request", read_state="unread")
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
Any other `/dms/send` field (WhatsApp `template`, `location`, `contacts`,
|
|
478
|
+
`list`, `cta_url`; Messenger `tag`, `template`...) goes through as a keyword
|
|
479
|
+
argument. **Bluesky DMs need an app password created with "Allow access to your
|
|
480
|
+
direct messages"** (Bluesky > Settings > Privacy and security > App passwords),
|
|
481
|
+
then reconnect Bluesky with it; otherwise the API answers 403
|
|
482
|
+
`bluesky_dm_scope_missing`. Other Bluesky codes:
|
|
483
|
+
|
|
484
|
+
| `error_code` | Status | Meaning |
|
|
485
|
+
| --- | --- | --- |
|
|
486
|
+
| `bluesky_recipient_unavailable` | 403 | The recipient does not accept your DM (`reason`: `MessagesDisabled`, `NotFollowedBySender`, `BlockedActor`...). No quota used |
|
|
487
|
+
| `bluesky_recipient_not_found` / `bluesky_conversation_not_found` / `bluesky_post_not_found` | 404 | Unknown handle, conversation or shared post |
|
|
488
|
+
| `bluesky_message_too_long` | 400 | More than 1000 characters. Nothing sent |
|
|
489
|
+
| `bluesky_rejected` | 400 | Bluesky refused it (e.g. a reaction that is not a single emoji) |
|
|
490
|
+
| `bluesky_reauth_required` | 401 | Reconnect Bluesky |
|
|
491
|
+
| `bluesky_rate_limited` | 429 | Wait `retry_after` seconds |
|
|
492
|
+
| `bluesky_chat_unavailable` / `bluesky_edge_rate_limited` | 502 / 503 | Nothing was sent; retry later |
|
|
493
|
+
| `bluesky_send_unconfirmed` | 504 | **It may have been sent.** Do not retry automatically: read the conversation first |
|
|
494
|
+
|
|
495
|
+
New DMs (Instagram, Messenger, WhatsApp and Bluesky) can also reach you as the
|
|
496
|
+
opt-in `dm_received` webhook. Bluesky has no push, so Upload-Post checks every
|
|
497
|
+
5 minutes.
|
|
498
|
+
|
|
499
|
+
### AutoDM rules (whole Instagram account)
|
|
500
|
+
|
|
501
|
+
A rule answers comments on **every** post and reel of the account (present and
|
|
502
|
+
future), plus story replies, story mentions and keyword DMs. Rules never expire
|
|
503
|
+
and work only from Meta's real-time notifications.
|
|
504
|
+
|
|
505
|
+
```python
|
|
506
|
+
rule = client.create_autodm_rule(
|
|
507
|
+
profile_username="my-profile",
|
|
508
|
+
reply_message="Here is your free guide!",
|
|
509
|
+
triggers=["comment", "story_reply", "story_mention", "dm_keyword"],
|
|
510
|
+
trigger_keywords=["guide"], # required for dm_keyword
|
|
511
|
+
buttons=[{"title": "Get the guide", "url": "https://example.com/guide"}],
|
|
512
|
+
per_user_cooldown_hours=24, # 0-720; never < 24 for story/DM triggers
|
|
513
|
+
)["rule"]
|
|
514
|
+
|
|
515
|
+
for r in client.list_autodm_rules("my-profile")["rules"]:
|
|
516
|
+
print(r["rule_id"], r["status"], r["webhook"]["receiving_events"], r["webhook"].get("hint"))
|
|
517
|
+
|
|
518
|
+
client.pause_autodm_rule(rule["rule_id"])
|
|
519
|
+
client.resume_autodm_rule(rule["rule_id"])
|
|
520
|
+
client.delete_autodm_rule(rule["rule_id"])
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
- Up to 10 rules per Instagram account; they do not count towards the
|
|
524
|
+
2-monitors-per-day limit. A post with its own monitor is answered by that
|
|
525
|
+
monitor only.
|
|
526
|
+
- Pace: 25 DMs per account every 15 minutes plus the plan's daily DM allowance;
|
|
527
|
+
what does not fit is queued for up to 24 hours.
|
|
528
|
+
- Errors: 409 `duplicate_rule` / `rule_limit_reached` /
|
|
529
|
+
`rule_creation_in_progress`, 502 `webhook_subscription_failed` (reconnect
|
|
530
|
+
Instagram), 503 `account_rules_disabled`, 409 `rule_stopped` on resume when
|
|
531
|
+
the account left its profile.
|
|
532
|
+
|
|
381
533
|
### Audience
|
|
382
534
|
|
|
383
535
|
Where the analytics methods answer *how did my posts do*, `get_audience()`
|
|
@@ -446,6 +598,52 @@ vs non-followers), `new_followers`, `reach` and the watch times.
|
|
|
446
598
|
Asking a platform a question it cannot answer fails with
|
|
447
599
|
`platform_not_supported` and the list of the ones that can.
|
|
448
600
|
|
|
601
|
+
## Webhooks: configuration, delivery log and redelivery
|
|
602
|
+
|
|
603
|
+
```python
|
|
604
|
+
# Turn the webhook on and opt into the inbound events
|
|
605
|
+
client.update_notification_config(
|
|
606
|
+
webhook_url="https://example.com/upload-post",
|
|
607
|
+
webhook_events=["upload_completed", "dm_received", "comment_received"],
|
|
608
|
+
channels={"webhook": True},
|
|
609
|
+
)
|
|
610
|
+
print(client.get_notification_config()["notifications"]["webhook_secret"])
|
|
611
|
+
|
|
612
|
+
# Did an event reach my server? (7-day history, newest first)
|
|
613
|
+
failed = client.list_webhook_deliveries(status="failed", limit=20, include_payload=True)
|
|
614
|
+
for d in failed["deliveries"]:
|
|
615
|
+
print(d["delivery_id"], d["event"], d["attempt_count"], d["last_status_code"], d["last_error"])
|
|
616
|
+
|
|
617
|
+
# One delivery with its exact payload, and send it again
|
|
618
|
+
one = client.get_webhook_delivery(failed["deliveries"][0]["delivery_id"])
|
|
619
|
+
again = client.redeliver_webhook(one["delivery"]["delivery_id"])
|
|
620
|
+
print(again["delivery"]["delivery_id"], again["delivery"]["redelivery_of"])
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
Webhooks are delivered **at least once**: a timeout, connection error, `5xx`,
|
|
624
|
+
`408` or `429` is retried with the same body and the same
|
|
625
|
+
`X-Upload-Post-Delivery` id (6 attempts over about 8.5 hours, header
|
|
626
|
+
`X-Upload-Post-Attempt` 1-6); other `4xx` answers are final. Deduplicate by
|
|
627
|
+
`X-Upload-Post-Delivery`. A redelivery is a new delivery with its own id and
|
|
628
|
+
`X-Upload-Post-Redelivery-Of`. Filters of `list_webhook_deliveries`: `event`,
|
|
629
|
+
`status` (`pending`, `delivered`, `failed`, `cancelled`), `request_id`,
|
|
630
|
+
`job_id`, `profile_username`, `limit` (1-100), `page` (1-50). `redeliver_webhook`
|
|
631
|
+
answers 409 `webhook_removed` / `event_unsubscribed` / `blocked_url` when there
|
|
632
|
+
is nowhere to send it.
|
|
633
|
+
|
|
634
|
+
## Team members (acting on the owner's account)
|
|
635
|
+
|
|
636
|
+
```python
|
|
637
|
+
client = UploadPostClient("MEMBER_API_KEY", act_as_user="owner@example.com")
|
|
638
|
+
client.list_users() # the owner's profiles
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
Every request carries `X-Act-As-User`. If the owner's current plan no longer
|
|
642
|
+
covers your seat (the last invited members are suspended after a downgrade,
|
|
643
|
+
never removed), requests fail with `status_code` 403 and `error_code`
|
|
644
|
+
`"TEAM_SEAT_SUSPENDED"`; access comes back by itself when the owner upgrades or
|
|
645
|
+
frees a seat.
|
|
646
|
+
|
|
449
647
|
## What a TikTok connection can do (`capabilities`)
|
|
450
648
|
|
|
451
649
|
Not every TikTok connection can do the same things. `client.list_users()`
|
|
@@ -658,7 +856,7 @@ for the music, location, cover and draft options.
|
|
|
658
856
|
- `x_alt_text` - Alt text (string, `x_alt_text[]`, or JSON array; ≤1000 chars)
|
|
659
857
|
- `x_subtitles` / `x_subtitles_url` / `x_subtitles_language` / `x_subtitles_name` - Video SRT captions
|
|
660
858
|
- `x_paid_partnership` - Paid partnership flag on the first post
|
|
661
|
-
- `x_article_title` / `x_article_body` / `x_article_content_state` / `x_article_draft` / `x_article_cover_media` - X Articles (text; Premium)
|
|
859
|
+
- `x_article_title` / `x_article_body` / `x_article_content_state` / `x_article_draft` / `x_article_cover_media` - X Articles (text; Premium). Articles have two heading levels: a `### Heading` in the body, or a `header-three` block in `x_article_content_state`, is published as `header-two`
|
|
662
860
|
|
|
663
861
|
### Threads
|
|
664
862
|
- `threads_long_text_as_post` - Post long text as single post (vs thread). Over 500 UTF-8 bytes this sends an official long post instead of truncating.
|
|
@@ -711,7 +909,7 @@ client.upload_photos(
|
|
|
711
909
|
- `bluesky_alt_text` - Alt text per image/video (JSON array, `||`-separated, or string)
|
|
712
910
|
- `bluesky_langs` - Comma list, max 3 BCP-47 codes
|
|
713
911
|
- `bluesky_labels` - `porn`, `sexual`, `nudity`, `graphic-media`
|
|
714
|
-
- `bluesky_gallery` - Publish up to
|
|
912
|
+
- `bluesky_gallery` - Publish up to 10 images as a gallery (`false` keeps the first 4). With more than 10, the first 10 are published and the response carries a `warnings` entry
|
|
715
913
|
- `bluesky_threadgate` / `bluesky_reply_settings` - Who can reply
|
|
716
914
|
- `bluesky_postgate` / `bluesky_quote_settings` - `disable_quotes` to block quotes
|
|
717
915
|
- `bluesky_quote_uri` / `bluesky_quote_id` / `bluesky_quote_url` - Quote another post
|
|
@@ -796,6 +994,10 @@ try:
|
|
|
796
994
|
print("Upload successful:", response)
|
|
797
995
|
except UploadPostError as e:
|
|
798
996
|
print("Upload failed:", str(e))
|
|
997
|
+
print(e.status_code) # HTTP status, or None if no answer arrived
|
|
998
|
+
print(e.error_code) # stable code, e.g. "bluesky_dm_scope_missing", "TEAM_SEAT_SUSPENDED"
|
|
999
|
+
print(e.response) # decoded JSON body of the error
|
|
1000
|
+
print(e.retry_after) # Retry-After seconds, when the API sent it
|
|
799
1001
|
```
|
|
800
1002
|
|
|
801
1003
|
## Links
|
|
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
|
|
|
5
5
|
|
|
6
6
|
setup(
|
|
7
7
|
name="upload-post",
|
|
8
|
-
version="2.
|
|
8
|
+
version="2.15.0",
|
|
9
9
|
author="Upload-Post",
|
|
10
10
|
author_email="hi@img2html.com",
|
|
11
11
|
description="Cross-platform social media upload for TikTok, Instagram, YouTube, LinkedIn, Facebook, Pinterest, Threads, Reddit, Bluesky, Discord, Telegram, and X (Twitter)",
|