upload-post 2.8.0__tar.gz → 2.9.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: upload-post
3
- Version: 2.8.0
3
+ Version: 2.9.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
@@ -211,9 +211,16 @@ jwt = client.generate_jwt(
211
211
  redirect_url="https://yourapp.com/callback",
212
212
  platforms=["tiktok", "instagram"],
213
213
  # Optional: force the connection page language for this profile.
214
- # Supported: "en", "es", "de", "fr", "pt". When omitted, the page
214
+ # Supported: "en", "es", "de", "fr", "pt", "pl", "tr". When omitted, the page
215
215
  # auto-detects the visitor's browser language and falls back to English.
216
216
  language="es",
217
+ # Optional: override individual connection-page strings. Flat dict of i18n
218
+ # dot-path keys to strings. Max 100 entries, keys ^[a-zA-Z0-9_.]+$, values
219
+ # up to 300 chars. Echoed back in the "profile" object of validate_jwt.
220
+ ui_labels={
221
+ "connect.title": "Link your accounts",
222
+ "connect.subtitle": "Publish everywhere from one place",
223
+ },
217
224
  )
218
225
  ```
219
226
 
@@ -225,6 +232,35 @@ analytics = client.get_analytics(
225
232
  platforms=["instagram", "tiktok"],
226
233
  )
227
234
  print(analytics)
235
+
236
+ # Instagram returns two audience breakdowns with the same shape
237
+ # ("age", "gender", "country", "city"):
238
+ print(analytics["analytics"]["instagram"]["follower_demographics"])
239
+ print(analytics["analytics"]["instagram"]["engaged_audience_demographics"])
240
+ ```
241
+
242
+ ### Cached Post Analytics
243
+
244
+ 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
245
+ calls, so it is not subject to the live post-analytics rate limit
246
+ (100 requests / 5 minutes). Use it to page through a profile's post history.
247
+
248
+ ```python
249
+ cursor = None
250
+ while True:
251
+ page = client.get_cached_post_analytics(
252
+ "my-profile",
253
+ platform="youtube", # optional: instagram, tiktok, youtube, facebook, linkedin, threads, pinterest, reddit
254
+ limit=50, # default 50, max 200
255
+ since="2026-06-01", # defaults to 30 days ago
256
+ until="2026-07-01", # defaults to today
257
+ cursor=cursor,
258
+ )
259
+ for post in page["posts"]:
260
+ print(post["platform"], post["post_id"], post["metrics"])
261
+ cursor = page["next_cursor"]
262
+ if not cursor:
263
+ break
228
264
  ```
229
265
 
230
266
  ### Get Media
@@ -244,6 +280,24 @@ media = client.get_media("linkedin", "my-profile", page_urn="me")
244
280
  media = client.get_media("linkedin", "my-profile", page_urn="12345")
245
281
  ```
246
282
 
283
+ The response carries a `pagination` object — `{"limit": ..., "next_cursor": ...,
284
+ "has_more": ...}`, with `next_cursor` `None` and `has_more` `False` on the last
285
+ page:
286
+
287
+ ```python
288
+ cursor = None
289
+ while True:
290
+ page = client.get_media("instagram", "my-profile", limit=50, cursor=cursor)
291
+ print(len(page["media"]))
292
+ cursor = page["pagination"]["next_cursor"]
293
+ if not cursor:
294
+ break
295
+ ```
296
+
297
+ `limit` defaults to 25 and is clamped to 1-100, with per-platform caps of 20 for
298
+ TikTok and 50 for YouTube. **LinkedIn, Discord and Telegram do not support
299
+ cursors** — they accept `limit` only, and passing a `cursor` returns HTTP 400.
300
+
247
301
  ### Helper Methods
248
302
 
249
303
  ```python
@@ -344,6 +398,30 @@ boards = client.get_pinterest_boards("my-profile")
344
398
  - `subreddit` - Subreddit name (without r/)
345
399
  - `flair_id` - Flair template ID
346
400
 
