marqetive-lib 0.1.17__py3-none-any.whl → 0.1.20__py3-none-any.whl

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.
@@ -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.
@@ -301,53 +319,165 @@ class LinkedInClient(SocialMediaPlatform):
301
319
  except httpx.HTTPError:
302
320
  return False
303
321
 
322
+ # ==================== Validation ====================
323
+
324
+ def _validate_create_post_request(self, request: PostCreateRequest) -> None:
325
+ """Validate LinkedIn post creation request.
326
+
327
+ LinkedIn Requirements:
328
+ - Content is required (text post content)
329
+ - Content max 3000 characters
330
+ - CTA labels must be from approved list if provided
331
+ - Media: max 20 images, or 1 video, or 1 document
332
+
333
+ Args:
334
+ request: Post creation request to validate.
335
+
336
+ Raises:
337
+ ValidationError: If validation fails.
338
+ """
339
+ if not request.content:
340
+ raise ValidationError(
341
+ "LinkedIn posts require content",
342
+ platform=self.platform_name,
343
+ field="content",
344
+ )
345
+
346
+ if len(request.content) > 3000:
347
+ raise ValidationError(
348
+ f"Post content exceeds 3000 characters ({len(request.content)} characters)",
349
+ platform=self.platform_name,
350
+ field="content",
351
+ )
352
+
353
+ # Validate CTA label if provided
354
+ if cta := request.additional_data.get("call_to_action"):
355
+ cta_upper = cta.upper()
356
+ if cta_upper not in VALID_CTA_LABELS:
357
+ raise ValidationError(
358
+ f"Invalid call_to_action: '{cta}'. "
359
+ f"Valid values: {', '.join(sorted(VALID_CTA_LABELS))}",
360
+ platform=self.platform_name,
361
+ field="call_to_action",
362
+ )
363
+
304
364
  # ==================== Post CRUD Methods ====================
305
365
 
306
366
  async def create_post(self, request: PostCreateRequest) -> Post:
307
367
  """Create and publish a LinkedIn post.
308
368
 
309
369
  Uses the Community Management API to create posts on personal profiles
310
- or organization pages.
370
+ or organization pages. Supports text, images, videos, documents,
371
+ multi-image posts, articles, and Direct Sponsored Content (dark posts).
311
372
 
312
373
  Args:
313
- request: Post creation request. Use additional_data for LinkedIn-specific
314
- options like visibility, distribution, and call-to-action.
374
+ request: Post creation request with the following fields:
375
+ - content: Post text (max 3000 chars). Supports mentions and hashtags.
376
+ - link: URL for article posts.
377
+ - media_ids: List of media URNs (images, videos, documents).
378
+ - additional_data: LinkedIn-specific options (see below).
379
+
380
+ Additional Data Options:
381
+ Basic options:
382
+ - visibility: "PUBLIC" (default), "CONNECTIONS", "LOGGED_IN"
383
+ - feed_distribution: "MAIN_FEED" (default), "NONE" (for dark posts)
384
+ - disable_reshare: bool (default False)
385
+
386
+ Call-to-action:
387
+ - call_to_action: CTA label (APPLY, DOWNLOAD, VIEW_QUOTE, LEARN_MORE,
388
+ SIGN_UP, SUBSCRIBE, REGISTER, JOIN, ATTEND, REQUEST_DEMO,
389
+ SEE_MORE, BUY_NOW, SHOP_NOW)
390
+ - landing_page: URL for CTA button
391
+
392
+ Media options:
393
+ - media_title: Title for single media
394
+ - media_alt_text: Alt text for single media
395
+ - media_alt_texts: List of alt texts for multi-image posts
396
+
397
+ Article options:
398
+ - article_title: Title for link preview
399
+ - article_description: Description for link preview
400
+ - article_thumbnail: Image URN for article thumbnail
401
+
402
+ Targeting (for targeted posts):
403
+ - target_entities: List of targeting criteria
404
+
405
+ Direct Sponsored Content (dark posts):
406
+ - ad_context: Dict with DSC configuration:
407
+ - is_dsc: True for dark posts
408
+ - dsc_ad_type: VIDEO, STANDARD, CAROUSEL, JOB_POSTING,
409
+ NATIVE_DOCUMENT, EVENT
410
+ - dsc_status: ACTIVE, ARCHIVED
411
+ - dsc_ad_account: Sponsored account URN
412
+ - dsc_name: Display name for the DSC
413
+
414
+ Mentions and Hashtags:
415
+ Use these formats in the content field:
416
+ - Organization mention: @[Display Name](urn:li:organization:12345)
417
+ - Person mention: @[Jane Smith](urn:li:person:abc123)
418
+ - Hashtag: #coding (plain text, auto-formatted by LinkedIn)
419
+
420
+ Note: Organization names in mentions must match exactly (case-sensitive).
315
421
 
316
422
  Returns:
317
423
  Created Post object.
318
424
 
319
425
  Raises:
320
- ValidationError: If request is invalid.
426
+ ValidationError: If request is invalid or CTA label is invalid.
321
427
  MediaUploadError: If media upload fails.
322
428
 
323
429
  Example:
430
+ >>> # Text post with link and CTA
324
431
  >>> request = PostCreateRequest(
325
432
  ... content="Check out our new product!",
326
433
  ... link="https://example.com/product",
327
434
  ... additional_data={
328
435
  ... "visibility": "PUBLIC",
329
- ... "call_to_action": "LEARN_MORE"
436
+ ... "call_to_action": "LEARN_MORE",
437
+ ... "landing_page": "https://example.com/signup"
330
438
  ... }
331
439
  ... )
332
440
  >>> post = await client.create_post(request)
441
+ >>>
442
+ >>> # Multi-image post
443
+ >>> img1 = await client.upload_image("photo1.jpg")
444
+ >>> img2 = await client.upload_image("photo2.jpg")
445
+ >>> request = PostCreateRequest(
446
+ ... content="Check out these photos!",
447
+ ... media_ids=[img1.asset_id, img2.asset_id],
448
+ ... additional_data={
449
+ ... "media_alt_texts": ["First photo", "Second photo"]
450
+ ... }
451
+ ... )
452
+ >>> post = await client.create_post(request)
453
+ >>>
454
+ >>> # Dark post (Direct Sponsored Content)
455
+ >>> request = PostCreateRequest(
456
+ ... content="Sponsored content",
457
+ ... media_ids=[video_asset.asset_id],
458
+ ... additional_data={
459
+ ... "ad_context": {
460
+ ... "is_dsc": True,
461
+ ... "dsc_ad_type": "VIDEO",
462
+ ... "dsc_status": "ACTIVE",
463
+ ... "dsc_ad_account": "urn:li:sponsoredAccount:123"
464
+ ... }
465
+ ... }
466
+ ... )
467
+ >>> post = await client.create_post(request)
468
+ >>>
469
+ >>> # Post with mentions and hashtags
470
+ >>> request = PostCreateRequest(
471
+ ... content="Excited to announce our partnership with "
472
+ ... "@[LinkedIn](urn:li:organization:1337)! #exciting #partnership"
473
+ ... )
474
+ >>> post = await client.create_post(request)
333
475
  """
334
476
  if not self.api_client:
335
477
  raise RuntimeError("Client must be used as async context manager")
336
478
 
337
- if not request.content:
338
- raise ValidationError(
339
- "LinkedIn posts require content",
340
- platform=self.platform_name,
341
- field="content",
342
- )
343
-
344
- # Validate content length (3000 characters for posts)
345
- if len(request.content) > 3000:
346
- raise ValidationError(
347
- f"Post content exceeds 3000 characters ({len(request.content)} characters)",
348
- platform=self.platform_name,
349
- field="content",
350
- )
479
+ # Validate request
480
+ self._validate_create_post_request(request)
351
481
 
352
482
  try:
353
483
  # Build REST API payload structure
@@ -370,23 +500,37 @@ class LinkedInClient(SocialMediaPlatform):
370
500
  ),
371
501
  }
372
502
 
373
- # Add media content if provided
503
+ # Add media content if provided (images, videos, or documents)
374
504
  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"),
505
+ if len(request.media_ids) > 1:
506
+ # Multi-image post (organic only, not for sponsored content)
507
+ # Per docs: use "multiImage" content type with array of images
508
+ images = []
509
+ for media_id in request.media_ids:
510
+ image_entry: dict[str, Any] = {"id": media_id}
511
+ # Alt text can be provided per image via additional_data
512
+ alt_texts = request.additional_data.get("media_alt_texts", [])
513
+ if alt_texts and len(alt_texts) > len(images):
514
+ image_entry["altText"] = alt_texts[len(images)]
515
+ images.append(image_entry)
516
+
517
+ post_payload["content"] = {"multiImage": {"images": images}}
518
+ else:
519
+ # Single media (image, video, or document)
520
+ media_id = request.media_ids[0]
521
+ post_payload["content"] = {
522
+ "media": {
523
+ "id": media_id,
524
+ "title": request.additional_data.get("media_title"),
525
+ "altText": request.additional_data.get("media_alt_text"),
526
+ }
527
+ }
528
+ # Remove None values
529
+ post_payload["content"]["media"] = {
530
+ k: v
531
+ for k, v in post_payload["content"]["media"].items()
532
+ if v is not None
382
533
  }
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
534
 
