marqetive-lib 0.1.16__tar.gz → 0.1.18__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.
Files changed (38) hide show
  1. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/PKG-INFO +1 -1
  2. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/pyproject.toml +1 -1
  3. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/platforms/instagram/client.py +8 -1
  4. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/platforms/linkedin/client.py +387 -28
  5. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/platforms/linkedin/media.py +320 -211
  6. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/README.md +0 -0
  7. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/__init__.py +0 -0
  8. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/core/__init__.py +0 -0
  9. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/core/base.py +0 -0
  10. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/core/client.py +0 -0
  11. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/core/exceptions.py +0 -0
  12. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/core/models.py +0 -0
  13. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/factory.py +0 -0
  14. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/platforms/__init__.py +0 -0
  15. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/platforms/instagram/__init__.py +0 -0
  16. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/platforms/instagram/exceptions.py +0 -0
  17. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/platforms/instagram/media.py +0 -0
  18. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/platforms/instagram/models.py +0 -0
  19. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/platforms/linkedin/__init__.py +0 -0
  20. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/platforms/linkedin/exceptions.py +0 -0
  21. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/platforms/linkedin/models.py +0 -0
  22. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/platforms/tiktok/__init__.py +0 -0
  23. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/platforms/tiktok/client.py +0 -0
  24. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/platforms/tiktok/exceptions.py +0 -0
  25. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/platforms/tiktok/media.py +0 -0
  26. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/platforms/tiktok/models.py +0 -0
  27. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/platforms/twitter/__init__.py +0 -0
  28. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/platforms/twitter/client.py +0 -0
  29. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/platforms/twitter/exceptions.py +0 -0
  30. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/platforms/twitter/media.py +0 -0
  31. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/platforms/twitter/models.py +0 -0
  32. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/py.typed +0 -0
  33. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/utils/__init__.py +0 -0
  34. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/utils/file_handlers.py +0 -0
  35. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/utils/helpers.py +0 -0
  36. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/utils/media.py +0 -0
  37. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/utils/oauth.py +0 -0
  38. {marqetive_lib-0.1.16 → marqetive_lib-0.1.18}/src/marqetive/utils/retry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: marqetive-lib
3
- Version: 0.1.16
3
+ Version: 0.1.18
4
4
  Summary: Modern Python utilities for web APIs
5
5
  Keywords: api,utilities,web,http,marqetive
6
6
  Requires-Python: >=3.12
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [project]
6
6
  name = "marqetive-lib"
7
- version = "0.1.16"
7
+ version = "0.1.18"
8
8
  description = "Modern Python utilities for web APIs"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -314,10 +314,17 @@ class InstagramClient(SocialMediaPlatform):
314
314
  if media_type_str in type_map:
315
315
  return type_map[media_type_str]
316
316
 
317
- # Auto-detect: multiple URLs = carousel, single = image
317
+ # Auto-detect from URL extension and count
318
318
  if len(request.media_urls) > 1:
319
319
  return MediaType.CAROUSEL
320
320
 
321
+ # Check if single media is a video (should be treated as reel)
322
+ if request.media_urls:
323
+ url_lower = request.media_urls[0].lower()
324
+ video_extensions = (".mp4", ".mov", ".avi", ".webm", ".m4v")
325
+ if any(url_lower.endswith(ext) for ext in video_extensions):
326
+ return MediaType.REEL
327
+
321
328
  return MediaType.IMAGE
322
329
 
323
330
  async def _create_single_image_post(self, request: PostCreateRequest) -> Post:
@@ -46,6 +46,24 @@ from marqetive.platforms.linkedin.models import (
46
46
  SocialMetadata,
47
47
  )
48
48
 
49
+ # Valid CTA (Call-To-Action) labels per LinkedIn API documentation
50
+ # Note: BUY_NOW and SHOP_NOW require API version 202504 or later
51
+ VALID_CTA_LABELS = frozenset({
52
+ "APPLY",
53
+ "DOWNLOAD",
54
+ "VIEW_QUOTE",
55
+ "LEARN_MORE",
56
+ "SIGN_UP",
57
+ "SUBSCRIBE",
58
+ "REGISTER",
59
+ "JOIN",
60
+ "ATTEND",
61
+ "REQUEST_DEMO",
62
+ "SEE_MORE",
63
+ "BUY_NOW", # Requires API version 202504+
64
+ "SHOP_NOW", # Requires API version 202504+
65
+ })
66
+
49
67
 