401
+ ### Google Business
402
+ - `gbp_location_id` - Location, e.g. `"accounts/123/locations/456"` (list them with `get_google_business_locations`). Required when the account has more than one location; the API only auto-selects when exactly one exists.
403
+ - `gbp_post_type` - `MEDIA`, `PHOTO` or `GALLERY` to publish into the location's photo gallery instead of creating a Local Post. Any other value, or omitting it, keeps the Local Post behaviour.
404
+ - `gbp_media_category` - Gallery category, default `ADDITIONAL`. One of `COVER`, `PROFILE`, `LOGO`, `EXTERIOR`, `INTERIOR`, `PRODUCT`, `AT_WORK`, `FOOD_AND_DRINK`, `MENU`, `COMMON_AREA`, `ROOMS`, `TEAMS`, `ADDITIONAL`.
405
+ - `gbp_topic_type` - STANDARD, EVENT or OFFER
406
+ - `gbp_media_url` / `gbp_media_format` - Media attached to the post
407
+ - `gbp_cta_type` / `gbp_cta_url` - Call-to-action button
408
+ - `gbp_event_title` / `gbp_event_start_date` / `gbp_event_start_time` / `gbp_event_end_date` / `gbp_event_end_time` - Used with `gbp_topic_type="EVENT"`
409
+ - `gbp_offer_coupon` / `gbp_offer_redeem_url` / `gbp_offer_terms` - Used with `gbp_topic_type="OFFER"`
410
+
411
+ ```python
412
+ locations = client.get_google_business_locations("my-profile")
413
+
414
+ # Publish a photo straight into the location's gallery
415
+ client.upload_photos(
416
+ ["storefront.jpg"],
417
+ user="my-profile",
418
+ platforms=["google_business"],
419
+ gbp_location_id=locations["locations"][0]["name"],
420
+ gbp_post_type="GALLERY",
421
+ gbp_media_category="EXTERIOR",
422
+ )
423
+ ```
424
+
347
425
  ## Common Options
348
426
 
349
427
  These options work across all upload methods:
@@ -360,9 +438,6 @@ These options work across all upload methods:
360
438
  | `add_to_queue` | Add to posting queue |
361
439
  | `max_posts_per_slot` | Max posts per queue slot (overrides profile setting) |
362
440
  | `async_upload` | Process asynchronously (default: True) |
363
- | `autogenerate` | If True, AI generates native per-platform title/description from the media and fills any field left empty |
364
- | `autogenerate_title` / `autogenerate_description` | Generate only the title or only the description (bool) |
365
- | `autogenerate_language` | Force the output language (ISO code); omit to auto-detect from the media |
366
441
 
367
442
  ## Error Handling
368
443
 
@@ -173,9 +173,16 @@ jwt = client.generate_jwt(
173
173
  redirect_url="https://yourapp.com/callback",
174
174
  platforms=["tiktok", "instagram"],
175
175
  # Optional: force the connection page language for this profile.
176
- # Supported: "en", "es", "de", "fr", "pt". When omitted, the page
176
+ # Supported: "en", "es", "de", "fr", "pt", "pl", "tr". When omitted, the page
177
177
  # auto-detects the visitor's browser language and falls back to English.
178
178
  language="es",
179
+ # Optional: override individual connection-page strings. Flat dict of i18n
180
+ # dot-path keys to strings. Max 100 entries, keys ^[a-zA-Z0-9_.]+$, values
181
+ # up to 300 chars. Echoed back in the "profile" object of validate_jwt.
182
+ ui_labels={
183
+ "connect.title": "Link your accounts",
184
+ "connect.subtitle": "Publish everywhere from one place",
185
+ },
179
186
  )
180
187
  ```
181
188
 
@@ -187,6 +194,35 @@ analytics = client.get_analytics(
187
194
  platforms=["instagram", "tiktok"],
188
195
  )
189
196
  print(analytics)
197
+
198
+ # Instagram returns two audience breakdowns with the same shape
199
+ # ("age", "gender", "country", "city"):
200
+ print(analytics["analytics"]["instagram"]["follower_demographics"])
201
+ print(analytics["analytics"]["instagram"]["engaged_audience_demographics"])
202
+ ```
203
+
204
+ ### Cached Post Analytics
205
+
206
+ 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
207
+ calls, so it is not subject to the live post-analytics rate limit
208
+ (100 requests / 5 minutes). Use it to page through a profile's post history.
209
+
210
+ ```python
211
+ cursor = None
212
+ while True:
213
+ page = client.get_cached_post_analytics(
214
+ "my-profile",
215
+ platform="youtube", # optional: instagram, tiktok, youtube, facebook, linkedin, threads, pinterest, reddit
216
+ limit=50, # default 50, max 200
217
+ since="2026-06-01", # defaults to 30 days ago
218
+ until="2026-07-01", # defaults to today
219
+ cursor=cursor,
220
+ )
221
+ for post in page["posts"]:
222
+ print(post["platform"], post["post_id"], post["metrics"])
223
+ cursor = page["next_cursor"]
224
+ if not cursor:
225
+ break
190
226
  ```
191
227
 
192
228
  ### Get Media
@@ -206,6 +242,24 @@ media = client.get_media("linkedin", "my-profile", page_urn="me")
206
242
  media = client.get_media("linkedin", "my-profile", page_urn="12345")