391
535
  # Add article/link if provided (and no media)
392
536
  elif request.link:
@@ -411,10 +555,40 @@ class LinkedInClient(SocialMediaPlatform):
411
555
 
412
556
  # Add call-to-action if provided
413
557
  if cta := request.additional_data.get("call_to_action"):
414
- post_payload["contentCallToActionLabel"] = cta
558
+ cta_upper = cta.upper()
559
+ if cta_upper not in VALID_CTA_LABELS:
560
+ raise ValidationError(
561
+ f"Invalid call_to_action: '{cta}'. "
562
+ f"Valid values: {', '.join(sorted(VALID_CTA_LABELS))}",
563
+ platform=self.platform_name,
564
+ field="call_to_action",
565
+ )
566
+ post_payload["contentCallToActionLabel"] = cta_upper
415
567
  if landing_page := request.additional_data.get("landing_page"):
416
568
  post_payload["contentLandingPage"] = landing_page
417
569
 
570
+ # Add adContext for Direct Sponsored Content (DSC) / dark posts
571
+ # Dark posts don't appear on company page but can be used in ad campaigns
572
+ if ad_context := request.additional_data.get("ad_context"):
573
+ post_payload["adContext"] = {}
574
+ if ad_context.get("is_dsc"):
575
+ post_payload["adContext"]["isDsc"] = True
576
+ if dsc_ad_type := ad_context.get("dsc_ad_type"):
577
+ # Valid types: VIDEO, STANDARD, CAROUSEL, JOB_POSTING,
578
+ # NATIVE_DOCUMENT, EVENT
579
+ post_payload["adContext"]["dscAdType"] = dsc_ad_type
580
+ if dsc_status := ad_context.get("dsc_status"):
581
+ # Valid values: ACTIVE, ARCHIVED
582
+ post_payload["adContext"]["dscStatus"] = dsc_status
583
+ if dsc_ad_account := ad_context.get("dsc_ad_account"):
584
+ post_payload["adContext"]["dscAdAccount"] = dsc_ad_account
585
+ if dsc_name := ad_context.get("dsc_name"):
586
+ post_payload["adContext"]["dscName"] = dsc_name
587
+
588
+ # For dark posts, set feedDistribution to NONE
589
+ if ad_context.get("is_dsc"):
590
+ post_payload["distribution"]["feedDistribution"] = "NONE"
591
+
418
592
  # Create the post
419
593
  response = await self.api_client.post("/posts", data=post_payload)
420
594
 
@@ -434,6 +608,7 @@ class LinkedInClient(SocialMediaPlatform):
434
608
  status=PostStatus.PUBLISHED,
435
609
  created_at=datetime.now(),
436
610
  author_id=self.author_urn,
611
+ url=None, # Not available without separate fetch
437
612
  raw_data=response.data,
438
613
  )
439
614
 
@@ -534,7 +709,15 @@ class LinkedInClient(SocialMediaPlatform):
534
709
  # Handle additional LinkedIn-specific fields
535
710
  additional = getattr(request, "additional_data", {}) or {}
536
711
  if cta := additional.get("call_to_action"):
537
- patch_payload["patch"]["$set"]["contentCallToActionLabel"] = cta
712
+ cta_upper = cta.upper()
713
+ if cta_upper not in VALID_CTA_LABELS:
714
+ raise ValidationError(
715
+ f"Invalid call_to_action: '{cta}'. "
716
+ f"Valid values: {', '.join(sorted(VALID_CTA_LABELS))}",
717
+ platform=self.platform_name,
718
+ field="call_to_action",
719
+ )
720
+ patch_payload["patch"]["$set"]["contentCallToActionLabel"] = cta_upper
538
721
  if landing_page := additional.get("landing_page"):
539
722
  patch_payload["patch"]["$set"]["contentLandingPage"] = landing_page
540
723
  if lifecycle := additional.get("lifecycle_state"):
@@ -597,9 +780,13 @@ class LinkedInClient(SocialMediaPlatform):
597
780
  raise RuntimeError("API client not initialized")
598
781
 
599
782
  encoded_post_id = quote(post_id, safe="")