50
68
  class LinkedInClient(SocialMediaPlatform):
51
69
  """LinkedIn API client using the Community Management API.
@@ -307,29 +325,111 @@ class LinkedInClient(SocialMediaPlatform):
307
325
  """Create and publish a LinkedIn post.
308
326
 
309
327
  Uses the Community Management API to create posts on personal profiles
310
- or organization pages.
328
+ or organization pages. Supports text, images, videos, documents,
329
+ multi-image posts, articles, and Direct Sponsored Content (dark posts).
311
330
 
312
331
  Args:
313
- request: Post creation request. Use additional_data for LinkedIn-specific
314
- options like visibility, distribution, and call-to-action.
332
+ request: Post creation request with the following fields:
333
+ - content: Post text (max 3000 chars). Supports mentions and hashtags.
334
+ - link: URL for article posts.
335
+ - media_ids: List of media URNs (images, videos, documents).
336
+ - additional_data: LinkedIn-specific options (see below).
337
+
338
+ Additional Data Options:
339
+ Basic options:
340
+ - visibility: "PUBLIC" (default), "CONNECTIONS", "LOGGED_IN"
341
+ - feed_distribution: "MAIN_FEED" (default), "NONE" (for dark posts)
342
+ - disable_reshare: bool (default False)
343
+
344
+ Call-to-action:
345
+ - call_to_action: CTA label (APPLY, DOWNLOAD, VIEW_QUOTE, LEARN_MORE,
346
+ SIGN_UP, SUBSCRIBE, REGISTER, JOIN, ATTEND, REQUEST_DEMO,
347
+ SEE_MORE, BUY_NOW, SHOP_NOW)
348
+ - landing_page: URL for CTA button
349
+
350
+ Media options:
351
+ - media_title: Title for single media
352
+ - media_alt_text: Alt text for single media
353
+ - media_alt_texts: List of alt texts for multi-image posts
354
+
355
+ Article options:
356
+ - article_title: Title for link preview
357
+ - article_description: Description for link preview
358
+ - article_thumbnail: Image URN for article thumbnail
359
+
360
+ Targeting (for targeted posts):
361
+ - target_entities: List of targeting criteria
362
+
363
+ Direct Sponsored Content (dark posts):
364
+ - ad_context: Dict with DSC configuration:
365
+ - is_dsc: True for dark posts
366
+ - dsc_ad_type: VIDEO, STANDARD, CAROUSEL, JOB_POSTING,
367
+ NATIVE_DOCUMENT, EVENT
368
+ - dsc_status: ACTIVE, ARCHIVED
369
+ - dsc_ad_account: Sponsored account URN
370
+ - dsc_name: Display name for the DSC
371
+
372
+ Mentions and Hashtags:
373
+ Use these formats in the content field:
374
+ - Organization mention: @[Display Name](urn:li:organization:12345)
375
+ - Person mention: @[Jane Smith](urn:li:person:abc123)
376
+ - Hashtag: #coding (plain text, auto-formatted by LinkedIn)
377
+
378
+ Note: Organization names in mentions must match exactly (case-sensitive).
315
379
 
316
380
  Returns:
317
381
  Created Post object.
318
382
 
319
383
  Raises:
320
- ValidationError: If request is invalid.
384
+ ValidationError: If request is invalid or CTA label is invalid.
321
385
  MediaUploadError: If media upload fails.
322
386
 
323
387
  Example:
388
+ >>> # Text post with link and CTA
324
389
  >>> request = PostCreateRequest(
325
390
  ... content="Check out our new product!",
326
391
  ... link="https://example.com/product",
327
392
  ... additional_data={
328
393
  ... "visibility": "PUBLIC",
329
- ... "call_to_action": "LEARN_MORE"
394
+ ... "call_to_action": "LEARN_MORE",
395
+ ... "landing_page": "https://example.com/signup"
396
+ ... }
397
+ ... )
398
+ >>> post = await client.create_post(request)
399
+ >>>
400
+ >>> # Multi-image post
401
+ >>> img1 = await client.upload_image("photo1.jpg")
402
+ >>> img2 = await client.upload_image("photo2.jpg")
403
+ >>> request = PostCreateRequest(
404
+ ... content="Check out these photos!",
405
+ ... media_ids=[img1.asset_id, img2.asset_id],
406
+ ... additional_data={
407
+ ... "media_alt_texts": ["First photo", "Second photo"]
408
+ ... }
409
+ ... )
410
+ >>> post = await client.create_post(request)
411
+ >>>
412
+ >>> # Dark post (Direct Sponsored Content)
413
+ >>> request = PostCreateRequest(
414
+ ... content="Sponsored content",
415
+ ... media_ids=[video_asset.asset_id],
416
+ ... additional_data={
417
+ ... "ad_context": {
418
+ ... "is_dsc": True,
419
+ ... "dsc_ad_type": "VIDEO",
420
+ ... "dsc_status": "ACTIVE",
421
+ ... "dsc_ad_account": "urn:li:sponsoredAccount:123"
422
+ ... }
330
423
  ... }
331
424
  ... )
332
425
  >>> post = await client.create_post(request)