207
243
  ```
208
244
 
245
+ The response carries a `pagination` object — `{"limit": ..., "next_cursor": ...,
246
+ "has_more": ...}`, with `next_cursor` `None` and `has_more` `False` on the last
247
+ page:
248
+
249
+ ```python
250
+ cursor = None
251
+ while True:
252
+ page = client.get_media("instagram", "my-profile", limit=50, cursor=cursor)
253
+ print(len(page["media"]))
254
+ cursor = page["pagination"]["next_cursor"]
255
+ if not cursor:
256
+ break
257
+ ```
258
+
259
+ `limit` defaults to 25 and is clamped to 1-100, with per-platform caps of 20 for
260
+ TikTok and 50 for YouTube. **LinkedIn, Discord and Telegram do not support
261
+ cursors** — they accept `limit` only, and passing a `cursor` returns HTTP 400.
262
+
209
263
  ### Helper Methods
210
264
 
211
265
  ```python
@@ -306,6 +360,30 @@ boards = client.get_pinterest_boards("my-profile")
306
360
  - `subreddit` - Subreddit name (without r/)
307
361
  - `flair_id` - Flair template ID
308
362
 
363
+ ### Google Business
364
+ - `gbp_location_id` - Location, e.g. `"accounts/123/locations/456"` (list them with `get_google_business_locations`). Required when the account has more than one location; the API only auto-selects when exactly one exists.
365
+ - `gbp_post_type` - `MEDIA`, `PHOTO` or `GALLERY` to publish into the location's photo gallery instead of creating a Local Post. Any other value, or omitting it, keeps the Local Post behaviour.
366
+ - `gbp_media_category` - Gallery category, default `ADDITIONAL`. One of `COVER`, `PROFILE`, `LOGO`, `EXTERIOR`, `INTERIOR`, `PRODUCT`, `AT_WORK`, `FOOD_AND_DRINK`, `MENU`, `COMMON_AREA`, `ROOMS`, `TEAMS`, `ADDITIONAL`.
367
+ - `gbp_topic_type` - STANDARD, EVENT or OFFER
368
+ - `gbp_media_url` / `gbp_media_format` - Media attached to the post
369
+ - `gbp_cta_type` / `gbp_cta_url` - Call-to-action button
370
+ - `gbp_event_title` / `gbp_event_start_date` / `gbp_event_start_time` / `gbp_event_end_date` / `gbp_event_end_time` - Used with `gbp_topic_type="EVENT"`
371
+ - `gbp_offer_coupon` / `gbp_offer_redeem_url` / `gbp_offer_terms` - Used with `gbp_topic_type="OFFER"`
372
+
373
+ ```python
374
+ locations = client.get_google_business_locations("my-profile")
375
+
376
+ # Publish a photo straight into the location's gallery
377
+ client.upload_photos(
378
+ ["storefront.jpg"],
379
+ user="my-profile",
380
+ platforms=["google_business"],
381
+ gbp_location_id=locations["locations"][0]["name"],
382
+ gbp_post_type="GALLERY",
383
+ gbp_media_category="EXTERIOR",
384
+ )
385
+ ```
386
+
309
387
  ## Common Options
310
388
 
311
389
  These options work across all upload methods:
@@ -322,9 +400,6 @@ These options work across all upload methods:
322
400
  | `add_to_queue` | Add to posting queue |
323
401
  | `max_posts_per_slot` | Max posts per queue slot (overrides profile setting) |
324
402
  | `async_upload` | Process asynchronously (default: True) |
325
- | `autogenerate` | If True, AI generates native per-platform title/description from the media and fills any field left empty |
326
- | `autogenerate_title` / `autogenerate_description` | Generate only the title or only the description (bool) |
327
- | `autogenerate_language` | Force the output language (ISO code); omit to auto-detect from the media |
328
403
 
329
404
  ## Error Handling
330
405
 
@@ -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.0",
8
+ version="2.9.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)",
@@ -15,7 +15,7 @@ Example:
15
15
  ... )