783
+ headers = {
784
+ **self._build_auth_headers(),
785
+ "X-RestLi-Method": "DELETE",
786
+ }
600
787
  await self.api_client._client.delete(
601
788
  f"{self.base_url}/posts/{encoded_post_id}",
602
- headers=self._build_auth_headers(),
789
+ headers=headers,
603
790
  )
604
791
  return True
605
792
 
@@ -693,6 +880,204 @@ class LinkedInClient(SocialMediaPlatform):
693
880
  platform=self.platform_name,
694
881
  ) from e
695
882
 
883
+ async def create_reshare(
884
+ self,
885
+ parent_post_urn: str,
886
+ commentary: str | None = None,
887
+ visibility: str = "PUBLIC",
888
+ ) -> Post:
889
+ """Create a reshare (repost) of an existing LinkedIn post.
890
+
891
+ Args:
892
+ parent_post_urn: URN of the post to reshare (urn:li:share:xxx or urn:li:ugcPost:xxx).
893
+ commentary: Optional commentary to add to the reshare.
894
+ visibility: Post visibility (PUBLIC, CONNECTIONS, LOGGED_IN).
895
+
896
+ Returns:
897
+ Created Post object.
898
+
899
+ Raises:
900
+ ValidationError: If parent_post_urn is invalid.
901
+
902
+ Example:
903
+ >>> reshare = await client.create_reshare(
904
+ ... "urn:li:share:6957408550713184256",
905
+ ... commentary="Great insights!"
906
+ ... )
907
+ """
908
+ if not self.api_client:
909
+ raise RuntimeError("Client must be used as async context manager")
910
+
911
+ try:
912
+ post_payload: dict[str, Any] = {
913
+ "author": self.author_urn,
914
+ "visibility": visibility,
915
+ "distribution": {
916
+ "feedDistribution": "MAIN_FEED",
917
+ "targetEntities": [],
918
+ "thirdPartyDistributionChannels": [],
919
+ },
920
+ "lifecycleState": "PUBLISHED",
921
+ "reshareContext": {
922
+ "parent": parent_post_urn,
923
+ },
924
+ }
925
+
926
+ if commentary:
927
+ post_payload["commentary"] = commentary
928
+
929
+ response = await self.api_client.post("/posts", data=post_payload)
930
+
931
+ post_id = response.data.get("id") or response.headers.get("x-restli-id")
932
+ if not post_id:
933
+ raise PlatformError(
934
+ "Failed to get post ID from response",
935
+ platform=self.platform_name,
936
+ )
937
+
938
+ return Post(
939
+ post_id=post_id,
940
+ platform=self.platform_name,
941
+ content=commentary or "",
942
+ status=PostStatus.PUBLISHED,
943
+ created_at=datetime.now(),
944
+ author_id=self.author_urn,
945
+ raw_data=response.data,
946
+ )
947
+
948
+ except httpx.HTTPError as e:
949
+ raise PlatformError(
950
+ f"Failed to create reshare: {e}",
951
+ platform=self.platform_name,
952
+ ) from e
953
+
954
+ async def batch_get_posts(self, post_ids: list[str]) -> list[Post]:
955
+ """Retrieve multiple posts by their URNs in a single request.
956
+
957
+ Uses the BATCH_GET method for efficient retrieval of multiple posts.
958
+
959
+ Args:
960
+ post_ids: List of post URNs (urn:li:share:xxx or urn:li:ugcPost:xxx).
961
+
962
+ Returns:
963
+ List of Post objects (in the same order as input if available).
964
+
965
+ Example:
966
+ >>> posts = await client.batch_get_posts([
967
+ ... "urn:li:share:123",
968
+ ... "urn:li:ugcPost:456"
969
+ ... ])
970
+ """
971
+ if not self.api_client:
972
+ raise RuntimeError("Client must be used as async context manager")
973
+
974
+ if not self.api_client._client:
975
+ raise RuntimeError("API client not initialized")
976
+
977
+ if not post_ids:
978
+ return []
979
+
980
+ try:
981
+ # URL encode each post ID
982
+ encoded_ids = [quote(pid, safe="") for pid in post_ids]
983
+ ids_param = f"List({','.join(encoded_ids)})"
984
+
985
+ headers = {
986
+ **self._build_auth_headers(),
987
+ "X-RestLi-Method": "BATCH_GET",
988
+ }
989
+
990
+ response = await self.api_client._client.get(
991
+ f"{self.base_url}/posts",
992
+ params={"ids": ids_param},
993
+ headers=headers,
994
+ )
995
+
996
+ data = response.json()
997
+ results = data.get("results", {})
998
+
999
+ # Parse posts, maintaining order where possible
1000
+ posts = []
1001
+ for post_id in post_ids:
1002
+ if post_id in results:
1003
+ posts.append(self._parse_post(results[post_id]))
1004
+
1005
+ return posts
1006
+
1007
+ except httpx.HTTPError as e:
1008
+ raise PlatformError(
1009
+ f"Failed to batch get posts: {e}",
1010
+ platform=self.platform_name,
1011
+ ) from e
1012
+
1013
+ async def list_dsc_posts(
1014
+ self,
1015
+ dsc_ad_account: str,
1016
+ dsc_ad_types: list[str] | None = None,
1017
+ limit: int = 10,
1018
+ offset: int = 0,
1019
+ ) -> list[Post]:
1020
+ """List Direct Sponsored Content (DSC) posts by ad account.
1021
+
1022
+ Args:
1023
+ dsc_ad_account: Sponsored account URN (urn:li:sponsoredAccount:xxx).
1024
+ dsc_ad_types: Optional filter by DSC types (VIDEO, STANDARD, CAROUSEL,
1025
+ JOB_POSTING, NATIVE_DOCUMENT, EVENT).
1026
+ limit: Maximum number of posts to retrieve (max 100).
1027
+ offset: Number of posts to skip for pagination.
1028
+
1029
+ Returns:
1030
+ List of Post objects.
1031
+
1032
+ Example:
1033
+ >>> dsc_posts = await client.list_dsc_posts(
1034
+ ... "urn:li:sponsoredAccount:520866471",
1035
+ ... dsc_ad_types=["VIDEO", "STANDARD"]
1036
+ ... )
1037
+ """
1038
+ if not self.api_client:
1039
+ raise RuntimeError("Client must be used as async context manager")
1040
+
1041
+ if not self.api_client._client:
1042
+ raise RuntimeError("API client not initialized")
1043
+
1044
+ try:
1045
+ encoded_account = quote(dsc_ad_account, safe="")
1046
+
1047
+ params: dict[str, Any] = {
1048
+ "dscAdAccount": encoded_account,
1049
+ "q": "dscAdAccount",
1050
+ "count": min(limit, 100),
1051
+ "start": offset,
1052
+ }
1053
+
1054
+ if dsc_ad_types:
1055
+ params["dscAdTypes"] = f"List({','.join(dsc_ad_types)})"
1056
+
1057
+ headers = {
1058
+ **self._build_auth_headers(),
1059
+ "X-RestLi-Method": "FINDER",
1060
+ }
1061
+
1062
+ response = await self.api_client._client.get(
1063
+ f"{self.base_url}/posts",
1064
+ params=params,
1065
+ headers=headers,
1066
+ )
1067
+
1068
+ posts = []
1069
+ data = response.json()
1070
+ for post_data in data.get("elements", []):
1071
+ posts.append(self._parse_post(post_data))
1072
+
1073
+ return posts
1074
+
1075
+ except httpx.HTTPError as e:
1076
+ raise PlatformError(
1077
+ f"Failed to list DSC posts: {e}",
1078
+ platform=self.platform_name,
1079
+ ) from e
1080
+
696
1081
  # ==================== Comment Methods ====================