426
+ >>>
427
+ >>> # Post with mentions and hashtags
428
+ >>> request = PostCreateRequest(
429
+ ... content="Excited to announce our partnership with "
430
+ ... "@[LinkedIn](urn:li:organization:1337)! #exciting #partnership"
431
+ ... )
432
+ >>> post = await client.create_post(request)
333
433
  """
334
434
  if not self.api_client:
335
435
  raise RuntimeError("Client must be used as async context manager")
@@ -370,23 +470,37 @@ class LinkedInClient(SocialMediaPlatform):
370
470
  ),
371
471
  }
372
472
 
373
- # Add media content if provided
473
+ # Add media content if provided (images, videos, or documents)
374
474
  if request.media_ids:
375
- # Determine media type from URN prefix
376
- media_id = request.media_ids[0]
377
- post_payload["content"] = {
378
- "media": {
379
- "id": media_id,
380
- "title": request.additional_data.get("media_title"),
381
- "altText": request.additional_data.get("media_alt_text"),
475
+ if len(request.media_ids) > 1:
476
+ # Multi-image post (organic only, not for sponsored content)
477
+ # Per docs: use "multiImage" content type with array of images
478
+ images = []
479
+ for media_id in request.media_ids:
480
+ image_entry: dict[str, Any] = {"id": media_id}
481
+ # Alt text can be provided per image via additional_data
482
+ alt_texts = request.additional_data.get("media_alt_texts", [])
483
+ if alt_texts and len(alt_texts) > len(images):
484
+ image_entry["altText"] = alt_texts[len(images)]
485
+ images.append(image_entry)
486
+
487
+ post_payload["content"] = {"multiImage": {"images": images}}
488
+ else:
489
+ # Single media (image, video, or document)
490
+ media_id = request.media_ids[0]
491
+ post_payload["content"] = {
492
+ "media": {
493
+ "id": media_id,
494
+ "title": request.additional_data.get("media_title"),
495
+ "altText": request.additional_data.get("media_alt_text"),
496
+ }
497
+ }
498
+ # Remove None values
499
+ post_payload["content"]["media"] = {
500
+ k: v
501
+ for k, v in post_payload["content"]["media"].items()
502
+ if v is not None
382
503
  }
383
- }
384
- # Remove None values
385
- post_payload["content"]["media"] = {
386
- k: v
387
- for k, v in post_payload["content"]["media"].items()
388
- if v is not None
389
- }
390
504
 
391
505
  # Add article/link if provided (and no media)
392
506
  elif request.link:
@@ -411,10 +525,40 @@ class LinkedInClient(SocialMediaPlatform):
411
525
 
412
526
  # Add call-to-action if provided
413
527
  if cta := request.additional_data.get("call_to_action"):
414
- post_payload["contentCallToActionLabel"] = cta
528
+ cta_upper = cta.upper()
529
+ if cta_upper not in VALID_CTA_LABELS:
530
+ raise ValidationError(
531
+ f"Invalid call_to_action: '{cta}'. "
532
+ f"Valid values: {', '.join(sorted(VALID_CTA_LABELS))}",
533
+ platform=self.platform_name,
534
+ field="call_to_action",
535
+ )
536
+ post_payload["contentCallToActionLabel"] = cta_upper
415
537
  if landing_page := request.additional_data.get("landing_page"):
416
538
  post_payload["contentLandingPage"] = landing_page
417
539
 
540
+ # Add adContext for Direct Sponsored Content (DSC) / dark posts
541
+ # Dark posts don't appear on company page but can be used in ad campaigns
542
+ if ad_context := request.additional_data.get("ad_context"):
543
+ post_payload["adContext"] = {}
544
+ if ad_context.get("is_dsc"):
545
+ post_payload["adContext"]["isDsc"] = True
546
+ if dsc_ad_type := ad_context.get("dsc_ad_type"):
547
+ # Valid types: VIDEO, STANDARD, CAROUSEL, JOB_POSTING,
548
+ # NATIVE_DOCUMENT, EVENT
549
+ post_payload["adContext"]["dscAdType"] = dsc_ad_type
550
+ if dsc_status := ad_context.get("dsc_status"):
551
+ # Valid values: ACTIVE, ARCHIVED
552
+ post_payload["adContext"]["dscStatus"] = dsc_status
553
+ if dsc_ad_account := ad_context.get("dsc_ad_account"):
554
+ post_payload["adContext"]["dscAdAccount"] = dsc_ad_account
555
+ if dsc_name := ad_context.get("dsc_name"):
556
+ post_payload["adContext"]["dscName"] = dsc_name
557
+
558
+ # For dark posts, set feedDistribution to NONE
559
+ if ad_context.get("is_dsc"):
560
+ post_payload["distribution"]["feedDistribution"] = "NONE"
561
+
418
562
  # Create the post
419
563
  response = await self.api_client.post("/posts", data=post_payload)
420
564
 
@@ -534,7 +678,15 @@ class LinkedInClient(SocialMediaPlatform):
534
678
  # Handle additional LinkedIn-specific fields
535
679
  additional = getattr(request, "additional_data", {}) or {}
536
680
  if cta := additional.get("call_to_action"):
537
- patch_payload["patch"]["$set"]["contentCallToActionLabel"] = cta
681
+ cta_upper = cta.upper()
682
+ if cta_upper not in VALID_CTA_LABELS:
683
+ raise ValidationError(
684
+ f"Invalid call_to_action: '{cta}'. "
685
+ f"Valid values: {', '.join(sorted(VALID_CTA_LABELS))}",
686
+ platform=self.platform_name,
687
+ field="call_to_action",
688
+ )
689
+ patch_payload["patch"]["$set"]["contentCallToActionLabel"] = cta_upper
538
690
  if landing_page := additional.get("landing_page"):
539
691
  patch_payload["patch"]["$set"]["contentLandingPage"] = landing_page
540
692
  if lifecycle := additional.get("lifecycle_state"):
@@ -597,9 +749,13 @@ class LinkedInClient(SocialMediaPlatform):
597
749
  raise RuntimeError("API client not initialized")
598
750
 
599
751
  encoded_post_id = quote(post_id, safe="")
752
+ headers = {
753
+ **self._build_auth_headers(),
754
+ "X-RestLi-Method": "DELETE",
755
+ }
600
756
  await self.api_client._client.delete(
601
757
  f"{self.base_url}/posts/{encoded_post_id}",
602
- headers=self._build_auth_headers(),
758
+ headers=headers,
603
759
  )
604
760
  return True
605
761
 
@@ -693,6 +849,204 @@ class LinkedInClient(SocialMediaPlatform):
693
849
  platform=self.platform_name,
694
850
  ) from e
695
851
 
852
+ async def create_reshare(
853
+ self,
854
+ parent_post_urn: str,
855
+ commentary: str | None = None,
856
+ visibility: str = "PUBLIC",
857
+ ) -> Post:
858
+ """Create a reshare (repost) of an existing LinkedIn post.
859
+
860
+ Args:
861
+ parent_post_urn: URN of the post to reshare (urn:li:share:xxx or urn:li:ugcPost:xxx).
862
+ commentary: Optional commentary to add to the reshare.
863
+ visibility: Post visibility (PUBLIC, CONNECTIONS, LOGGED_IN).
864
+
865
+ Returns:
866
+ Created Post object.
867
+
868
+ Raises:
869
+ ValidationError: If parent_post_urn is invalid.
870
+
871
+ Example:
872
+ >>> reshare = await client.create_reshare(
873
+ ... "urn:li:share:6957408550713184256",
874
+ ... commentary="Great insights!"
875
+ ... )
876
+ """
877
+ if not self.api_client:
878
+ raise RuntimeError("Client must be used as async context manager")
879
+
880
+ try:
881
+ post_payload: dict[str, Any] = {
882
+ "author": self.author_urn,
883
+ "visibility": visibility,
884
+ "distribution": {
885
+ "feedDistribution": "MAIN_FEED",
886
+ "targetEntities": [],
887
+ "thirdPartyDistributionChannels": [],
888
+ },
889
+ "lifecycleState": "PUBLISHED",
890
+ "reshareContext": {
891
+ "parent": parent_post_urn,
892
+ },
893
+ }
894
+
895
+ if commentary:
896
+ post_payload["commentary"] = commentary
897
+
898
+ response = await self.api_client.post("/posts", data=post_payload)
899
+
900
+ post_id = response.data.get("id") or response.headers.get("x-restli-id")
901
+ if not post_id:
902
+ raise PlatformError(
903
+ "Failed to get post ID from response",
904
+ platform=self.platform_name,
905
+ )
906
+
907
+ return Post(
908
+ post_id=post_id,
909
+ platform=self.platform_name,
910
+ content=commentary or "",
911
+ status=PostStatus.PUBLISHED,
912
+ created_at=datetime.now(),
913
+ author_id=self.author_urn,
914
+ raw_data=response.data,
915
+ )
916
+
917
+ except httpx.HTTPError as e:
918
+ raise PlatformError(
919
+ f"Failed to create reshare: {e}",
920
+ platform=self.platform_name,
921
+ ) from e
922
+
923
+ async def batch_get_posts(self, post_ids: list[str]) -> list[Post]:
924
+ """Retrieve multiple posts by their URNs in a single request.
925
+
926
+ Uses the BATCH_GET method for efficient retrieval of multiple posts.
927
+
928
+ Args:
929
+ post_ids: List of post URNs (urn:li:share:xxx or urn:li:ugcPost:xxx).
930
+
931
+ Returns:
932
+ List of Post objects (in the same order as input if available).
933
+
934
+ Example:
935
+ >>> posts = await client.batch_get_posts([
936
+ ... "urn:li:share:123",
937
+ ... "urn:li:ugcPost:456"
938
+ ... ])
939
+ """
940
+ if not self.api_client:
941
+ raise RuntimeError("Client must be used as async context manager")
942
+
943
+ if not self.api_client._client:
944
+ raise RuntimeError("API client not initialized")
945
+
946
+ if not post_ids:
947
+ return []
948
+
949
+ try:
950
+ # URL encode each post ID
951
+ encoded_ids = [quote(pid, safe="") for pid in post_ids]
952
+ ids_param = f"List({','.join(encoded_ids)})"
953
+
954
+ headers = {
955
+ **self._build_auth_headers(),
956
+ "X-RestLi-Method": "BATCH_GET",
957
+ }
958
+
959
+ response = await self.api_client._client.get(
960
+ f"{self.base_url}/posts",
961
+ params={"ids": ids_param},
962
+ headers=headers,
963
+ )
964
+
965
+ data = response.json()
966
+ results = data.get("results", {})
967
+
968
+ # Parse posts, maintaining order where possible
969
+ posts = []
970
+ for post_id in post_ids:
971
+ if post_id in results:
972
+ posts.append(self._parse_post(results[post_id]))
973
+
974
+ return posts
975
+
976
+ except httpx.HTTPError as e:
977
+ raise PlatformError(
978
+ f"Failed to batch get posts: {e}",
979
+ platform=self.platform_name,
980
+ ) from e
981
+
982
+ async def list_dsc_posts(
983
+ self,
984
+ dsc_ad_account: str,
985
+ dsc_ad_types: list[str] | None = None,
986
+ limit: int = 10,
987
+ offset: int = 0,
988
+ ) -> list[Post]:
989
+ """List Direct Sponsored Content (DSC) posts by ad account.
990
+
991
+ Args:
992
+ dsc_ad_account: Sponsored account URN (urn:li:sponsoredAccount:xxx).
993
+ dsc_ad_types: Optional filter by DSC types (VIDEO, STANDARD, CAROUSEL,
994
+ JOB_POSTING, NATIVE_DOCUMENT, EVENT).
995
+ limit: Maximum number of posts to retrieve (max 100).
996
+ offset: Number of posts to skip for pagination.
997
+
998
+ Returns:
999
+ List of Post objects.
1000
+
1001
+ Example:
1002
+ >>> dsc_posts = await client.list_dsc_posts(
1003
+ ... "urn:li:sponsoredAccount:520866471",
1004
+ ... dsc_ad_types=["VIDEO", "STANDARD"]
1005
+ ... )
1006
+ """
1007
+ if not self.api_client:
1008
+ raise RuntimeError("Client must be used as async context manager")
1009
+
1010
+ if not self.api_client._client:
1011
+ raise RuntimeError("API client not initialized")
1012
+
1013
+ try:
1014
+ encoded_account = quote(dsc_ad_account, safe="")
1015
+
1016
+ params: dict[str, Any] = {
1017
+ "dscAdAccount": encoded_account,
1018
+ "q": "dscAdAccount",
1019
+ "count": min(limit, 100),
1020
+ "start": offset,
1021
+ }
1022
+
1023
+ if dsc_ad_types:
1024
+ params["dscAdTypes"] = f"List({','.join(dsc_ad_types)})"
1025
+
1026
+ headers = {
1027
+ **self._build_auth_headers(),
1028
+ "X-RestLi-Method": "FINDER",
1029
+ }
1030
+
1031
+ response = await self.api_client._client.get(
1032
+ f"{self.base_url}/posts",
1033
+ params=params,
1034
+ headers=headers,
1035
+ )
1036
+
1037
+ posts = []
1038
+ data = response.json()
1039
+ for post_data in data.get("elements", []):
1040
+ posts.append(self._parse_post(post_data))
1041
+
1042
+ return posts
1043
+
1044
+ except httpx.HTTPError as e:
1045
+ raise PlatformError(
1046
+ f"Failed to list DSC posts: {e}",
1047
+ platform=self.platform_name,
1048
+ ) from e
1049
+
696
1050
  # ==================== Comment Methods ====================
697
1051
 
698
1052
  async def get_comments(
@@ -1098,20 +1452,25 @@ class LinkedInClient(SocialMediaPlatform):
1098
1452
  *,
1099
1453
  title: str | None = None,
1100
1454
  ) -> MediaAsset:
1101
- """Upload a document/PDF to LinkedIn.
1455
+ """Upload a document to LinkedIn using the Documents API.
1102
1456
 
1103
- Convenience method for document uploads.
1457
+ Convenience method for document uploads. Supports PDF, PPT, PPTX, DOC, DOCX.
1104
1458
 
1105
1459
  Args:
1106
- file_path: Path to PDF file or URL.
1107
- title: Document title.
1460
+ file_path: Path to document file or URL.
1461
+ title: Document title (reserved for future use).
1108
1462
 
1109
1463
  Returns:
1110
- MediaAsset with asset ID.
1464
+ MediaAsset with document URN (urn:li:document:xxx).
1111
1465
 
1112
1466
  Example:
1113
1467
  >>> async with LinkedInClient(credentials) as client:
1114
1468
  ... asset = await client.upload_document("report.pdf")
1469
+ ... request = PostCreateRequest(
1470
+ ... content="Check out our report!",
1471
+ ... media_ids=[asset.asset_id]
1472
+ ... )
1473
+ ... post = await client.create_post(request)
1115
1474
  """
1116
1475
  if not self._media_manager:
1117
1476
  raise RuntimeError("Client must be used as async context manager")