16
16
  """
17
17
 
18
- __version__ = "2.8.0"
18
+ __version__ = "2.9.0"
19
19
 
20
20
  from .api_client import UploadPostClient, UploadPostError
21
21
 
@@ -129,14 +129,6 @@ class UploadPostClient:
129
129
  if async_upload is not None:
130
130
  data.append(("async_upload", str(async_upload).lower()))
131
131
  # AI auto-generation of native per-platform copy from the media (fills blank fields)
132
- if kwargs.get("autogenerate") is not None:
133
- data.append(("autogenerate", str(kwargs["autogenerate"]).lower()))
134
- if kwargs.get("autogenerate_title") is not None:
135
- data.append(("autogenerate_title", str(kwargs["autogenerate_title"]).lower()))
136
- if kwargs.get("autogenerate_description") is not None:
137
- data.append(("autogenerate_description", str(kwargs["autogenerate_description"]).lower()))
138
- if kwargs.get("autogenerate_language"):
139
- data.append(("autogenerate_language", str(kwargs["autogenerate_language"])))
140
132
 
141
133
  # Platform-specific title overrides
142
134
  title_overrides = [
@@ -408,6 +400,49 @@ class UploadPostClient:
408
400
  if reddit_link:
409
401
  data.append(("reddit_link_url", reddit_link))
410
402
 
403
+ def _add_google_business_params(self, data: List[tuple], **kwargs):
404
+ """Add Google Business Profile parameters.
405
+
406
+ ``gbp_location_id`` is required for accounts with more than one location;
407
+ the API only auto-selects when exactly one location exists.
408
+
409
+ ``gbp_post_type`` switches the call from the default Local Post to a
410
+ gallery photo upload (MEDIA / PHOTO / GALLERY). Any other value, or
411
+ omitting it, keeps the Local Post behaviour.
412
+ """
413
+ if kwargs.get("gbp_location_id"):
414
+ data.append(("gbp_location_id", kwargs["gbp_location_id"]))
415
+ if kwargs.get("gbp_post_type"):
416
+ data.append(("gbp_post_type", str(kwargs["gbp_post_type"]).upper()))
417
+ if kwargs.get("gbp_media_category"):
418
+ data.append(("gbp_media_category", str(kwargs["gbp_media_category"]).upper()))
419
+ if kwargs.get("gbp_topic_type"):
420
+ data.append(("gbp_topic_type", str(kwargs["gbp_topic_type"]).upper()))
421
+ if kwargs.get("gbp_media_url"):
422
+ data.append(("gbp_media_url", kwargs["gbp_media_url"]))
423
+ if kwargs.get("gbp_media_format"):
424
+ data.append(("gbp_media_format", str(kwargs["gbp_media_format"]).upper()))
425
+ if kwargs.get("gbp_cta_type"):
426
+ data.append(("gbp_cta_type", str(kwargs["gbp_cta_type"]).upper()))
427
+ if kwargs.get("gbp_cta_url"):
428
+ data.append(("gbp_cta_url", kwargs["gbp_cta_url"]))
429
+ if kwargs.get("gbp_event_title"):
430
+ data.append(("gbp_event_title", kwargs["gbp_event_title"]))
431
+ if kwargs.get("gbp_event_start_date"):
432
+ data.append(("gbp_event_start_date", kwargs["gbp_event_start_date"]))
433
+ if kwargs.get("gbp_event_start_time"):
434
+ data.append(("gbp_event_start_time", kwargs["gbp_event_start_time"]))
435
+ if kwargs.get("gbp_event_end_date"):
436
+ data.append(("gbp_event_end_date", kwargs["gbp_event_end_date"]))
437
+ if kwargs.get("gbp_event_end_time"):
438
+ data.append(("gbp_event_end_time", kwargs["gbp_event_end_time"]))
439
+ if kwargs.get("gbp_offer_coupon"):
440
+ data.append(("gbp_offer_coupon", kwargs["gbp_offer_coupon"]))
441
+ if kwargs.get("gbp_offer_redeem_url"):
442
+ data.append(("gbp_offer_redeem_url", kwargs["gbp_offer_redeem_url"]))
443
+ if kwargs.get("gbp_offer_terms"):
444
+ data.append(("gbp_offer_terms", kwargs["gbp_offer_terms"]))
445
+
411
446
  def upload_video(
412
447
  self,
413
448
  video_path: Union[str, Path],
@@ -426,7 +461,7 @@ class UploadPostClient:
426
461
  user: User identifier (profile name).
427
462
  platforms: Target platforms. Supported: tiktok, instagram, youtube,
428
463
  linkedin, facebook, pinterest, threads, bluesky, discord, telegram, x,
429
- mastodon, wordpress
464
+ google_business, mastodon, wordpress
430
465
 
431
466
  Keyword Args:
432
467
  description: Video description
@@ -435,10 +470,7 @@ class UploadPostClient:
435
470
  timezone: Timezone for scheduled date (e.g., "Europe/Madrid")
436
471
  add_to_queue: Add to posting queue
437
472
  async_upload: Process asynchronously (default: True)
438
- autogenerate: If True, the server uses AI to generate native per-platform
439
473
  title/description from the media and fills any platform field
440
- left empty. Also: autogenerate_title / autogenerate_description
441
- (bool) for granularity, autogenerate_language (ISO code) to force
442
474
  the language (omit to auto-detect from the media).
443
475
 
444
476
  TikTok:
@@ -515,6 +547,22 @@ class UploadPostClient:
515
547
  threads_long_text_as_post: Post long text as single post
516
548
  threads_topic_tag: Topic tag for the post (1-50 chars, no periods or ampersands)
517
549
 
550
+ Google Business:
551
+ gbp_location_id: Location, e.g. "accounts/123/locations/456". Required
552
+ when the account has more than one location.
553
+ gbp_post_type: MEDIA, PHOTO or GALLERY to publish into the location's
554
+ gallery instead of creating a Local Post. Any other value, or
555
+ omitting it, keeps the Local Post behaviour.
556
+ gbp_media_category: Gallery category (default ADDITIONAL). One of
557
+ COVER, PROFILE, LOGO, EXTERIOR, INTERIOR, PRODUCT, AT_WORK,
558
+ FOOD_AND_DRINK, MENU, COMMON_AREA, ROOMS, TEAMS, ADDITIONAL.
559
+ gbp_topic_type: STANDARD, EVENT or OFFER
560
+ gbp_media_url / gbp_media_format: Media attached to the post
561
+ gbp_cta_type / gbp_cta_url: Call-to-action button
562
+ gbp_event_title / gbp_event_start_date / gbp_event_start_time /
563
+ gbp_event_end_date / gbp_event_end_time: Used with EVENT
564
+ gbp_offer_coupon / gbp_offer_redeem_url / gbp_offer_terms: Used with OFFER
565
+
518
566
  Returns:
519
567
  API response with request_id for async uploads.
520
568
 
@@ -554,7 +602,9 @@ class UploadPostClient:
554
602
  self._add_x_params(data, is_text=False, **kwargs)
555
603
  if "threads" in platforms:
556
604
  self._add_threads_params(data, **kwargs)
557
-
605
+ if "google_business" in platforms:
606
+ self._add_google_business_params(data, **kwargs)
607
+
558
608
  return self._request("/upload", "POST", data=data, files=files if files else None)
559
609
 
560
610
  finally:
@@ -579,7 +629,7 @@ class UploadPostClient:
579
629
  user: User identifier (profile name).
580
630
  platforms: Target platforms. Supported: tiktok, instagram, linkedin,
581
631
  facebook, pinterest, threads, reddit, bluesky, discord, telegram, x,
582
- mastodon, lemmy, wordpress
632
+ google_business, mastodon, lemmy, wordpress
583
633
 
584
634
  Keyword Args:
585
635
  description: Photo description
@@ -638,6 +688,18 @@ class UploadPostClient:
638
688
  subreddit: Subreddit name (without r/)
639
689
  flair_id: Flair template ID
640
690
 
691
+ Google Business:
692
+ gbp_location_id: Location, e.g. "accounts/123/locations/456". Required
693
+ when the account has more than one location.
694
+ gbp_post_type: MEDIA, PHOTO or GALLERY to publish the photo into the
695
+ location's gallery instead of creating a Local Post. Any other
696
+ value, or omitting it, keeps the Local Post behaviour.
697
+ gbp_media_category: Gallery category (default ADDITIONAL). One of
698
+ COVER, PROFILE, LOGO, EXTERIOR, INTERIOR, PRODUCT, AT_WORK,
699
+ FOOD_AND_DRINK, MENU, COMMON_AREA, ROOMS, TEAMS, ADDITIONAL.
700
+ gbp_topic_type: STANDARD, EVENT or OFFER
701
+ gbp_cta_type / gbp_cta_url: Call-to-action button
702
+
641
703
  first_comment_media: List of file paths to attach as images in
642
704
  the first comment. Supported on Reddit and X.
643
705
 
@@ -682,6 +744,8 @@ class UploadPostClient:
682
744
  self._add_threads_params(data, **kwargs)
683
745
  if "reddit" in platforms:
684
746
  self._add_reddit_params(data, **kwargs)
747
+ if "google_business" in platforms:
748
+ self._add_google_business_params(data, **kwargs)
685
749
 
686
750
  first_comment_media = kwargs.get("first_comment_media")
687
751
  if first_comment_media:
@@ -713,8 +777,9 @@ class UploadPostClient:
713
777
  title: Text content for the post.
714
778
  user: User identifier (profile name).
715
779
  platforms: Target platforms. Supported: x, linkedin, facebook,
716
- threads, reddit, bluesky, discord, telegram, slack, mastodon,
717
- nostr, lemmy, devto, hashnode, wordpress, whop, listmonk
780
+ threads, reddit, bluesky, discord, telegram, google_business,
781
+ slack, mastodon, nostr, lemmy, devto, hashnode, wordpress,
782
+ whop, listmonk
718
783
 
719
784
  Keyword Args:
720
785
  first_comment: First comment to post
@@ -757,6 +822,15 @@ class UploadPostClient:
757
822
  (kind: "link") instead of a text post. Overrides `link_url`
758
823
  for Reddit.
759
824
 
825
+ Google Business:
826
+ gbp_location_id: Location, e.g. "accounts/123/locations/456". Required
827
+ when the account has more than one location.
828
+ gbp_topic_type: STANDARD, EVENT or OFFER
829
+ gbp_cta_type / gbp_cta_url: Call-to-action button
830
+ gbp_event_title / gbp_event_start_date / gbp_event_start_time /
831
+ gbp_event_end_date / gbp_event_end_time: Used with EVENT
832
+ gbp_offer_coupon / gbp_offer_redeem_url / gbp_offer_terms: Used with OFFER
833
+
760
834
  first_comment_media: List of file paths to attach as images in
761
835
  the first comment. Supported on Reddit and X.
762
836
 
@@ -789,6 +863,8 @@ class UploadPostClient:
789
863
  bluesky_link = kwargs.get("bluesky_link_url")
790
864
  if bluesky_link:
791
865
  data.append(("bluesky_link_url", bluesky_link))
866
+ if "google_business" in platforms:
867
+ self._add_google_business_params(data, **kwargs)
792
868
 
793
869
  first_comment_media = kwargs.get("first_comment_media")
794
870
  opened_files: List = []
@@ -984,7 +1060,11 @@ class UploadPostClient:
984
1060
 
985
1061
  Returns:
986
1062
  Analytics data per platform. For Instagram, the response includes both
987
- 'views' (official Instagram metric) and 'impressions' (backwards-compatible alias).
1063
+ 'views' (official Instagram metric) and 'impressions' (backwards-compatible alias),
1064
+ plus two audience breakdowns with the same shape ('age', 'gender',
1065
+ 'country', 'city'): 'follower_demographics' for the account's followers
1066
+ and 'engaged_audience_demographics' for the accounts that engaged with
1067
+ its content.
988
1068
  """
