upload-post 2.10.0__tar.gz → 2.12.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.10.0 → upload_post-2.12.0}/PKG-INFO +179 -10
- {upload_post-2.10.0 → upload_post-2.12.0}/README.md +178 -9
- {upload_post-2.10.0 → upload_post-2.12.0}/setup.py +1 -1
- upload_post-2.12.0/tests/test_comments_audience.py +231 -0
- {upload_post-2.10.0 → upload_post-2.12.0}/upload_post/__init__.py +1 -1
- {upload_post-2.10.0 → upload_post-2.12.0}/upload_post/api_client.py +186 -9
- {upload_post-2.10.0 → upload_post-2.12.0}/upload_post.egg-info/PKG-INFO +179 -10
- {upload_post-2.10.0 → upload_post-2.12.0}/upload_post.egg-info/SOURCES.txt +1 -0
- {upload_post-2.10.0 → upload_post-2.12.0}/setup.cfg +0 -0
- {upload_post-2.10.0 → upload_post-2.12.0}/upload_post/cli.py +0 -0
- {upload_post-2.10.0 → upload_post-2.12.0}/upload_post.egg-info/dependency_links.txt +0 -0
- {upload_post-2.10.0 → upload_post-2.12.0}/upload_post.egg-info/requires.txt +0 -0
- {upload_post-2.10.0 → upload_post-2.12.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.12.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
|
|
@@ -76,6 +76,8 @@ print(response)
|
|
|
76
76
|
- ✅ **Posting Queue** - Add posts to your configured queue
|
|
77
77
|
- ✅ **First Comments** - Auto-post first comment after publishing
|
|
78
78
|
- ✅ **Analytics** - Get engagement metrics
|
|
79
|
+
- ✅ **Audience** - Who follows a profile, per platform
|
|
80
|
+
- ✅ **Suggestions** - Hashtags and searches to post about, per platform
|
|
79
81
|
- ✅ **Full Type Hints**
|
|
80
82
|
|
|
81
83
|
## API Reference
|
|
@@ -322,17 +324,183 @@ found = client.search_tiktok_music("my-profile", q="bad bunny", country_code="ES
|
|
|
322
324
|
locations = client.get_tiktok_locations("my-profile", "Madrid")
|
|
323
325
|
```
|
|
324
326
|
|
|
327
|
+
### Comments
|
|
328
|
+
|
|
329
|
+
The same methods cover every platform that has comments — Instagram, Facebook,
|
|
330
|
+
YouTube, LinkedIn and TikTok. There is no per-network method: the endpoint
|
|
331
|
+
answers one question and `platform` says who to ask.
|
|
332
|
+
|
|
333
|
+
```python
|
|
334
|
+
# Read the comments on a post. TikTok has no post-URL lookup: pass the video id.
|
|
335
|
+
comments = client.get_post_comments(
|
|
336
|
+
user="my-profile",
|
|
337
|
+
platform="tiktok",
|
|
338
|
+
post_id="7412345678901234567",
|
|
339
|
+
limit=20,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# Read the replies hanging from one of them — same question, one more parameter
|
|
343
|
+
replies = client.get_post_comments(
|
|
344
|
+
user="my-profile",
|
|
345
|
+
platform="tiktok",
|
|
346
|
+
post_id="7412345678901234567",
|
|
347
|
+
comment_id="7412345678909999999",
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# Comment on the post, or reply to a comment
|
|
351
|
+
client.create_comment(
|
|
352
|
+
user="my-profile",
|
|
353
|
+
platform="tiktok",
|
|
354
|
+
post_id="7412345678901234567",
|
|
355
|
+
message="Thanks for watching!",
|
|
356
|
+
)
|
|
357
|
+
client.create_comment(
|
|
358
|
+
user="my-profile",
|
|
359
|
+
platform="tiktok",
|
|
360
|
+
comment_id="7412345678909999999",
|
|
361
|
+
message="Glad you liked it",
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
# Delete one
|
|
365
|
+
client.delete_comment(
|
|
366
|
+
user="my-profile", platform="tiktok", comment_id="7412345678909999999"
|
|
367
|
+
)
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
You can also have the first comment posted for you right after publishing, with
|
|
371
|
+
`first_comment` for every platform or `tiktok_first_comment` for TikTok alone.
|
|
372
|
+
|
|
373
|
+
On TikTok all of this needs the `comments` capability, which the account grants
|
|
374
|
+
when it connects — see
|
|
375
|
+
[What a TikTok connection can do](#what-a-tiktok-connection-can-do-capabilities).
|
|
376
|
+
|
|
377
|
+
#### Moderating a comment: hide, like, pin
|
|
378
|
+
|
|
379
|
+
`comment_action()` does the three and undoes them, on any platform that
|
|
380
|
+
supports it. Each action carries its own inverse, and `post_id` is only sent
|
|
381
|
+
when the platform needs it:
|
|
382
|
+
|
|
383
|
+
```python
|
|
384
|
+
client.comment_action(
|
|
385
|
+
user="my-profile", platform="tiktok", action="hide",
|
|
386
|
+
comment_id="7412345678909999999", post_id="7412345678901234567",
|
|
387
|
+
)
|
|
388
|
+
client.comment_action(
|
|
389
|
+
user="my-profile", platform="tiktok", action="like",
|
|
390
|
+
comment_id="7412345678909999999",
|
|
391
|
+
)
|
|
392
|
+
client.comment_action(
|
|
393
|
+
user="my-profile", platform="tiktok", action="unpin",
|
|
394
|
+
comment_id="7412345678909999999", post_id="7412345678901234567",
|
|
395
|
+
)
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
| `action` | Undo | `post_id` |
|
|
399
|
+
| --- | --- | --- |
|
|
400
|
+
| `hide` | `unhide` | required |
|
|
401
|
+
| `like` | `unlike` | not sent |
|
|
402
|
+
| `pin` | `unpin` | required |
|
|
403
|
+
|
|
404
|
+
### Audience
|
|
405
|
+
|
|
406
|
+
Where the analytics methods answer *how did my posts do*, `get_audience()`
|
|
407
|
+
answers *who is following me*. One endpoint, one `platform` parameter, like
|
|
408
|
+
every other question in the API.
|
|
409
|
+
|
|
410
|
+
```python
|
|
411
|
+
audience = client.get_audience(
|
|
412
|
+
user="my-profile",
|
|
413
|
+
platform="tiktok",
|
|
414
|
+
start_date="2026-07-01",
|
|
415
|
+
end_date="2026-07-30",
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
print(audience["range"]) # the window actually used
|
|
419
|
+
print(audience["audience"]["countries"]) # and "cities", "ages", "genders"
|
|
420
|
+
print(audience["activity_by_hour"]) # [{"hour": "14", "followers_online": 1494}, ...]
|
|
421
|
+
print(audience["followers_daily"]) # [{"date", "total", "new", "lost"}, ...]
|
|
422
|
+
print(audience["profile_actions"]) # bio link, address, email, phone, leads
|
|
423
|
+
print(audience["bio_description"])
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
The window is clamped on the server: at most 60 days, and `end_date` always
|
|
427
|
+
before today. A wider window is trimmed to what the platform accepts instead of
|
|
428
|
+
failing.
|
|
429
|
+
|
|
430
|
+
Ask for a `benchmark_category` and the same call also returns how the account
|
|
431
|
+
compares with the average of that category. The accepted categories come back in
|
|
432
|
+
`benchmark_categories` on every response, so a picker needs no second call:
|
|
433
|
+
|
|
434
|
+
```python
|
|
435
|
+
categories = client.get_audience(
|
|
436
|
+
user="my-profile", platform="tiktok"
|
|
437
|
+
)["benchmark_categories"]
|
|
438
|
+
|
|
439
|
+
benchmark = client.get_audience(
|
|
440
|
+
user="my-profile", platform="tiktok",
|
|
441
|
+
benchmark_category="SOFTWARE_AND_APPS",
|
|
442
|
+
)["benchmark"]
|
|
443
|
+
print(benchmark["average_engagement_rate"], benchmark["average_video_views"])
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
### Suggestions
|
|
447
|
+
|
|
448
|
+
`get_suggestions()` answers *what is worth posting about*: the hashtags or the
|
|
449
|
+
searches a platform suggests around a keyword. One endpoint for both, told apart
|
|
450
|
+
by `type`.
|
|
451
|
+
|
|
452
|
+
```python
|
|
453
|
+
hashtags = client.get_suggestions(
|
|
454
|
+
user="my-profile", platform="tiktok", type="hashtags",
|
|
455
|
+
q="pilates", country_code="ES", language="es",
|
|
456
|
+
)["hashtags"]
|
|
457
|
+
print(hashtags) # [{"name": ..., "view_count": ...}, ...]
|
|
458
|
+
|
|
459
|
+
keywords = client.get_suggestions(
|
|
460
|
+
user="my-profile", platform="tiktok", type="keywords", q="pilates",
|
|
461
|
+
)["keywords"]
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
Per-post numbers stay in `get_post_analytics()`. On TikTok that response carries
|
|
465
|
+
more than the usual counters: `retention` (the curve, second by second),
|
|
466
|
+
`impression_sources` (For You, search, profile...), `audience_types` (followers
|
|
467
|
+
vs non-followers), `new_followers`, `reach` and the watch times.
|
|
468
|
+
|
|
469
|
+
Asking a platform a question it cannot answer fails with
|
|
470
|
+
`platform_not_supported` and the list of the ones that can.
|
|
471
|
+
|
|
472
|
+
## What a TikTok connection can do (`capabilities`)
|
|
473
|
+
|
|
474
|
+
Not every TikTok connection can do the same things. `client.list_users()`
|
|
475
|
+
(`GET /api/uploadposts/users`) returns a `capabilities` array on each profile's
|
|
476
|
+
TikTok account; check it before offering a feature.
|
|
477
|
+
|
|
478
|
+
| Capability | What it unlocks |
|
|
479
|
+
| --- | --- |
|
|
480
|
+
| `music` | `tiktok_music_id` and the volume/trim fields, plus `get_tiktok_trending_music()` and `search_tiktok_music()` |
|
|
481
|
+
| `location` | `tiktok_location_id` / `tiktok_location_name`, plus `get_tiktok_locations()` |
|
|
482
|
+
| `cover_image` | `tiktok_cover_image_url` |
|
|
483
|
+
| `cover_timestamp` | `cover_timestamp` |
|
|
484
|
+
| `draft` | `tiktok_upload_to_draft` |
|
|
485
|
+
| `video_privacy` | `privacy_level` on video |
|
|
486
|
+
| `photo_privacy` | `privacy_level` on photo posts |
|
|
487
|
+
| `profile_analytics` | `get_audience()` and `get_suggestions(type="hashtags")` with `platform="tiktok"` |
|
|
488
|
+
| `comments` | Comments on TikTok: `get_post_comments()` (top-level and replies), `create_comment()`, `delete_comment()`, `comment_action()` and `tiktok_first_comment` |
|
|
489
|
+
| `trend_search` | `get_suggestions(type="keywords")` with `platform="tiktok"` |
|
|
490
|
+
|
|
491
|
+
> **`comments` and `trend_search` need the account to be reconnected.** TikTok
|
|
492
|
+
> grants them at connect time, so an account linked before they existed keeps
|
|
493
|
+
> working for everything else but will not list them — reconnect it from Manage
|
|
494
|
+
> Users to enable them.
|
|
495
|
+
|
|
496
|
+
If a connection lacks a capability the upload field is simply ignored: the post
|
|
497
|
+
still publishes and the response carries a per-field `warnings` string. The
|
|
498
|
+
methods above answer with an error asking for a reconnection.
|
|
499
|
+
|
|
325
500
|
## TikTok music, location, cover and drafts
|
|
326
501
|
|
|
327
|
-
>
|
|
328
|
-
>
|
|
329
|
-
> `capabilities` array on the TikTok account returned by
|
|
330
|
-
> `GET /api/uploadposts/users` (`client.list_users()`). Other values that can
|
|
331
|
-
> appear there: `cover_timestamp`, `photo_privacy`, `video_privacy`,
|
|
332
|
-
> `inbox_fallback` and `profile_analytics`. If your connection does
|
|
333
|
-
> not have the capability, the field is ignored, the post still publishes, and
|
|
334
|
-
> the response includes a per-field `warnings` string — reconnect the TikTok
|
|
335
|
-
> account to enable it.
|
|
502
|
+
> Needs the `music`, `location`, `cover_image` or `draft` capability — see
|
|
503
|
+
> [What a TikTok connection can do](#what-a-tiktok-connection-can-do-capabilities).
|
|
336
504
|
|
|
337
505
|
```python
|
|
338
506
|
# 1. Pick a track and a place
|
|
@@ -525,6 +693,7 @@ These options work across all upload methods:
|
|
|
525
693
|
| `user` | Profile name (required) |
|
|
526
694
|
| `platforms` | Target platforms list (required) |
|
|
527
695
|
| `first_comment` | First comment to post |
|
|
696
|
+
| `tiktok_first_comment` | First comment for TikTok only (needs the `comments` capability) |
|
|
528
697
|
| `alt_text` | Alt text for accessibility |
|
|
529
698
|
| `scheduled_date` | ISO date for scheduling |
|
|
530
699
|
| `timezone` | Timezone for scheduled date |
|
|
@@ -38,6 +38,8 @@ print(response)
|
|
|
38
38
|
- ✅ **Posting Queue** - Add posts to your configured queue
|
|
39
39
|
- ✅ **First Comments** - Auto-post first comment after publishing
|
|
40
40
|
- ✅ **Analytics** - Get engagement metrics
|
|
41
|
+
- ✅ **Audience** - Who follows a profile, per platform
|
|
42
|
+
- ✅ **Suggestions** - Hashtags and searches to post about, per platform
|
|
41
43
|
- ✅ **Full Type Hints**
|
|
42
44
|
|
|
43
45
|
## API Reference
|
|
@@ -284,17 +286,183 @@ found = client.search_tiktok_music("my-profile", q="bad bunny", country_code="ES
|
|
|
284
286
|
locations = client.get_tiktok_locations("my-profile", "Madrid")
|
|
285
287
|
```
|
|
286
288
|
|
|
289
|
+
### Comments
|
|
290
|
+
|
|
291
|
+
The same methods cover every platform that has comments — Instagram, Facebook,
|
|
292
|
+
YouTube, LinkedIn and TikTok. There is no per-network method: the endpoint
|
|
293
|
+
answers one question and `platform` says who to ask.
|
|
294
|
+
|
|
295
|
+
```python
|
|
296
|
+
# Read the comments on a post. TikTok has no post-URL lookup: pass the video id.
|
|
297
|
+
comments = client.get_post_comments(
|
|
298
|
+
user="my-profile",
|
|
299
|
+
platform="tiktok",
|
|
300
|
+
post_id="7412345678901234567",
|
|
301
|
+
limit=20,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# Read the replies hanging from one of them — same question, one more parameter
|
|
305
|
+
replies = client.get_post_comments(
|
|
306
|
+
user="my-profile",
|
|
307
|
+
platform="tiktok",
|
|
308
|
+
post_id="7412345678901234567",
|
|
309
|
+
comment_id="7412345678909999999",
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# Comment on the post, or reply to a comment
|
|
313
|
+
client.create_comment(
|
|
314
|
+
user="my-profile",
|
|
315
|
+
platform="tiktok",
|
|
316
|
+
post_id="7412345678901234567",
|
|
317
|
+
message="Thanks for watching!",
|
|
318
|
+
)
|
|
319
|
+
client.create_comment(
|
|
320
|
+
user="my-profile",
|
|
321
|
+
platform="tiktok",
|
|
322
|
+
comment_id="7412345678909999999",
|
|
323
|
+
message="Glad you liked it",
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# Delete one
|
|
327
|
+
client.delete_comment(
|
|
328
|
+
user="my-profile", platform="tiktok", comment_id="7412345678909999999"
|
|
329
|
+
)
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
You can also have the first comment posted for you right after publishing, with
|
|
333
|
+
`first_comment` for every platform or `tiktok_first_comment` for TikTok alone.
|
|
334
|
+
|
|
335
|
+
On TikTok all of this needs the `comments` capability, which the account grants
|
|
336
|
+
when it connects — see
|
|
337
|
+
[What a TikTok connection can do](#what-a-tiktok-connection-can-do-capabilities).
|
|
338
|
+
|
|
339
|
+
#### Moderating a comment: hide, like, pin
|
|
340
|
+
|
|
341
|
+
`comment_action()` does the three and undoes them, on any platform that
|
|
342
|
+
supports it. Each action carries its own inverse, and `post_id` is only sent
|
|
343
|
+
when the platform needs it:
|
|
344
|
+
|
|
345
|
+
```python
|
|
346
|
+
client.comment_action(
|
|
347
|
+
user="my-profile", platform="tiktok", action="hide",
|
|
348
|
+
comment_id="7412345678909999999", post_id="7412345678901234567",
|
|
349
|
+
)
|
|
350
|
+
client.comment_action(
|
|
351
|
+
user="my-profile", platform="tiktok", action="like",
|
|
352
|
+
comment_id="7412345678909999999",
|
|
353
|
+
)
|
|
354
|
+
client.comment_action(
|
|
355
|
+
user="my-profile", platform="tiktok", action="unpin",
|
|
356
|
+
comment_id="7412345678909999999", post_id="7412345678901234567",
|
|
357
|
+
)
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
| `action` | Undo | `post_id` |
|
|
361
|
+
| --- | --- | --- |
|
|
362
|
+
| `hide` | `unhide` | required |
|
|
363
|
+
| `like` | `unlike` | not sent |
|
|
364
|
+
| `pin` | `unpin` | required |
|
|
365
|
+
|
|
366
|
+
### Audience
|
|
367
|
+
|
|
368
|
+
Where the analytics methods answer *how did my posts do*, `get_audience()`
|
|
369
|
+
answers *who is following me*. One endpoint, one `platform` parameter, like
|
|
370
|
+
every other question in the API.
|
|
371
|
+
|
|
372
|
+
```python
|
|
373
|
+
audience = client.get_audience(
|
|
374
|
+
user="my-profile",
|
|
375
|
+
platform="tiktok",
|
|
376
|
+
start_date="2026-07-01",
|
|
377
|
+
end_date="2026-07-30",
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
print(audience["range"]) # the window actually used
|
|
381
|
+
print(audience["audience"]["countries"]) # and "cities", "ages", "genders"
|
|
382
|
+
print(audience["activity_by_hour"]) # [{"hour": "14", "followers_online": 1494}, ...]
|
|
383
|
+
print(audience["followers_daily"]) # [{"date", "total", "new", "lost"}, ...]
|
|
384
|
+
print(audience["profile_actions"]) # bio link, address, email, phone, leads
|
|
385
|
+
print(audience["bio_description"])
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
The window is clamped on the server: at most 60 days, and `end_date` always
|
|
389
|
+
before today. A wider window is trimmed to what the platform accepts instead of
|
|
390
|
+
failing.
|
|
391
|
+
|
|
392
|
+
Ask for a `benchmark_category` and the same call also returns how the account
|
|
393
|
+
compares with the average of that category. The accepted categories come back in
|
|
394
|
+
`benchmark_categories` on every response, so a picker needs no second call:
|
|
395
|
+
|
|
396
|
+
```python
|
|
397
|
+
categories = client.get_audience(
|
|
398
|
+
user="my-profile", platform="tiktok"
|
|
399
|
+
)["benchmark_categories"]
|
|
400
|
+
|
|
401
|
+
benchmark = client.get_audience(
|
|
402
|
+
user="my-profile", platform="tiktok",
|
|
403
|
+
benchmark_category="SOFTWARE_AND_APPS",
|
|
404
|
+
)["benchmark"]
|
|
405
|
+
print(benchmark["average_engagement_rate"], benchmark["average_video_views"])
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
### Suggestions
|
|
409
|
+
|
|
410
|
+
`get_suggestions()` answers *what is worth posting about*: the hashtags or the
|
|
411
|
+
searches a platform suggests around a keyword. One endpoint for both, told apart
|
|
412
|
+
by `type`.
|
|
413
|
+
|
|
414
|
+
```python
|
|
415
|
+
hashtags = client.get_suggestions(
|
|
416
|
+
user="my-profile", platform="tiktok", type="hashtags",
|
|
417
|
+
q="pilates", country_code="ES", language="es",
|
|
418
|
+
)["hashtags"]
|
|
419
|
+
print(hashtags) # [{"name": ..., "view_count": ...}, ...]
|
|
420
|
+
|
|
421
|
+
keywords = client.get_suggestions(
|
|
422
|
+
user="my-profile", platform="tiktok", type="keywords", q="pilates",
|
|
423
|
+
)["keywords"]
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
Per-post numbers stay in `get_post_analytics()`. On TikTok that response carries
|
|
427
|
+
more than the usual counters: `retention` (the curve, second by second),
|
|
428
|
+
`impression_sources` (For You, search, profile...), `audience_types` (followers
|
|
429
|
+
vs non-followers), `new_followers`, `reach` and the watch times.
|
|
430
|
+
|
|
431
|
+
Asking a platform a question it cannot answer fails with
|
|
432
|
+
`platform_not_supported` and the list of the ones that can.
|
|
433
|
+
|
|
434
|
+
## What a TikTok connection can do (`capabilities`)
|
|
435
|
+
|
|
436
|
+
Not every TikTok connection can do the same things. `client.list_users()`
|
|
437
|
+
(`GET /api/uploadposts/users`) returns a `capabilities` array on each profile's
|
|
438
|
+
TikTok account; check it before offering a feature.
|
|
439
|
+
|
|
440
|
+
| Capability | What it unlocks |
|
|
441
|
+
| --- | --- |
|
|
442
|
+
| `music` | `tiktok_music_id` and the volume/trim fields, plus `get_tiktok_trending_music()` and `search_tiktok_music()` |
|
|
443
|
+
| `location` | `tiktok_location_id` / `tiktok_location_name`, plus `get_tiktok_locations()` |
|
|
444
|
+
| `cover_image` | `tiktok_cover_image_url` |
|
|
445
|
+
| `cover_timestamp` | `cover_timestamp` |
|
|
446
|
+
| `draft` | `tiktok_upload_to_draft` |
|
|
447
|
+
| `video_privacy` | `privacy_level` on video |
|
|
448
|
+
| `photo_privacy` | `privacy_level` on photo posts |
|
|
449
|
+
| `profile_analytics` | `get_audience()` and `get_suggestions(type="hashtags")` with `platform="tiktok"` |
|
|
450
|
+
| `comments` | Comments on TikTok: `get_post_comments()` (top-level and replies), `create_comment()`, `delete_comment()`, `comment_action()` and `tiktok_first_comment` |
|
|
451
|
+
| `trend_search` | `get_suggestions(type="keywords")` with `platform="tiktok"` |
|
|
452
|
+
|
|
453
|
+
> **`comments` and `trend_search` need the account to be reconnected.** TikTok
|
|
454
|
+
> grants them at connect time, so an account linked before they existed keeps
|
|
455
|
+
> working for everything else but will not list them — reconnect it from Manage
|
|
456
|
+
> Users to enable them.
|
|
457
|
+
|
|
458
|
+
If a connection lacks a capability the upload field is simply ignored: the post
|
|
459
|
+
still publishes and the response carries a per-field `warnings` string. The
|
|
460
|
+
methods above answer with an error asking for a reconnection.
|
|
461
|
+
|
|
287
462
|
## TikTok music, location, cover and drafts
|
|
288
463
|
|
|
289
|
-
>
|
|
290
|
-
>
|
|
291
|
-
> `capabilities` array on the TikTok account returned by
|
|
292
|
-
> `GET /api/uploadposts/users` (`client.list_users()`). Other values that can
|
|
293
|
-
> appear there: `cover_timestamp`, `photo_privacy`, `video_privacy`,
|
|
294
|
-
> `inbox_fallback` and `profile_analytics`. If your connection does
|
|
295
|
-
> not have the capability, the field is ignored, the post still publishes, and
|
|
296
|
-
> the response includes a per-field `warnings` string — reconnect the TikTok
|
|
297
|
-
> account to enable it.
|
|
464
|
+
> Needs the `music`, `location`, `cover_image` or `draft` capability — see
|
|
465
|
+
> [What a TikTok connection can do](#what-a-tiktok-connection-can-do-capabilities).
|
|
298
466
|
|
|
299
467
|
```python
|
|
300
468
|
# 1. Pick a track and a place
|
|
@@ -487,6 +655,7 @@ These options work across all upload methods:
|
|
|
487
655
|
| `user` | Profile name (required) |
|
|
488
656
|
| `platforms` | Target platforms list (required) |
|
|
489
657
|
| `first_comment` | First comment to post |
|
|
658
|
+
| `tiktok_first_comment` | First comment for TikTok only (needs the `comments` capability) |
|
|
490
659
|
| `alt_text` | Alt text for accessibility |
|
|
491
660
|
| `scheduled_date` | ISO date for scheduling |
|
|
492
661
|
| `timezone` | Timezone for scheduled date |
|
|
@@ -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.12.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)",
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Tests for the question-shaped endpoints.
|
|
2
|
+
|
|
3
|
+
Comments (list, replies, moderate), audience and suggestions are all one
|
|
4
|
+
endpoint each with a `platform` parameter, not a method per network. TikTok is
|
|
5
|
+
the platform used here because it is the one that answers all of them today.
|
|
6
|
+
|
|
7
|
+
No network: the session's HTTP verbs are patched, so every assertion is about
|
|
8
|
+
what the client would have sent.
|
|
9
|
+
|
|
10
|
+
Run with: python -m unittest discover -s tests
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import unittest
|
|
14
|
+
from unittest.mock import MagicMock, patch
|
|
15
|
+
|
|
16
|
+
from upload_post import UploadPostClient, UploadPostError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _response(payload=None, status=200):
|
|
20
|
+
response = MagicMock()
|
|
21
|
+
response.status_code = status
|
|
22
|
+
response.json.return_value = payload if payload is not None else {"success": True}
|
|
23
|
+
response.raise_for_status.return_value = None
|
|
24
|
+
return response
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ClientTestCase(unittest.TestCase):
|
|
28
|
+
def setUp(self):
|
|
29
|
+
self.client = UploadPostClient("test-key")
|
|
30
|
+
self.get = patch.object(self.client.session, "get",
|
|
31
|
+
return_value=_response()).start()
|
|
32
|
+
self.post = patch.object(self.client.session, "post",
|
|
33
|
+
return_value=_response()).start()
|
|
34
|
+
self.delete = patch.object(self.client.session, "delete",
|
|
35
|
+
return_value=_response()).start()
|
|
36
|
+
self.addCleanup(patch.stopall)
|
|
37
|
+
|
|
38
|
+
def assertGet(self, mock, endpoint, params):
|
|
39
|
+
url, kwargs = mock.call_args[0][0], mock.call_args[1]
|
|
40
|
+
self.assertEqual(url, f"https://api.upload-post.com/api{endpoint}")
|
|
41
|
+
self.assertEqual(kwargs["params"], params)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TestComments(ClientTestCase):
|
|
45
|
+
"""One /comments endpoint, told who to ask with platform."""
|
|
46
|
+
|
|
47
|
+
def test_get_post_comments(self):
|
|
48
|
+
self.client.get_post_comments(
|
|
49
|
+
user="my-profile", platform="tiktok",
|
|
50
|
+
post_id="7412345678901234567", limit=20,
|
|
51
|
+
)
|
|
52
|
+
self.assertGet(self.get, "/uploadposts/comments", {
|
|
53
|
+
"platform": "tiktok", "user": "my-profile",
|
|
54
|
+
"post_id": "7412345678901234567", "limit": 20,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
def test_comment_id_asks_for_the_replies(self):
|
|
58
|
+
self.client.get_post_comments(
|
|
59
|
+
user="my-profile", platform="tiktok",
|
|
60
|
+
post_id="741", comment_id="99", limit=20,
|
|
61
|
+
)
|
|
62
|
+
self.assertGet(self.get, "/uploadposts/comments", {
|
|
63
|
+
"platform": "tiktok", "user": "my-profile",
|
|
64
|
+
"post_id": "741", "comment_id": "99", "limit": 20,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
def test_comment_id_is_omitted_when_not_asked_for(self):
|
|
68
|
+
self.client.get_post_comments(
|
|
69
|
+
user="my-profile", platform="tiktok", post_id="741"
|
|
70
|
+
)
|
|
71
|
+
self.assertGet(self.get, "/uploadposts/comments", {
|
|
72
|
+
"platform": "tiktok", "user": "my-profile", "post_id": "741",
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
def test_create_comment(self):
|
|
76
|
+
self.client.create_comment(
|
|
77
|
+
user="my-profile", platform="tiktok", post_id="741", message="hola"
|
|
78
|
+
)
|
|
79
|
+
self.assertEqual(self.post.call_args[1]["json"], {
|
|
80
|
+
"platform": "tiktok", "user": "my-profile",
|
|
81
|
+
"message": "hola", "post_id": "741",
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
def test_delete_comment(self):
|
|
85
|
+
self.client.delete_comment(
|
|
86
|
+
user="my-profile", platform="tiktok", comment_id="99"
|
|
87
|
+
)
|
|
88
|
+
self.assertEqual(self.delete.call_args[1]["json"], {
|
|
89
|
+
"platform": "tiktok", "user": "my-profile", "comment_id": "99",
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class TestFirstComment(ClientTestCase):
|
|
94
|
+
def test_tiktok_first_comment_is_forwarded_like_the_rest(self):
|
|
95
|
+
data = []
|
|
96
|
+
self.client._add_common_params(
|
|
97
|
+
data, user="my-profile", title="hi", platforms=["tiktok"],
|
|
98
|
+
first_comment="shared",
|
|
99
|
+
tiktok_first_comment="solo TikTok",
|
|
100
|
+
instagram_first_comment="solo IG",
|
|
101
|
+
)
|
|
102
|
+
fields = dict(data)
|
|
103
|
+
self.assertEqual(fields["first_comment"], "shared")
|
|
104
|
+
self.assertEqual(fields["tiktok_first_comment"], "solo TikTok")
|
|
105
|
+
self.assertEqual(fields["instagram_first_comment"], "solo IG")
|
|
106
|
+
|
|
107
|
+
def test_tiktok_is_not_filtered_out_of_the_upload(self):
|
|
108
|
+
self.client.upload_text(
|
|
109
|
+
user="my-profile", platforms=["tiktok"], title="hi",
|
|
110
|
+
tiktok_first_comment="primero",
|
|
111
|
+
)
|
|
112
|
+
fields = dict(self.post.call_args[1]["data"])
|
|
113
|
+
self.assertEqual(fields["tiktok_first_comment"], "primero")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class TestCommentAction(ClientTestCase):
|
|
117
|
+
def test_hide_and_pin_carry_the_post(self):
|
|
118
|
+
for action in ("hide", "unhide", "pin", "unpin"):
|
|
119
|
+
with self.subTest(action=action):
|
|
120
|
+
self.client.comment_action(
|
|
121
|
+
user="my-profile", platform="tiktok", action=action,
|
|
122
|
+
comment_id="99", post_id="741",
|
|
123
|
+
)
|
|
124
|
+
self.assertEqual(self.post.call_args[0][0],
|
|
125
|
+
"https://api.upload-post.com/api"
|
|
126
|
+
"/uploadposts/comments/action")
|
|
127
|
+
self.assertEqual(self.post.call_args[1]["json"], {
|
|
128
|
+
"platform": "tiktok", "user": "my-profile",
|
|
129
|
+
"comment_id": "99", "action": action, "post_id": "741",
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
def test_like_never_carries_the_post(self):
|
|
133
|
+
for action in ("like", "unlike"):
|
|
134
|
+
with self.subTest(action=action):
|
|
135
|
+
self.client.comment_action(
|
|
136
|
+
user="my-profile", platform="tiktok", action=action,
|
|
137
|
+
comment_id="99", post_id="741",
|
|
138
|
+
)
|
|
139
|
+
self.assertEqual(self.post.call_args[1]["json"], {
|
|
140
|
+
"platform": "tiktok", "user": "my-profile",
|
|
141
|
+
"comment_id": "99", "action": action,
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class TestAudience(ClientTestCase):
|
|
146
|
+
def test_window_travels_as_snake_case(self):
|
|
147
|
+
self.client.get_audience(
|
|
148
|
+
user="my-profile", platform="tiktok",
|
|
149
|
+
start_date="2026-07-01", end_date="2026-07-30",
|
|
150
|
+
)
|
|
151
|
+
self.assertGet(self.get, "/uploadposts/audience", {
|
|
152
|
+
"user": "my-profile", "platform": "tiktok",
|
|
153
|
+
"start_date": "2026-07-01", "end_date": "2026-07-30",
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
def test_default_window_is_left_to_the_api(self):
|
|
157
|
+
self.client.get_audience(user="my-profile", platform="tiktok")
|
|
158
|
+
self.assertGet(self.get, "/uploadposts/audience",
|
|
159
|
+
{"user": "my-profile", "platform": "tiktok"})
|
|
160
|
+
|
|
161
|
+
def test_benchmark_category_rides_along(self):
|
|
162
|
+
self.client.get_audience(
|
|
163
|
+
user="my-profile", platform="tiktok",
|
|
164
|
+
benchmark_category="SOFTWARE_AND_APPS",
|
|
165
|
+
)
|
|
166
|
+
self.assertGet(self.get, "/uploadposts/audience", {
|
|
167
|
+
"user": "my-profile", "platform": "tiktok",
|
|
168
|
+
"benchmark_category": "SOFTWARE_AND_APPS",
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class TestSuggestions(ClientTestCase):
|
|
173
|
+
def test_hashtags_take_country_and_language(self):
|
|
174
|
+
self.client.get_suggestions(
|
|
175
|
+
user="my-profile", platform="tiktok", type="hashtags",
|
|
176
|
+
q="pilates", country_code="ES", language="es",
|
|
177
|
+
)
|
|
178
|
+
self.assertGet(self.get, "/uploadposts/suggestions", {
|
|
179
|
+
"user": "my-profile", "platform": "tiktok", "type": "hashtags",
|
|
180
|
+
"q": "pilates", "country_code": "ES", "language": "es",
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
def test_keywords_use_the_same_call(self):
|
|
184
|
+
self.client.get_suggestions(
|
|
185
|
+
user="my-profile", platform="tiktok", type="keywords", q="pilates"
|
|
186
|
+
)
|
|
187
|
+
self.assertGet(self.get, "/uploadposts/suggestions", {
|
|
188
|
+
"user": "my-profile", "platform": "tiktok",
|
|
189
|
+
"type": "keywords", "q": "pilates",
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class TestTheOldPerNetworkMethodsAreGone(ClientTestCase):
|
|
194
|
+
def test_nothing_answers_the_old_names(self):
|
|
195
|
+
for name in (
|
|
196
|
+
"get_tiktok_comment_replies", "tiktok_comment_action",
|
|
197
|
+
"search_tiktok_keywords", "get_tiktok_insights",
|
|
198
|
+
"get_tiktok_video_insights", "get_tiktok_hashtags",
|
|
199
|
+
"get_tiktok_benchmark",
|
|
200
|
+
):
|
|
201
|
+
with self.subTest(name=name):
|
|
202
|
+
self.assertFalse(hasattr(self.client, name))
|
|
203
|
+
|
|
204
|
+
def test_the_composer_helpers_stay(self):
|
|
205
|
+
for name in (
|
|
206
|
+
"get_tiktok_trending_music", "search_tiktok_music",
|
|
207
|
+
"get_tiktok_locations", "get_tiktok_publishing_settings",
|
|
208
|
+
):
|
|
209
|
+
with self.subTest(name=name):
|
|
210
|
+
self.assertTrue(hasattr(self.client, name))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class TestErrors(ClientTestCase):
|
|
214
|
+
def test_api_error_keeps_the_message_the_api_sent(self):
|
|
215
|
+
import requests
|
|
216
|
+
|
|
217
|
+
failed = _response({"message": "platform_not_supported"}, status=400)
|
|
218
|
+
error = requests.exceptions.HTTPError("400 Client Error")
|
|
219
|
+
error.response = failed
|
|
220
|
+
failed.raise_for_status.side_effect = error
|
|
221
|
+
self.get.return_value = failed
|
|
222
|
+
|
|
223
|
+
with self.assertRaises(UploadPostError) as caught:
|
|
224
|
+
self.client.get_suggestions(
|
|
225
|
+
user="my-profile", platform="x", type="hashtags"
|
|
226
|
+
)
|
|
227
|
+
self.assertIn("platform_not_supported", str(caught.exception))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
if __name__ == "__main__":
|
|
231
|
+
unittest.main()
|
|
@@ -151,11 +151,13 @@ class UploadPostClient:
|
|
|
151
151
|
if kwargs.get(key):
|
|
152
152
|
data.append((key, kwargs[key]))
|
|
153
153
|
|
|
154
|
-
# Platform-specific first comment overrides
|
|
154
|
+
# Platform-specific first comment overrides. TikTok needs the
|
|
155
|
+
# `comments` capability on the connection: without it the post still
|
|
156
|
+
# publishes and the response carries a warning instead of the comment.
|
|
155
157
|
comment_overrides = [
|
|
156
158
|
"instagram_first_comment", "facebook_first_comment", "x_first_comment",
|
|
157
159
|
"threads_first_comment", "youtube_first_comment", "reddit_first_comment",
|
|
158
|
-
"bluesky_first_comment", "linkedin_first_comment"
|
|
160
|
+
"bluesky_first_comment", "linkedin_first_comment", "tiktok_first_comment"
|
|
159
161
|
]
|
|
160
162
|
for key in comment_overrides:
|
|
161
163
|
if kwargs.get(key):
|
|
@@ -1193,6 +1195,13 @@ class UploadPostClient:
|
|
|
1193
1195
|
"""
|
|
1194
1196
|
Get analytics for a specific post across all platforms it was published to.
|
|
1195
1197
|
|
|
1198
|
+
'post_metrics' carries whatever the platform reports, so it is not the
|
|
1199
|
+
same shape everywhere: on TikTok it also brings 'retention' (the curve,
|
|
1200
|
+
second by second), 'impression_sources' (For You, search, profile...),
|
|
1201
|
+
'audience_types' (followers vs non-followers), 'new_followers', 'reach'
|
|
1202
|
+
and the watch times ('average_time_watched', 'total_time_watched',
|
|
1203
|
+
'full_video_watched_rate').
|
|
1204
|
+
|
|
1196
1205
|
Args:
|
|
1197
1206
|
request_id: The request_id from the upload.
|
|
1198
1207
|
|
|
@@ -1263,6 +1272,95 @@ class UploadPostClient:
|
|
|
1263
1272
|
params["until"] = until
|
|
1264
1273
|
return self._request("/uploadposts/post-analytics/cached", "GET", params=params)
|
|
1265
1274
|
|
|
1275
|
+
def get_audience(
|
|
1276
|
+
self,
|
|
1277
|
+
user: str,
|
|
1278
|
+
platform: str,
|
|
1279
|
+
start_date: Optional[str] = None,
|
|
1280
|
+
end_date: Optional[str] = None,
|
|
1281
|
+
benchmark_category: Optional[str] = None
|
|
1282
|
+
) -> Dict:
|
|
1283
|
+
"""
|
|
1284
|
+
Get who the audience is: where they are, how old they are, when they
|
|
1285
|
+
are online and what they tap on the profile.
|
|
1286
|
+
|
|
1287
|
+
Where the post-analytics methods answer how did my posts do, this one
|
|
1288
|
+
answers who is following me. It is one endpoint for every platform,
|
|
1289
|
+
chosen with `platform`; a platform that cannot answer it comes back
|
|
1290
|
+
with "platform_not_supported" and the list of the ones that can.
|
|
1291
|
+
|
|
1292
|
+
The window is clamped server-side: at most 60 days, and end_date always
|
|
1293
|
+
before today. A wider window is trimmed rather than rejected.
|
|
1294
|
+
|
|
1295
|
+
Args:
|
|
1296
|
+
user: Profile username.
|
|
1297
|
+
platform: Platform to ask (tiktok).
|
|
1298
|
+
start_date: Window start, ISO "YYYY-MM-DD".
|
|
1299
|
+
end_date: Window end, ISO "YYYY-MM-DD".
|
|
1300
|
+
benchmark_category: Compare the account against this category's
|
|
1301
|
+
averages, e.g. "SOFTWARE_AND_APPS". Every accepted value comes
|
|
1302
|
+
back in "benchmark_categories" on any response, so a picker
|
|
1303
|
+
needs no second call.
|
|
1304
|
+
|
|
1305
|
+
Returns:
|
|
1306
|
+
'range', 'audience' (countries, cities, ages, genders),
|
|
1307
|
+
'activity_by_hour', 'followers_daily', 'profile_actions',
|
|
1308
|
+
'bio_description', 'benchmark_categories' and, with a category,
|
|
1309
|
+
'benchmark'.
|
|
1310
|
+
"""
|
|
1311
|
+
params: Dict[str, Any] = {"user": user, "platform": platform}
|
|
1312
|
+
if start_date:
|
|
1313
|
+
params["start_date"] = start_date
|
|
1314
|
+
if end_date:
|
|
1315
|
+
params["end_date"] = end_date
|
|
1316
|
+
if benchmark_category:
|
|
1317
|
+
params["benchmark_category"] = benchmark_category
|
|
1318
|
+
return self._request("/uploadposts/audience", "GET", params=params)
|
|
1319
|
+
|
|
1320
|
+
def get_suggestions(
|
|
1321
|
+
self,
|
|
1322
|
+
user: str,
|
|
1323
|
+
platform: str,
|
|
1324
|
+
type: str,
|
|
1325
|
+
q: Optional[str] = None,
|
|
1326
|
+
country_code: Optional[str] = None,
|
|
1327
|
+
language: Optional[str] = None
|
|
1328
|
+
) -> Dict:
|
|
1329
|
+
"""
|
|
1330
|
+
Get what to write about: the hashtags or the searches a platform
|
|
1331
|
+
suggests around a keyword.
|
|
1332
|
+
|
|
1333
|
+
One endpoint for both questions, told apart by `type`:
|
|
1334
|
+
|
|
1335
|
+
type answers
|
|
1336
|
+
hashtags {"hashtags": [{"name": ..., "view_count": ...}]}
|
|
1337
|
+
keywords {"keywords": [...]}
|
|
1338
|
+
|
|
1339
|
+
Chosen with `platform`, like every other question in the API; a
|
|
1340
|
+
platform that cannot answer it comes back with
|
|
1341
|
+
"platform_not_supported" and the list of the ones that can.
|
|
1342
|
+
|
|
1343
|
+
Args:
|
|
1344
|
+
user: Profile username.
|
|
1345
|
+
platform: Platform to ask (tiktok).
|
|
1346
|
+
type: Which suggestions you want: "hashtags" or "keywords".
|
|
1347
|
+
q: Keyword to get suggestions around.
|
|
1348
|
+
country_code: ISO country code to bias the suggestions.
|
|
1349
|
+
language: Language code to bias the suggestions.
|
|
1350
|
+
|
|
1351
|
+
Returns:
|
|
1352
|
+
{"success": True, "platform": ..., "type": ..., "hashtags": [...]}
|
|
1353
|
+
or {"success": True, "platform": ..., "type": ..., "keywords": [...]}
|
|
1354
|
+
"""
|
|
1355
|
+
params: Dict[str, Any] = {"user": user, "platform": platform, "type": type}
|
|
1356
|
+
if q:
|
|
1357
|
+
params["q"] = q
|
|
1358
|
+
if country_code:
|
|
1359
|
+
params["country_code"] = country_code
|
|
1360
|
+
if language:
|
|
1361
|
+
params["language"] = language
|
|
1362
|
+
return self._request("/uploadposts/suggestions", "GET", params=params)
|
|
1363
|
+
|
|
1266
1364
|
def get_platform_metrics(self) -> Dict:
|
|
1267
1365
|
"""
|
|
1268
1366
|
Get available metrics configuration for all supported platforms.
|
|
@@ -1576,7 +1674,7 @@ class UploadPostClient:
|
|
|
1576
1674
|
params = {"profile": profile} if profile else None
|
|
1577
1675
|
return self._request("/uploadposts/pinterest/boards", "GET", params=params)
|
|
1578
1676
|
|
|
1579
|
-
# ====================
|
|
1677
|
+
# ==================== Comments ====================
|
|
1580
1678
|
|
|
1581
1679
|
def get_post_comments(
|
|
1582
1680
|
self,
|
|
@@ -1585,18 +1683,33 @@ class UploadPostClient:
|
|
|
1585
1683
|
post_id: Optional[str] = None,
|
|
1586
1684
|
post_url: Optional[str] = None,
|
|
1587
1685
|
limit: Optional[int] = None,
|
|
1588
|
-
after: Optional[str] = None
|
|
1686
|
+
after: Optional[str] = None,
|
|
1687
|
+
comment_id: Optional[str] = None
|
|
1589
1688
|
) -> Dict:
|
|
1590
1689
|
"""
|
|
1591
|
-
Get comments on a post.
|
|
1690
|
+
Get comments on a post, or the replies hanging from one of them.
|
|
1691
|
+
|
|
1692
|
+
Pass comment_id to read that comment's replies instead of the post's
|
|
1693
|
+
top-level comments. It is the same question - what was said here - so it
|
|
1694
|
+
is the same endpoint with one more parameter, not a method per platform.
|
|
1695
|
+
|
|
1696
|
+
On TikTok this needs the `comments` capability on the connection (see
|
|
1697
|
+
`capabilities` on the TikTok account returned by list_users()). TikTok
|
|
1698
|
+
grants it at connect time, so an account connected earlier has to be
|
|
1699
|
+
reconnected.
|
|
1592
1700
|
|
|
1593
1701
|
Args:
|
|
1594
1702
|
user: Profile username.
|
|
1595
|
-
platform: Platform name (instagram, facebook, youtube, linkedin
|
|
1596
|
-
|
|
1703
|
+
platform: Platform name (instagram, facebook, youtube, linkedin,
|
|
1704
|
+
tiktok).
|
|
1705
|
+
post_id: Post/media ID (provide post_id or post_url). Required on
|
|
1706
|
+
TikTok, which has no post URL lookup.
|
|
1597
1707
|
post_url: Full post URL (provide post_id or post_url).
|
|
1598
1708
|
limit: Maximum number of comments to return.
|
|
1599
1709
|
after: Pagination cursor for the next page of comments.
|
|
1710
|
+
comment_id: Read the replies to this comment instead of the post's
|
|
1711
|
+
top-level comments. Keyword-only in practice: it goes last so
|
|
1712
|
+
the existing positional order keeps working.
|
|
1600
1713
|
|
|
1601
1714
|
Returns:
|
|
1602
1715
|
Comments data including comment IDs, text, timestamps, and user info.
|
|
@@ -1606,6 +1719,8 @@ class UploadPostClient:
|
|
|
1606
1719
|
params["post_id"] = post_id
|
|
1607
1720
|
if post_url:
|
|
1608
1721
|
params["post_url"] = post_url
|
|
1722
|
+
if comment_id:
|
|
1723
|
+
params["comment_id"] = comment_id
|
|
1609
1724
|
if limit is not None:
|
|
1610
1725
|
params["limit"] = limit
|
|
1611
1726
|
if after is not None:
|
|
@@ -1678,9 +1793,15 @@ class UploadPostClient:
|
|
|
1678
1793
|
"""
|
|
1679
1794
|
Create a comment on a post, or reply to an existing comment.
|
|
1680
1795
|
|
|
1796
|
+
On TikTok this needs the `comments` capability on the connection (see
|
|
1797
|
+
`capabilities` on the TikTok account returned by list_users()). TikTok
|
|
1798
|
+
grants it at connect time, so an account connected earlier has to be
|
|
1799
|
+
reconnected.
|
|
1800
|
+
|
|
1681
1801
|
Args:
|
|
1682
1802
|
user: Profile username.
|
|
1683
|
-
platform: Platform name (instagram, facebook, youtube, linkedin
|
|
1803
|
+
platform: Platform name (instagram, facebook, youtube, linkedin,
|
|
1804
|
+
tiktok).
|
|
1684
1805
|
message: Comment text.
|
|
1685
1806
|
post_id: Post/media ID to comment on.
|
|
1686
1807
|
post_url: Full post URL to comment on.
|
|
@@ -1714,9 +1835,15 @@ class UploadPostClient:
|
|
|
1714
1835
|
"""
|
|
1715
1836
|
Delete a comment on a post.
|
|
1716
1837
|
|
|
1838
|
+
On TikTok this needs the `comments` capability on the connection (see
|
|
1839
|
+
`capabilities` on the TikTok account returned by list_users()). TikTok
|
|
1840
|
+
grants it at connect time, so an account connected earlier has to be
|
|
1841
|
+
reconnected.
|
|
1842
|
+
|
|
1717
1843
|
Args:
|
|
1718
1844
|
user: Profile username.
|
|
1719
|
-
platform: Platform name (instagram, facebook, youtube, linkedin
|
|
1845
|
+
platform: Platform name (instagram, facebook, youtube, linkedin,
|
|
1846
|
+
tiktok).
|
|
1720
1847
|
comment_id: Comment ID to delete.
|
|
1721
1848
|
post_id: Post URN (required for LinkedIn).
|
|
1722
1849
|
|
|
@@ -1732,6 +1859,56 @@ class UploadPostClient:
|
|
|
1732
1859
|
json_data["post_id"] = post_id
|
|
1733
1860
|
return self._request("/uploadposts/comments/delete", "DELETE", json_data=json_data)
|
|
1734
1861
|
|
|
1862
|
+
def comment_action(
|
|
1863
|
+
self,
|
|
1864
|
+
user: str,
|
|
1865
|
+
platform: str,
|
|
1866
|
+
comment_id: str,
|
|
1867
|
+
action: str,
|
|
1868
|
+
post_id: Optional[str] = None
|
|
1869
|
+
) -> Dict:
|
|
1870
|
+
"""
|
|
1871
|
+
Moderate a comment: hide, like or pin it - and undo any of the three.
|
|
1872
|
+
|
|
1873
|
+
One method for every platform, because it is one question: do this to
|
|
1874
|
+
that comment. Each action carries its own inverse:
|
|
1875
|
+
|
|
1876
|
+
action post_id
|
|
1877
|
+
hide / unhide required
|
|
1878
|
+
like / unlike not sent (the platform likes the comment alone)
|
|
1879
|
+
pin / unpin required
|
|
1880
|
+
|
|
1881
|
+
On TikTok this needs the `comments` capability on the connection (see
|
|
1882
|
+
`capabilities` on the TikTok account returned by list_users()). TikTok
|
|
1883
|
+
grants it at connect time, so an account connected earlier has to be
|
|
1884
|
+
reconnected.
|
|
1885
|
+
|
|
1886
|
+
Args:
|
|
1887
|
+
user: Profile username.
|
|
1888
|
+
platform: Platform name (tiktok).
|
|
1889
|
+
comment_id: Comment to act on.
|
|
1890
|
+
action: What to do: "hide", "unhide", "like", "unlike", "pin" or
|
|
1891
|
+
"unpin".
|
|
1892
|
+
post_id: Native post ID. Required for hide/unhide and pin/unpin;
|
|
1893
|
+
never sent for like/unlike.
|
|
1894
|
+
|
|
1895
|
+
Returns:
|
|
1896
|
+
{"success": True, "platform": ..., "action": ...,
|
|
1897
|
+
"comment_id": ..., "result": {...}}
|
|
1898
|
+
"""
|
|
1899
|
+
json_data: Dict[str, Any] = {
|
|
1900
|
+
"platform": platform,
|
|
1901
|
+
"user": user,
|
|
1902
|
+
"comment_id": comment_id,
|
|
1903
|
+
"action": action,
|
|
1904
|
+
}
|
|
1905
|
+
# Liking takes the comment alone; hiding and pinning need the post too.
|
|
1906
|
+
if action not in ("like", "unlike") and post_id:
|
|
1907
|
+
json_data["post_id"] = post_id
|
|
1908
|
+
return self._request(
|
|
1909
|
+
"/uploadposts/comments/action", "POST", json_data=json_data
|
|
1910
|
+
)
|
|
1911
|
+
|
|
1735
1912
|
# ==================== TikTok ====================
|
|
1736
1913
|
|
|
1737
1914
|
def get_tiktok_trending_music(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: upload-post
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.12.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
|
|
@@ -76,6 +76,8 @@ print(response)
|
|
|
76
76
|
- ✅ **Posting Queue** - Add posts to your configured queue
|
|
77
77
|
- ✅ **First Comments** - Auto-post first comment after publishing
|
|
78
78
|
- ✅ **Analytics** - Get engagement metrics
|
|
79
|
+
- ✅ **Audience** - Who follows a profile, per platform
|
|
80
|
+
- ✅ **Suggestions** - Hashtags and searches to post about, per platform
|
|
79
81
|
- ✅ **Full Type Hints**
|
|
80
82
|
|
|
81
83
|
## API Reference
|
|
@@ -322,17 +324,183 @@ found = client.search_tiktok_music("my-profile", q="bad bunny", country_code="ES
|
|
|
322
324
|
locations = client.get_tiktok_locations("my-profile", "Madrid")
|
|
323
325
|
```
|
|
324
326
|
|
|
327
|
+
### Comments
|
|
328
|
+
|
|
329
|
+
The same methods cover every platform that has comments — Instagram, Facebook,
|
|
330
|
+
YouTube, LinkedIn and TikTok. There is no per-network method: the endpoint
|
|
331
|
+
answers one question and `platform` says who to ask.
|
|
332
|
+
|
|
333
|
+
```python
|
|
334
|
+
# Read the comments on a post. TikTok has no post-URL lookup: pass the video id.
|
|
335
|
+
comments = client.get_post_comments(
|
|
336
|
+
user="my-profile",
|
|
337
|
+
platform="tiktok",
|
|
338
|
+
post_id="7412345678901234567",
|
|
339
|
+
limit=20,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# Read the replies hanging from one of them — same question, one more parameter
|
|
343
|
+
replies = client.get_post_comments(
|
|
344
|
+
user="my-profile",
|
|
345
|
+
platform="tiktok",
|
|
346
|
+
post_id="7412345678901234567",
|
|
347
|
+
comment_id="7412345678909999999",
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# Comment on the post, or reply to a comment
|
|
351
|
+
client.create_comment(
|
|
352
|
+
user="my-profile",
|
|
353
|
+
platform="tiktok",
|
|
354
|
+
post_id="7412345678901234567",
|
|
355
|
+
message="Thanks for watching!",
|
|
356
|
+
)
|
|
357
|
+
client.create_comment(
|
|
358
|
+
user="my-profile",
|
|
359
|
+
platform="tiktok",
|
|
360
|
+
comment_id="7412345678909999999",
|
|
361
|
+
message="Glad you liked it",
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
# Delete one
|
|
365
|
+
client.delete_comment(
|
|
366
|
+
user="my-profile", platform="tiktok", comment_id="7412345678909999999"
|
|
367
|
+
)
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
You can also have the first comment posted for you right after publishing, with
|
|
371
|
+
`first_comment` for every platform or `tiktok_first_comment` for TikTok alone.
|
|
372
|
+
|
|
373
|
+
On TikTok all of this needs the `comments` capability, which the account grants
|
|
374
|
+
when it connects — see
|
|
375
|
+
[What a TikTok connection can do](#what-a-tiktok-connection-can-do-capabilities).
|
|
376
|
+
|
|
377
|
+
#### Moderating a comment: hide, like, pin
|
|
378
|
+
|
|
379
|
+
`comment_action()` does the three and undoes them, on any platform that
|
|
380
|
+
supports it. Each action carries its own inverse, and `post_id` is only sent
|
|
381
|
+
when the platform needs it:
|
|
382
|
+
|
|
383
|
+
```python
|
|
384
|
+
client.comment_action(
|
|
385
|
+
user="my-profile", platform="tiktok", action="hide",
|
|
386
|
+
comment_id="7412345678909999999", post_id="7412345678901234567",
|
|
387
|
+
)
|
|
388
|
+
client.comment_action(
|
|
389
|
+
user="my-profile", platform="tiktok", action="like",
|
|
390
|
+
comment_id="7412345678909999999",
|
|
391
|
+
)
|
|
392
|
+
client.comment_action(
|
|
393
|
+
user="my-profile", platform="tiktok", action="unpin",
|
|
394
|
+
comment_id="7412345678909999999", post_id="7412345678901234567",
|
|
395
|
+
)
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
| `action` | Undo | `post_id` |
|
|
399
|
+
| --- | --- | --- |
|
|
400
|
+
| `hide` | `unhide` | required |
|
|
401
|
+
| `like` | `unlike` | not sent |
|
|
402
|
+
| `pin` | `unpin` | required |
|
|
403
|
+
|
|
404
|
+
### Audience
|
|
405
|
+
|
|
406
|
+
Where the analytics methods answer *how did my posts do*, `get_audience()`
|
|
407
|
+
answers *who is following me*. One endpoint, one `platform` parameter, like
|
|
408
|
+
every other question in the API.
|
|
409
|
+
|
|
410
|
+
```python
|
|
411
|
+
audience = client.get_audience(
|
|
412
|
+
user="my-profile",
|
|
413
|
+
platform="tiktok",
|
|
414
|
+
start_date="2026-07-01",
|
|
415
|
+
end_date="2026-07-30",
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
print(audience["range"]) # the window actually used
|
|
419
|
+
print(audience["audience"]["countries"]) # and "cities", "ages", "genders"
|
|
420
|
+
print(audience["activity_by_hour"]) # [{"hour": "14", "followers_online": 1494}, ...]
|
|
421
|
+
print(audience["followers_daily"]) # [{"date", "total", "new", "lost"}, ...]
|
|
422
|
+
print(audience["profile_actions"]) # bio link, address, email, phone, leads
|
|
423
|
+
print(audience["bio_description"])
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
The window is clamped on the server: at most 60 days, and `end_date` always
|
|
427
|
+
before today. A wider window is trimmed to what the platform accepts instead of
|
|
428
|
+
failing.
|
|
429
|
+
|
|
430
|
+
Ask for a `benchmark_category` and the same call also returns how the account
|
|
431
|
+
compares with the average of that category. The accepted categories come back in
|
|
432
|
+
`benchmark_categories` on every response, so a picker needs no second call:
|
|
433
|
+
|
|
434
|
+
```python
|
|
435
|
+
categories = client.get_audience(
|
|
436
|
+
user="my-profile", platform="tiktok"
|
|
437
|
+
)["benchmark_categories"]
|
|
438
|
+
|
|
439
|
+
benchmark = client.get_audience(
|
|
440
|
+
user="my-profile", platform="tiktok",
|
|
441
|
+
benchmark_category="SOFTWARE_AND_APPS",
|
|
442
|
+
)["benchmark"]
|
|
443
|
+
print(benchmark["average_engagement_rate"], benchmark["average_video_views"])
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
### Suggestions
|
|
447
|
+
|
|
448
|
+
`get_suggestions()` answers *what is worth posting about*: the hashtags or the
|
|
449
|
+
searches a platform suggests around a keyword. One endpoint for both, told apart
|
|
450
|
+
by `type`.
|
|
451
|
+
|
|
452
|
+
```python
|
|
453
|
+
hashtags = client.get_suggestions(
|
|
454
|
+
user="my-profile", platform="tiktok", type="hashtags",
|
|
455
|
+
q="pilates", country_code="ES", language="es",
|
|
456
|
+
)["hashtags"]
|
|
457
|
+
print(hashtags) # [{"name": ..., "view_count": ...}, ...]
|
|
458
|
+
|
|
459
|
+
keywords = client.get_suggestions(
|
|
460
|
+
user="my-profile", platform="tiktok", type="keywords", q="pilates",
|
|
461
|
+
)["keywords"]
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
Per-post numbers stay in `get_post_analytics()`. On TikTok that response carries
|
|
465
|
+
more than the usual counters: `retention` (the curve, second by second),
|
|
466
|
+
`impression_sources` (For You, search, profile...), `audience_types` (followers
|
|
467
|
+
vs non-followers), `new_followers`, `reach` and the watch times.
|
|
468
|
+
|
|
469
|
+
Asking a platform a question it cannot answer fails with
|
|
470
|
+
`platform_not_supported` and the list of the ones that can.
|
|
471
|
+
|
|
472
|
+
## What a TikTok connection can do (`capabilities`)
|
|
473
|
+
|
|
474
|
+
Not every TikTok connection can do the same things. `client.list_users()`
|
|
475
|
+
(`GET /api/uploadposts/users`) returns a `capabilities` array on each profile's
|
|
476
|
+
TikTok account; check it before offering a feature.
|
|
477
|
+
|
|
478
|
+
| Capability | What it unlocks |
|
|
479
|
+
| --- | --- |
|
|
480
|
+
| `music` | `tiktok_music_id` and the volume/trim fields, plus `get_tiktok_trending_music()` and `search_tiktok_music()` |
|
|
481
|
+
| `location` | `tiktok_location_id` / `tiktok_location_name`, plus `get_tiktok_locations()` |
|
|
482
|
+
| `cover_image` | `tiktok_cover_image_url` |
|
|
483
|
+
| `cover_timestamp` | `cover_timestamp` |
|
|
484
|
+
| `draft` | `tiktok_upload_to_draft` |
|
|
485
|
+
| `video_privacy` | `privacy_level` on video |
|
|
486
|
+
| `photo_privacy` | `privacy_level` on photo posts |
|
|
487
|
+
| `profile_analytics` | `get_audience()` and `get_suggestions(type="hashtags")` with `platform="tiktok"` |
|
|
488
|
+
| `comments` | Comments on TikTok: `get_post_comments()` (top-level and replies), `create_comment()`, `delete_comment()`, `comment_action()` and `tiktok_first_comment` |
|
|
489
|
+
| `trend_search` | `get_suggestions(type="keywords")` with `platform="tiktok"` |
|
|
490
|
+
|
|
491
|
+
> **`comments` and `trend_search` need the account to be reconnected.** TikTok
|
|
492
|
+
> grants them at connect time, so an account linked before they existed keeps
|
|
493
|
+
> working for everything else but will not list them — reconnect it from Manage
|
|
494
|
+
> Users to enable them.
|
|
495
|
+
|
|
496
|
+
If a connection lacks a capability the upload field is simply ignored: the post
|
|
497
|
+
still publishes and the response carries a per-field `warnings` string. The
|
|
498
|
+
methods above answer with an error asking for a reconnection.
|
|
499
|
+
|
|
325
500
|
## TikTok music, location, cover and drafts
|
|
326
501
|
|
|
327
|
-
>
|
|
328
|
-
>
|
|
329
|
-
> `capabilities` array on the TikTok account returned by
|
|
330
|
-
> `GET /api/uploadposts/users` (`client.list_users()`). Other values that can
|
|
331
|
-
> appear there: `cover_timestamp`, `photo_privacy`, `video_privacy`,
|
|
332
|
-
> `inbox_fallback` and `profile_analytics`. If your connection does
|
|
333
|
-
> not have the capability, the field is ignored, the post still publishes, and
|
|
334
|
-
> the response includes a per-field `warnings` string — reconnect the TikTok
|
|
335
|
-
> account to enable it.
|
|
502
|
+
> Needs the `music`, `location`, `cover_image` or `draft` capability — see
|
|
503
|
+
> [What a TikTok connection can do](#what-a-tiktok-connection-can-do-capabilities).
|
|
336
504
|
|
|
337
505
|
```python
|
|
338
506
|
# 1. Pick a track and a place
|
|
@@ -525,6 +693,7 @@ These options work across all upload methods:
|
|
|
525
693
|
| `user` | Profile name (required) |
|
|
526
694
|
| `platforms` | Target platforms list (required) |
|
|
527
695
|
| `first_comment` | First comment to post |
|
|
696
|
+
| `tiktok_first_comment` | First comment for TikTok only (needs the `comments` capability) |
|
|
528
697
|
| `alt_text` | Alt text for accessibility |
|
|
529
698
|
| `scheduled_date` | ISO date for scheduling |
|
|
530
699
|
| `timezone` | Timezone for scheduled date |
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|