upload-post 2.8.1__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.1
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:
@@ -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:
@@ -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.1",
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
 
@@ -400,6 +400,49 @@ class UploadPostClient:
400
400
  if reddit_link:
401
401
  data.append(("reddit_link_url", reddit_link))
402
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
+
403
446
  def upload_video(
404
447
  self,
405
448
  video_path: Union[str, Path],
@@ -418,7 +461,7 @@ class UploadPostClient:
418
461
  user: User identifier (profile name).
419
462
  platforms: Target platforms. Supported: tiktok, instagram, youtube,
420
463
  linkedin, facebook, pinterest, threads, bluesky, discord, telegram, x,
421
- mastodon, wordpress
464
+ google_business, mastodon, wordpress
422
465
 
423
466
  Keyword Args:
424
467
  description: Video description
@@ -427,10 +470,7 @@ class UploadPostClient:
427
470
  timezone: Timezone for scheduled date (e.g., "Europe/Madrid")
428
471
  add_to_queue: Add to posting queue
429
472
  async_upload: Process asynchronously (default: True)
430
- autogenerate: If True, the server uses AI to generate native per-platform
431
473
  title/description from the media and fills any platform field
432
- left empty. Also: autogenerate_title / autogenerate_description
433
- (bool) for granularity, autogenerate_language (ISO code) to force
434
474
  the language (omit to auto-detect from the media).
435
475
 
436
476
  TikTok:
@@ -507,6 +547,22 @@ class UploadPostClient:
507
547
  threads_long_text_as_post: Post long text as single post
508
548
  threads_topic_tag: Topic tag for the post (1-50 chars, no periods or ampersands)
509
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
+
510
566
  Returns:
511
567
  API response with request_id for async uploads.
512
568
 
@@ -546,7 +602,9 @@ class UploadPostClient:
546
602
  self._add_x_params(data, is_text=False, **kwargs)
547
603
  if "threads" in platforms:
548
604
  self._add_threads_params(data, **kwargs)
549
-
605
+ if "google_business" in platforms:
606
+ self._add_google_business_params(data, **kwargs)
607
+
550
608
  return self._request("/upload", "POST", data=data, files=files if files else None)
551
609
 
552
610
  finally:
@@ -571,7 +629,7 @@ class UploadPostClient:
571
629
  user: User identifier (profile name).
572
630
  platforms: Target platforms. Supported: tiktok, instagram, linkedin,
573
631
  facebook, pinterest, threads, reddit, bluesky, discord, telegram, x,
574
- mastodon, lemmy, wordpress
632
+ google_business, mastodon, lemmy, wordpress
575
633
 
576
634
  Keyword Args:
577
635
  description: Photo description
@@ -630,6 +688,18 @@ class UploadPostClient:
630
688
  subreddit: Subreddit name (without r/)
631
689
  flair_id: Flair template ID
632
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
+
633
703
  first_comment_media: List of file paths to attach as images in
634
704
  the first comment. Supported on Reddit and X.
635
705
 
@@ -674,6 +744,8 @@ class UploadPostClient:
674
744
  self._add_threads_params(data, **kwargs)
675
745
  if "reddit" in platforms:
676
746
  self._add_reddit_params(data, **kwargs)
747
+ if "google_business" in platforms:
748
+ self._add_google_business_params(data, **kwargs)
677
749
 
678
750
  first_comment_media = kwargs.get("first_comment_media")
679
751
  if first_comment_media:
@@ -705,8 +777,9 @@ class UploadPostClient:
705
777
  title: Text content for the post.
706
778
  user: User identifier (profile name).
707
779
  platforms: Target platforms. Supported: x, linkedin, facebook,
708
- threads, reddit, bluesky, discord, telegram, slack, mastodon,
709
- 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
710
783
 
711
784
  Keyword Args:
712
785
  first_comment: First comment to post
@@ -749,6 +822,15 @@ class UploadPostClient:
749
822
  (kind: "link") instead of a text post. Overrides `link_url`
750
823
  for Reddit.
751
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
+
752
834
  first_comment_media: List of file paths to attach as images in
753
835
  the first comment. Supported on Reddit and X.
754
836
 
@@ -781,6 +863,8 @@ class UploadPostClient:
781
863
  bluesky_link = kwargs.get("bluesky_link_url")
782
864
  if bluesky_link:
783
865
  data.append(("bluesky_link_url", bluesky_link))
866
+ if "google_business" in platforms:
867
+ self._add_google_business_params(data, **kwargs)
784
868
 
785
869
  first_comment_media = kwargs.get("first_comment_media")
786
870
  opened_files: List = []
@@ -976,7 +1060,11 @@ class UploadPostClient:
976
1060
 
977
1061
  Returns:
978
1062
  Analytics data per platform. For Instagram, the response includes both
979
- '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.
980
1068
  """
981
1069
  params = {}
982
1070
  if platforms:
@@ -1062,6 +1150,49 @@ class UploadPostClient:
1062
1150
  }
1063
1151
  return self._request("/uploadposts/post-analytics", "GET", params=params)
1064
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
+
1065
1196
  def get_platform_metrics(self) -> Dict:
1066
1197
  """
1067
1198
  Get available metrics configuration for all supported platforms.
@@ -1076,6 +1207,8 @@ class UploadPostClient:
1076
1207
  platform: str,
1077
1208
  user: str,
1078
1209
  page_urn: Optional[str] = None,
1210
+ limit: Optional[int] = None,
1211
+ cursor: Optional[str] = None,
1079
1212
  ) -> Dict:
1080
1213
  """
1081
1214
  Retrieve recent media from a connected social account.
@@ -1086,13 +1219,24 @@ class UploadPostClient:
1086
1219
  user: Profile username.
1087
1220
  page_urn: LinkedIn only. Numeric org ID, full URN, or ``"me"`` to
1088
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.
1089
1227
 
1090
1228
  Returns:
1091
- ``{"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``.
1092
1232
  """
1093
- params: Dict[str, str] = {"platform": platform, "user": user}
1233
+ params: Dict[str, Any] = {"platform": platform, "user": user}
1094
1234
  if page_urn:
1095
1235
  params["page_urn"] = page_urn
1236
+ if limit is not None:
1237
+ params["limit"] = limit
1238
+ if cursor:
1239
+ params["cursor"] = cursor
1096
1240
  return self._request("/uploadposts/media", "GET", params=params)
1097
1241
 
1098
1242
  # ==================== Scheduled Posts ====================
@@ -1188,7 +1332,8 @@ class UploadPostClient:
1188
1332
  readonly_calendar: Optional[bool] = None,
1189
1333
  connect_title: Optional[str] = None,
1190
1334
  connect_description: Optional[str] = None,
1191
- language: Optional[str] = None
1335
+ language: Optional[str] = None,
1336
+ ui_labels: Optional[Dict[str, str]] = None
1192
1337
  ) -> Dict:
1193
1338
  """
1194
1339
  Generate a JWT for platform integration.
@@ -1205,8 +1350,13 @@ class UploadPostClient:
1205
1350
  connect_title: Custom title for the connection page.
1206
1351
  connect_description: Custom description for the connection page.
1207
1352
  language: Force the connection page language for this profile.
1208
- Supported: 'en', 'es', 'de', 'fr', 'pt'. When omitted, the page
1209
- 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.
1210
1360
 
1211
1361
  Returns:
1212
1362
  JWT and connection URL.
@@ -1230,6 +1380,8 @@ class UploadPostClient:
1230
1380
  body["connect_description"] = connect_description
1231
1381
  if language:
1232
1382
  body["language"] = language
1383
+ if ui_labels:
1384
+ body["ui_labels"] = ui_labels
1233
1385
  return self._request("/uploadposts/users/generate-jwt", "POST", json_data=body)
1234
1386
 
1235
1387
  def validate_jwt(self, jwt: str) -> Dict:
@@ -1240,7 +1392,9 @@ class UploadPostClient:
1240
1392
  jwt: JWT token to validate.
1241
1393
 
1242
1394
  Returns:
1243
- 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.
1244
1398
  """
1245
1399
  return self._request("/uploadposts/users/validate-jwt", "POST", json_data={"jwt": jwt})
1246
1400
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: upload-post
3
- Version: 2.8.1
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:
File without changes