989
1069
  params = {}
990
1070
  if platforms:
@@ -1070,6 +1150,49 @@ class UploadPostClient:
1070
1150
  }
1071
1151
  return self._request("/uploadposts/post-analytics", "GET", params=params)
1072
1152
 
1153
+ def get_cached_post_analytics(
1154
+ self,
1155
+ user: str,
1156
+ platform: Optional[str] = None,
1157
+ limit: Optional[int] = None,
1158
+ cursor: Optional[str] = None,
1159
+ since: Optional[str] = None,
1160
+ until: Optional[str] = None,
1161
+ ) -> Dict:
1162
+ """
1163
+ Replay per-post metrics already fetched, instead of querying
1164
+ the platforms live.
1165
+
1166
+ Because it never calls the platform APIs it is not subject to the live
1167
+ post-analytics rate limit (100 requests / 5 minutes), which makes it the
1168
+ right method for paging through a profile's whole post history.
1169
+
1170
+ Args:
1171
+ user: Profile username.
1172
+ platform: Filter by platform (instagram, tiktok, youtube, facebook,
1173
+ linkedin, threads, pinterest, reddit).
1174
+ limit: Posts per page. Defaults to 50, max 200.
1175
+ cursor: Opaque cursor from a previous response's ``next_cursor``.
1176
+ since: Start date in YYYY-MM-DD format (defaults to 30 days ago).
1177
+ until: End date in YYYY-MM-DD format (defaults to today).
1178
+
1179
+ Returns:
1180
+ Cached post metrics with ``source: "snapshot_cache"``, plus
1181
+ ``next_cursor`` and ``has_more`` for pagination.
1182
+ """
1183
+ params: Dict[str, Any] = {"user": user}
1184
+ if platform:
1185
+ params["platform"] = platform
1186
+ if limit is not None:
1187
+ params["limit"] = limit
1188
+ if cursor:
1189
+ params["cursor"] = cursor
1190
+ if since:
1191
+ params["since"] = since
1192
+ if until:
1193
+ params["until"] = until
1194
+ return self._request("/uploadposts/post-analytics/cached", "GET", params=params)
1195
+
1073
1196
  def get_platform_metrics(self) -> Dict:
1074
1197
  """
1075
1198
  Get available metrics configuration for all supported platforms.
@@ -1084,6 +1207,8 @@ class UploadPostClient:
1084
1207
  platform: str,
1085
1208
  user: str,
1086
1209
  page_urn: Optional[str] = None,
1210
+ limit: Optional[int] = None,
1211
+ cursor: Optional[str] = None,
1087
1212
  ) -> Dict:
1088
1213
  """
1089
1214
  Retrieve recent media from a connected social account.
@@ -1094,13 +1219,24 @@ class UploadPostClient:
1094
1219
  user: Profile username.
1095
1220
  page_urn: LinkedIn only. Numeric org ID, full URN, or ``"me"`` to
1096
1221
  force the personal profile of an org-admin account.
1222
+ limit: Items per page. Defaults to 25 and is clamped to 1-100.
1223
+ Per-platform caps: TikTok 20, YouTube 50, everything else 100.
1224
+ cursor: Opaque cursor from a previous response's
1225
+ ``pagination["next_cursor"]``. LinkedIn, Discord and Telegram do
1226
+ not support cursors: passing one there returns HTTP 400.
1097
1227
 
1098
1228
  Returns:
1099
- ``{"success": True, "media": [...]}``.
1229
+ ``{"success": True, "media": [...], "pagination": {"limit": 50,
1230
+ "next_cursor": "QVFI...", "has_more": True}}``. The last page has
1231
+ ``next_cursor`` ``None`` and ``has_more`` ``False``.
1100
1232
  """
1101
- params: Dict[str, str] = {"platform": platform, "user": user}
1233
+ params: Dict[str, Any] = {"platform": platform, "user": user}
1102
1234
  if page_urn:
1103
1235
  params["page_urn"] = page_urn
1236
+ if limit is not None:
1237
+ params["limit"] = limit
1238
+ if cursor:
1239
+ params["cursor"] = cursor
1104
1240
  return self._request("/uploadposts/media", "GET", params=params)
1105
1241
 
1106
1242
  # ==================== Scheduled Posts ====================
@@ -1196,7 +1332,8 @@ class UploadPostClient:
1196
1332
  readonly_calendar: Optional[bool] = None,
1197
1333
  connect_title: Optional[str] = None,
1198
1334
  connect_description: Optional[str] = None,
1199
- language: Optional[str] = None
1335
+ language: Optional[str] = None,
1336
+ ui_labels: Optional[Dict[str, str]] = None
1200
1337
  ) -> Dict:
1201
1338
  """