697
1082
 
698
1083
  async def get_comments(
@@ -1098,20 +1483,25 @@ class LinkedInClient(SocialMediaPlatform):
1098
1483
  *,
1099
1484
  title: str | None = None,
1100
1485
  ) -> MediaAsset:
1101
- """Upload a document/PDF to LinkedIn.
1486
+ """Upload a document to LinkedIn using the Documents API.
1102
1487
 
1103
- Convenience method for document uploads.
1488
+ Convenience method for document uploads. Supports PDF, PPT, PPTX, DOC, DOCX.
1104
1489
 
1105
1490
  Args:
1106
- file_path: Path to PDF file or URL.
1107
- title: Document title.
1491
+ file_path: Path to document file or URL.
1492
+ title: Document title (reserved for future use).
1108
1493
 
1109
1494
  Returns:
1110
- MediaAsset with asset ID.
1495
+ MediaAsset with document URN (urn:li:document:xxx).
1111
1496
 
1112
1497
  Example:
1113
1498
  >>> async with LinkedInClient(credentials) as client:
1114
1499
  ... asset = await client.upload_document("report.pdf")
1500
+ ... request = PostCreateRequest(
1501
+ ... content="Check out our report!",
1502
+ ... media_ids=[asset.asset_id]
1503
+ ... )
1504
+ ... post = await client.create_post(request)
1115
1505
  """
1116
1506
  if not self._media_manager:
1117
1507
  raise RuntimeError("Client must be used as async context manager")