1202
1339
  Generate a JWT for platform integration.
@@ -1213,8 +1350,13 @@ class UploadPostClient:
1213
1350
  connect_title: Custom title for the connection page.
1214
1351
  connect_description: Custom description for the connection page.
1215
1352
  language: Force the connection page language for this profile.
1216
- Supported: 'en', 'es', 'de', 'fr', 'pt'. When omitted, the page
1217
- auto-detects the visitor's browser language and falls back to English.
1353
+ Supported: 'en', 'es', 'de', 'fr', 'pt', 'pl', 'tr'. When omitted,
1354
+ the page auto-detects the visitor's browser language and falls back
1355
+ to English.
1356
+ ui_labels: Flat mapping of i18n dot-path keys to replacement strings,
1357
+ e.g. {"connect.title": "Link your accounts"}. Max 100 entries; keys
1358
+ must match ^[a-zA-Z0-9_.]+$ and values are strings of up to 300
1359
+ characters. Echoed back in the 'profile' object of validate_jwt.
1218
1360
 
1219
1361
  Returns:
1220
1362
  JWT and connection URL.
@@ -1238,6 +1380,8 @@ class UploadPostClient:
1238
1380
  body["connect_description"] = connect_description
1239
1381
  if language:
1240
1382
  body["language"] = language
1383
+ if ui_labels:
1384
+ body["ui_labels"] = ui_labels
1241
1385
  return self._request("/uploadposts/users/generate-jwt", "POST", json_data=body)
1242
1386
 
1243
1387
  def validate_jwt(self, jwt: str) -> Dict:
@@ -1248,7 +1392,9 @@ class UploadPostClient:
1248
1392
  jwt: JWT token to validate.
1249
1393
 
1250
1394
  Returns:
1251
- Validation result.
1395
+ Validation result. The 'profile' object echoes back the connection
1396
+ page settings, including 'language' and any 'ui_labels' sent to
1397
+ generate_jwt.
1252
1398
  """
1253
1399
  return self._request("/uploadposts/users/validate-jwt", "POST", json_data={"jwt": jwt})
1254
1400
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: upload-post
3
- Version: 2.8.0
3
+ Version: 2.9.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
@@ -211,9 +211,16 @@ jwt = client.generate_jwt(
211
211
  redirect_url="https://yourapp.com/callback",
212
212
  platforms=["tiktok", "instagram"],
213
213
  # Optional: force the connection page language for this profile.
214
- # Supported: "en", "es", "de", "fr", "pt". When omitted, the page
214
+ # Supported: "en", "es", "de", "fr", "pt", "pl", "tr". When omitted, the page
215
215
  # auto-detects the visitor's browser language and falls back to English.
216
216
  language="es",
217
+ # Optional: override individual connection-page strings. Flat dict of i18n
218
+ # dot-path keys to strings. Max 100 entries, keys ^[a-zA-Z0-9_.]+$, values
219
+ # up to 300 chars. Echoed back in the "profile" object of validate_jwt.
220
+ ui_labels={
221
+ "connect.title": "Link your accounts",
222
+ "connect.subtitle": "Publish everywhere from one place",
223
+ },
217
224
  )
218
225
  ```
219
226
 
@@ -225,6 +232,35 @@ analytics = client.get_analytics(
225
232
  platforms=["instagram", "tiktok"],
226
233
  )
227
234
  print(analytics)
235
+
236
+ # Instagram returns two audience breakdowns with the same shape
237
+ # ("age", "gender", "country", "city"):
238
+ print(analytics["analytics"]["instagram"]["follower_demographics"])
239
+ print(analytics["analytics"]["instagram"]["engaged_audience_demographics"])
240
+ ```
241
+
242
+ ### Cached Post Analytics
243
+
244
+ 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
245
+ calls, so it is not subject to the live post-analytics rate limit
246
+ (100 requests / 5 minutes). Use it to page through a profile's post history.
247
+
248
+ ```python
249
+ cursor = None
250
+ while True:
251
+ page = client.get_cached_post_analytics(
252
+ "my-profile",
253
+ platform="youtube", # optional: instagram, tiktok, youtube, facebook, linkedin, threads, pinterest, reddit
254
+ limit=50, # default 50, max 200
255
+ since="2026-06-01", # defaults to 30 days ago
256
+ until="2026-07-01", # defaults to today
257
+ cursor=cursor,
258
+ )
259
+ for post in page["posts"]:
260
+ print(post["platform"], post["post_id"], post["metrics"])
261
+ cursor = page["next_cursor"]
262
+ if not cursor:
263
+ break
228
264
  ```
229
265
 
230
266
  ### Get Media
@@ -244,6 +280,24 @@ media = client.get_media("linkedin", "my-profile", page_urn="me")
244
280
  media = client.get_media("linkedin", "my-profile", page_urn="12345")
245
281
  ```
246
282
 
283
+ The response carries a `pagination` object — `{"limit": ..., "next_cursor": ...,
284
+ "has_more": ...}`, with `next_cursor` `None` and `has_more` `False` on the last
285
+ page:
286
+
287
+ ```python
288
+ cursor = None
289
+ while True:
290
+ page = client.get_media("instagram", "my-profile", limit=50, cursor=cursor)
291
+ print(len(page["media"]))
292
+ cursor = page["pagination"]["next_cursor"]
293
+ if not cursor:
294
+ break
295
+ ```
296
+
297
+ `limit` defaults to 25 and is clamped to 1-100, with per-platform caps of 20 for
298
+ TikTok and 50 for YouTube. **LinkedIn, Discord and Telegram do not support
299
+ cursors** — they accept `limit` only, and passing a `cursor` returns HTTP 400.
300
+
247
301
  ### Helper Methods
248
302
 
249
303
  ```python
@@ -344,6 +398,30 @@ boards = client.get_pinterest_boards("my-profile")
344
398
  - `subreddit` - Subreddit name (without r/)
345
399
  - `flair_id` - Flair template ID
346
400
 
401
+ ### Google Business
402
+ - `gbp_location_id` - Location, e.g. `"accounts/123/locations/456"` (list them with `get_google_business_locations`). Required when the account has more than one location; the API only auto-selects when exactly one exists.
403
+ - `gbp_post_type` - `MEDIA`, `PHOTO` or `GALLERY` to publish into the location's photo gallery instead of creating a Local Post. Any other value, or omitting it, keeps the Local Post behaviour.
404
+ - `gbp_media_category` - Gallery category, default `ADDITIONAL`. One of `COVER`, `PROFILE`, `LOGO`, `EXTERIOR`, `INTERIOR`, `PRODUCT`, `AT_WORK`, `FOOD_AND_DRINK`, `MENU`, `COMMON_AREA`, `ROOMS`, `TEAMS`, `ADDITIONAL`.
405
+ - `gbp_topic_type` - STANDARD, EVENT or OFFER
406
+ - `gbp_media_url` / `gbp_media_format` - Media attached to the post
407
+ - `gbp_cta_type` / `gbp_cta_url` - Call-to-action button
408
+ - `gbp_event_title` / `gbp_event_start_date` / `gbp_event_start_time` / `gbp_event_end_date` / `gbp_event_end_time` - Used with `gbp_topic_type="EVENT"`
409
+ - `gbp_offer_coupon` / `gbp_offer_redeem_url` / `gbp_offer_terms` - Used with `gbp_topic_type="OFFER"`
410
+
411
+ ```python
412
+ locations = client.get_google_business_locations("my-profile")
413
+
414
+ # Publish a photo straight into the location's gallery
415
+ client.upload_photos(
416
+ ["storefront.jpg"],
417
+ user="my-profile",
418
+ platforms=["google_business"],
419
+ gbp_location_id=locations["locations"][0]["name"],
420
+ gbp_post_type="GALLERY",
421
+ gbp_media_category="EXTERIOR",
422
+ )
423
+ ```
424
+
347
425
  ## Common Options
348
426
 
349
427
  These options work across all upload methods:
@@ -360,9 +438,6 @@ These options work across all upload methods:
360
438
  | `add_to_queue` | Add to posting queue |
361
439
  | `max_posts_per_slot` | Max posts per queue slot (overrides profile setting) |
362
440
  | `async_upload` | Process asynchronously (default: True) |
363
- | `autogenerate` | If True, AI generates native per-platform title/description from the media and fills any field left empty |
364
- | `autogenerate_title` / `autogenerate_description` | Generate only the title or only the description (bool) |
365
- | `autogenerate_language` | Force the output language (ISO code); omit to auto-detect from the media |
366
441
 
367
442
  ## Error Handling
368
443
 
File without changes