marqetive-lib 0.1.17__py3-none-any.whl → 0.1.18__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.
- marqetive/platforms/linkedin/client.py +387 -28
- marqetive/platforms/linkedin/media.py +320 -211
- {marqetive_lib-0.1.17.dist-info → marqetive_lib-0.1.18.dist-info}/METADATA +1 -1
- {marqetive_lib-0.1.17.dist-info → marqetive_lib-0.1.18.dist-info}/RECORD +5 -5
- {marqetive_lib-0.1.17.dist-info → marqetive_lib-0.1.18.dist-info}/WHEEL +0 -0
|
@@ -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
|
|
314
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
"
|
|
381
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
|
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
|
|
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
|
|
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")
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
"""LinkedIn media upload manager with support for images, videos, and documents.
|
|
2
2
|
|
|
3
|
-
LinkedIn uses
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
LinkedIn uses the REST API for media uploads (Community Management API):
|
|
4
|
+
- Images API: POST /rest/images?action=initializeUpload
|
|
5
|
+
- Videos API: POST /rest/videos?action=initializeUpload + finalizeUpload
|
|
6
|
+
- Documents API: POST /rest/documents?action=initializeUpload
|
|
7
|
+
|
|
8
|
+
Upload process:
|
|
9
|
+
1. Initialize upload (get upload URL and asset URN)
|
|
10
|
+
2. Upload file to the URL (PUT)
|
|
11
|
+
3. For videos: Finalize upload with ETags from each chunk
|
|
12
|
+
4. For videos: Wait for processing to complete
|
|
7
13
|
|
|
8
14
|
This module supports:
|
|
9
15
|
- Image uploads (single and multiple)
|
|
10
|
-
- Video uploads with processing monitoring
|
|
16
|
+
- Video uploads with chunked upload and processing monitoring
|
|
11
17
|
- Document/PDF uploads
|
|
12
18
|
"""
|
|
13
19
|
|
|
@@ -37,23 +43,44 @@ type ProgressCallback = SyncProgressCallback | AsyncProgressCallback
|
|
|
37
43
|
|
|
38
44
|
logger = logging.getLogger(__name__)
|
|
39
45
|
|
|
40
|
-
# LinkedIn limits
|
|
46
|
+
# LinkedIn limits (per REST API documentation)
|
|
41
47
|
MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10MB
|
|
42
|
-
MAX_VIDEO_SIZE =
|
|
43
|
-
|
|
44
|
-
|
|
48
|
+
MAX_VIDEO_SIZE = 5 * 1024 * 1024 * 1024 # 5GB (for multi-part uploads)
|
|
49
|
+
MAX_VIDEO_SIZE_SINGLE = 200 * 1024 * 1024 # 200MB (practical limit for single upload)
|
|
50
|
+
MAX_DOCUMENT_SIZE = 100 * 1024 * 1024 # 100MB (per LinkedIn docs)
|
|
51
|
+
MAX_VIDEO_DURATION = 1800 # 30 minutes (per docs: 3 seconds to 30 minutes)
|
|
52
|
+
MAX_DOCUMENT_PAGES = 300 # Maximum pages for documents
|
|
53
|
+
|
|
54
|
+
# Video chunk size for multi-part uploads (per LinkedIn docs: 4MB per part)
|
|
55
|
+
VIDEO_CHUNK_SIZE = 4 * 1024 * 1024 # 4MB (4,194,304 bytes)
|
|
56
|
+
|
|
57
|
+
# Supported document MIME types
|
|
58
|
+
SUPPORTED_DOCUMENT_TYPES = {
|
|
59
|
+
"application/pdf",
|
|
60
|
+
"application/vnd.ms-powerpoint", # PPT
|
|
61
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation", # PPTX
|
|
62
|
+
"application/msword", # DOC
|
|
63
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", # DOCX
|
|
64
|
+
}
|
|
45
65
|
|
|
46
66
|
# Processing timeouts
|
|
47
67
|
VIDEO_PROCESSING_TIMEOUT = 600 # 10 minutes
|
|
48
68
|
|
|
49
69
|
|
|
50
70
|
class VideoProcessingState(str, Enum):
|
|
51
|
-
"""LinkedIn video processing states.
|
|
71
|
+
"""LinkedIn video processing states (per REST Videos API).
|
|
72
|
+
|
|
73
|
+
Status values:
|
|
74
|
+
PROCESSING: Asset processing to generate missing artifacts
|
|
75
|
+
PROCESSING_FAILED: Processing failed (file size, format, internal error)
|
|
76
|
+
AVAILABLE: Ready for use; all required artifacts available
|
|
77
|
+
WAITING_UPLOAD: Waiting for source file upload completion
|
|
78
|
+
"""
|
|
52
79
|
|
|
53
80
|
PROCESSING = "PROCESSING"
|
|
54
|
-
|
|
55
|
-
FAILED = "FAILED"
|
|
81
|
+
PROCESSING_FAILED = "PROCESSING_FAILED"
|
|
56
82
|
AVAILABLE = "AVAILABLE"
|
|
83
|
+
WAITING_UPLOAD = "WAITING_UPLOAD"
|
|
57
84
|
|
|
58
85
|
|
|
59
86
|
@dataclass
|
|
@@ -134,9 +161,8 @@ class LinkedInMediaManager:
|
|
|
134
161
|
self.timeout = timeout
|
|
135
162
|
self.progress_callback = progress_callback
|
|
136
163
|
|
|
137
|
-
# Use
|
|
138
|
-
|
|
139
|
-
self.base_url = "https://api.linkedin.com/v2"
|
|
164
|
+
# Use REST API for media uploads (Community Management API)
|
|
165
|
+
self.base_url = "https://api.linkedin.com/rest"
|
|
140
166
|
self.client = httpx.AsyncClient(
|
|
141
167
|
timeout=httpx.Timeout(timeout),
|
|
142
168
|
headers={
|
|
@@ -199,22 +225,28 @@ class LinkedInMediaManager:
|
|
|
199
225
|
*,
|
|
200
226
|
alt_text: str | None = None, # noqa: ARG002
|
|
201
227
|
) -> MediaAsset:
|
|
202
|
-
"""Upload an image to LinkedIn.
|
|
228
|
+
"""Upload an image to LinkedIn using the REST Images API.
|
|
229
|
+
|
|
230
|
+
Uses the REST API endpoint: POST /rest/images?action=initializeUpload
|
|
231
|
+
https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/images-api
|
|
203
232
|
|
|
204
233
|
Args:
|
|
205
234
|
file_path: Path to image file or URL.
|
|
206
|
-
alt_text: Alternative text for accessibility.
|
|
235
|
+
alt_text: Alternative text for accessibility (stored for reference).
|
|
207
236
|
|
|
208
237
|
Returns:
|
|
209
|
-
MediaAsset with
|
|
238
|
+
MediaAsset with image URN (urn:li:image:xxx).
|
|
210
239
|
|
|
211
240
|
Raises:
|
|
212
241
|
MediaUploadError: If upload fails.
|
|
213
242
|
ValidationError: If file is invalid.
|
|
214
243
|
|
|
244
|
+
Supported formats:
|
|
245
|
+
JPG, GIF, PNG (GIF up to 250 frames)
|
|
246
|
+
|
|
215
247
|
Example:
|
|
216
248
|
>>> asset = await manager.upload_image("photo.jpg")
|
|
217
|
-
>>> print(f"
|
|
249
|
+
>>> print(f"Image URN: {asset.asset_id}")
|
|
218
250
|
"""
|
|
219
251
|
# Download if URL
|
|
220
252
|
if file_path.startswith(("http://", "https://")):
|
|
@@ -238,52 +270,76 @@ class LinkedInMediaManager:
|
|
|
238
270
|
platform="linkedin",
|
|
239
271
|
)
|
|
240
272
|
|
|
241
|
-
#
|
|
242
|
-
|
|
243
|
-
"registerUploadRequest": {
|
|
244
|
-
"recipes": ["urn:li:digitalmediaRecipe:feedshare-image"],
|
|
245
|
-
"owner": self.person_urn,
|
|
246
|
-
"serviceRelationships": [
|
|
247
|
-
{
|
|
248
|
-
"relationshipType": "OWNER",
|
|
249
|
-
"identifier": "urn:li:userGeneratedContent",
|
|
250
|
-
}
|
|
251
|
-
],
|
|
252
|
-
}
|
|
253
|
-
}
|
|
273
|
+
# Step 1: Initialize upload using REST Images API
|
|
274
|
+
init_payload = {"initializeUploadRequest": {"owner": self.person_urn}}
|
|
254
275
|
|
|
255
|
-
|
|
276
|
+
try:
|
|
277
|
+
response = await self.client.post(
|
|
278
|
+
f"{self.base_url}/images?action=initializeUpload",
|
|
279
|
+
json=init_payload,
|
|
280
|
+
)
|
|
281
|
+
response.raise_for_status()
|
|
282
|
+
init_result = response.json()
|
|
283
|
+
except httpx.HTTPError as e:
|
|
284
|
+
raise MediaUploadError(
|
|
285
|
+
f"Failed to initialize image upload: {e}",
|
|
286
|
+
platform="linkedin",
|
|
287
|
+
media_type="image",
|
|
288
|
+
) from e
|
|
289
|
+
|
|
290
|
+
# Extract upload URL and image URN from response
|
|
291
|
+
upload_url = init_result["value"]["uploadUrl"]
|
|
292
|
+
image_urn = init_result["value"]["image"]
|
|
256
293
|
|
|
257
294
|
# Notify start
|
|
258
295
|
await self._emit_progress(
|
|
259
296
|
status=ProgressStatus.INITIALIZING,
|
|
260
297
|
progress=0,
|
|
261
298
|
total=100,
|
|
262
|
-
message="Registering upload",
|
|
263
|
-
entity_id=
|
|
299
|
+
message="Registering image upload",
|
|
300
|
+
entity_id=image_urn,
|
|
264
301
|
bytes_uploaded=0,
|
|
265
302
|
total_bytes=file_size,
|
|
266
303
|
)
|
|
267
304
|
|
|
268
|
-
#
|
|
269
|
-
|
|
305
|
+
# Step 2: Upload image binary via PUT
|
|
306
|
+
await self._emit_progress(
|
|
307
|
+
status=ProgressStatus.UPLOADING,
|
|
308
|
+
progress=0,
|
|
309
|
+
total=100,
|
|
310
|
+
message="Uploading image",
|
|
311
|
+
entity_id=image_urn,
|
|
312
|
+
bytes_uploaded=0,
|
|
313
|
+
total_bytes=file_size,
|
|
314
|
+
)
|
|
270
315
|
|
|
271
|
-
|
|
272
|
-
|
|
316
|
+
try:
|
|
317
|
+
upload_response = await self.client.put(
|
|
318
|
+
upload_url,
|
|
319
|
+
content=file_bytes,
|
|
320
|
+
headers={"Content-Type": "application/octet-stream"},
|
|
321
|
+
)
|
|
322
|
+
upload_response.raise_for_status()
|
|
323
|
+
except httpx.HTTPError as e:
|
|
324
|
+
raise MediaUploadError(
|
|
325
|
+
f"Failed to upload image binary: {e}",
|
|
326
|
+
platform="linkedin",
|
|
327
|
+
media_type="image",
|
|
328
|
+
) from e
|
|
273
329
|
|
|
274
330
|
# Notify completion
|
|
275
331
|
await self._emit_progress(
|
|
276
332
|
status=ProgressStatus.COMPLETED,
|
|
277
333
|
progress=100,
|
|
278
334
|
total=100,
|
|
279
|
-
message="
|
|
280
|
-
entity_id=
|
|
335
|
+
message="Image upload completed",
|
|
336
|
+
entity_id=image_urn,
|
|
281
337
|
bytes_uploaded=file_size,
|
|
282
338
|
total_bytes=file_size,
|
|
283
339
|
)
|
|
284
340
|
|
|
285
|
-
logger.info(f"Image uploaded successfully: {
|
|
286
|
-
return MediaAsset(asset_id=
|
|
341
|
+
logger.info(f"Image uploaded successfully: {image_urn}")
|
|
342
|
+
return MediaAsset(asset_id=image_urn, status="AVAILABLE")
|
|
287
343
|
|
|
288
344
|
async def upload_video(
|
|
289
345
|
self,
|
|
@@ -292,26 +348,39 @@ class LinkedInMediaManager:
|
|
|
292
348
|
title: str | None = None, # noqa: ARG002
|
|
293
349
|
wait_for_processing: bool = True,
|
|
294
350
|
) -> MediaAsset:
|
|
295
|
-
"""Upload a video to LinkedIn.
|
|
351
|
+
"""Upload a video to LinkedIn using the REST Videos API.
|
|
352
|
+
|
|
353
|
+
Uses the REST API endpoints:
|
|
354
|
+
- POST /rest/videos?action=initializeUpload
|
|
355
|
+
- PUT {uploadUrl} (for each chunk)
|
|
356
|
+
- POST /rest/videos?action=finalizeUpload
|
|
357
|
+
|
|
358
|
+
https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/videos-api
|
|
296
359
|
|
|
297
360
|
Args:
|
|
298
361
|
file_path: Path to video file or URL.
|
|
299
|
-
title: Video title.
|
|
362
|
+
title: Video title (reserved for future use).
|
|
300
363
|
wait_for_processing: Wait for video processing to complete.
|
|
301
364
|
|
|
302
365
|
Returns:
|
|
303
|
-
MediaAsset with
|
|
366
|
+
MediaAsset with video URN (urn:li:video:xxx).
|
|
304
367
|
|
|
305
368
|
Raises:
|
|
306
369
|
MediaUploadError: If upload or processing fails.
|
|
307
370
|
ValidationError: If file is invalid.
|
|
308
371
|
|
|
372
|
+
Specifications:
|
|
373
|
+
- Length: 3 seconds to 30 minutes
|
|
374
|
+
- File size: 75 KB to 5 GB
|
|
375
|
+
- Format: MP4
|
|
376
|
+
- Chunk size: 4 MB per part (for multi-part uploads)
|
|
377
|
+
|
|
309
378
|
Example:
|
|
310
379
|
>>> asset = await manager.upload_video(
|
|
311
380
|
... "video.mp4",
|
|
312
|
-
... title="My Video",
|
|
313
381
|
... wait_for_processing=True
|
|
314
382
|
... )
|
|
383
|
+
>>> print(f"Video URN: {asset.asset_id}")
|
|
315
384
|
"""
|
|
316
385
|
# Download if URL
|
|
317
386
|
if file_path.startswith(("http://", "https://")):
|
|
@@ -335,21 +404,34 @@ class LinkedInMediaManager:
|
|
|
335
404
|
platform="linkedin",
|
|
336
405
|
)
|
|
337
406
|
|
|
338
|
-
#
|
|
339
|
-
|
|
340
|
-
"
|
|
341
|
-
"recipes": ["urn:li:digitalmediaRecipe:feedshare-video"],
|
|
407
|
+
# Step 1: Initialize upload using REST Videos API
|
|
408
|
+
init_payload = {
|
|
409
|
+
"initializeUploadRequest": {
|
|
342
410
|
"owner": self.person_urn,
|
|
343
|
-
"
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
"identifier": "urn:li:userGeneratedContent",
|
|
347
|
-
}
|
|
348
|
-
],
|
|
411
|
+
"fileSizeBytes": file_size,
|
|
412
|
+
"uploadCaptions": False,
|
|
413
|
+
"uploadThumbnail": False,
|
|
349
414
|
}
|
|
350
415
|
}
|
|
351
416
|
|
|
352
|
-
|
|
417
|
+
try:
|
|
418
|
+
response = await self.client.post(
|
|
419
|
+
f"{self.base_url}/videos?action=initializeUpload",
|
|
420
|
+
json=init_payload,
|
|
421
|
+
)
|
|
422
|
+
response.raise_for_status()
|
|
423
|
+
init_result = response.json()
|
|
424
|
+
except httpx.HTTPError as e:
|
|
425
|
+
raise MediaUploadError(
|
|
426
|
+
f"Failed to initialize video upload: {e}",
|
|
427
|
+
platform="linkedin",
|
|
428
|
+
media_type="video",
|
|
429
|
+
) from e
|
|
430
|
+
|
|
431
|
+
# Extract video URN and upload instructions
|
|
432
|
+
video_urn = init_result["value"]["video"]
|
|
433
|
+
upload_instructions = init_result["value"]["uploadInstructions"]
|
|
434
|
+
upload_token = init_result["value"].get("uploadToken", "")
|
|
353
435
|
|
|
354
436
|
# Notify start
|
|
355
437
|
await self._emit_progress(
|
|
@@ -357,20 +439,94 @@ class LinkedInMediaManager:
|
|
|
357
439
|
progress=0,
|
|
358
440
|
total=100,
|
|
359
441
|
message="Registering video upload",
|
|
360
|
-
entity_id=
|
|
442
|
+
entity_id=video_urn,
|
|
361
443
|
bytes_uploaded=0,
|
|
362
444
|
total_bytes=file_size,
|
|
363
445
|
)
|
|
364
446
|
|
|
365
|
-
#
|
|
366
|
-
|
|
447
|
+
# Step 2: Upload video in chunks and collect ETags
|
|
448
|
+
uploaded_part_ids: list[str] = []
|
|
449
|
+
total_chunks = len(upload_instructions)
|
|
450
|
+
bytes_uploaded = 0
|
|
451
|
+
|
|
452
|
+
for i, instruction in enumerate(upload_instructions):
|
|
453
|
+
upload_url = instruction["uploadUrl"]
|
|
454
|
+
first_byte = instruction["firstByte"]
|
|
455
|
+
last_byte = instruction["lastByte"]
|
|
367
456
|
|
|
368
|
-
|
|
369
|
-
|
|
457
|
+
# Extract chunk from file bytes
|
|
458
|
+
chunk = file_bytes[first_byte : last_byte + 1]
|
|
459
|
+
chunk_size = len(chunk)
|
|
370
460
|
|
|
371
|
-
|
|
461
|
+
# Notify progress
|
|
462
|
+
progress_pct = int((i / total_chunks) * 80) # 80% for upload
|
|
463
|
+
await self._emit_progress(
|
|
464
|
+
status=ProgressStatus.UPLOADING,
|
|
465
|
+
progress=progress_pct,
|
|
466
|
+
total=100,
|
|
467
|
+
message=f"Uploading chunk {i + 1}/{total_chunks}",
|
|
468
|
+
entity_id=video_urn,
|
|
469
|
+
bytes_uploaded=bytes_uploaded,
|
|
470
|
+
total_bytes=file_size,
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
try:
|
|
474
|
+
upload_response = await self.client.put(
|
|
475
|
+
upload_url,
|
|
476
|
+
content=chunk,
|
|
477
|
+
headers={"Content-Type": "application/octet-stream"},
|
|
478
|
+
)
|
|
479
|
+
upload_response.raise_for_status()
|
|
480
|
+
|
|
481
|
+
# Get ETag from response headers (required for finalization)
|
|
482
|
+
etag = upload_response.headers.get("etag")
|
|
483
|
+
if etag:
|
|
484
|
+
uploaded_part_ids.append(etag)
|
|
485
|
+
|
|
486
|
+
bytes_uploaded += chunk_size
|
|
487
|
+
|
|
488
|
+
except httpx.HTTPError as e:
|
|
489
|
+
raise MediaUploadError(
|
|
490
|
+
f"Failed to upload video chunk {i + 1}: {e}",
|
|
491
|
+
platform="linkedin",
|
|
492
|
+
media_type="video",
|
|
493
|
+
) from e
|
|
494
|
+
|
|
495
|
+
# Step 3: Finalize upload
|
|
496
|
+
await self._emit_progress(
|
|
497
|
+
status=ProgressStatus.UPLOADING,
|
|
498
|
+
progress=85,
|
|
499
|
+
total=100,
|
|
500
|
+
message="Finalizing video upload",
|
|
501
|
+
entity_id=video_urn,
|
|
502
|
+
bytes_uploaded=file_size,
|
|
503
|
+
total_bytes=file_size,
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
finalize_payload = {
|
|
507
|
+
"finalizeUploadRequest": {
|
|
508
|
+
"video": video_urn,
|
|
509
|
+
"uploadToken": upload_token,
|
|
510
|
+
"uploadedPartIds": uploaded_part_ids,
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
try:
|
|
515
|
+
finalize_response = await self.client.post(
|
|
516
|
+
f"{self.base_url}/videos?action=finalizeUpload",
|
|
517
|
+
json=finalize_payload,
|
|
518
|
+
)
|
|
519
|
+
finalize_response.raise_for_status()
|
|
520
|
+
except httpx.HTTPError as e:
|
|
521
|
+
raise MediaUploadError(
|
|
522
|
+
f"Failed to finalize video upload: {e}",
|
|
523
|
+
platform="linkedin",
|
|
524
|
+
media_type="video",
|
|
525
|
+
) from e
|
|
526
|
+
|
|
527
|
+
# Step 4: Wait for processing if requested
|
|
372
528
|
if wait_for_processing:
|
|
373
|
-
await self._wait_for_video_processing(
|
|
529
|
+
await self._wait_for_video_processing(video_urn)
|
|
374
530
|
|
|
375
531
|
# Notify completion
|
|
376
532
|
final_status = (
|
|
@@ -385,14 +541,15 @@ class LinkedInMediaManager:
|
|
|
385
541
|
message=(
|
|
386
542
|
"Video upload completed" if wait_for_processing else "Video processing"
|
|
387
543
|
),
|
|
388
|
-
entity_id=
|
|
544
|
+
entity_id=video_urn,
|
|
389
545
|
bytes_uploaded=file_size,
|
|
390
546
|
total_bytes=file_size,
|
|
391
547
|
)
|
|
392
548
|
|
|
393
|
-
logger.info(f"Video uploaded successfully: {
|
|
549
|
+
logger.info(f"Video uploaded successfully: {video_urn}")
|
|
394
550
|
return MediaAsset(
|
|
395
|
-
asset_id=
|
|
551
|
+
asset_id=video_urn,
|
|
552
|
+
status="AVAILABLE" if wait_for_processing else "PROCESSING",
|
|
396
553
|
)
|
|
397
554
|
|
|
398
555
|
async def upload_document(
|
|
@@ -401,19 +558,25 @@ class LinkedInMediaManager:
|
|
|
401
558
|
*,
|
|
402
559
|
title: str | None = None, # noqa: ARG002
|
|
403
560
|
) -> MediaAsset:
|
|
404
|
-
"""Upload a document
|
|
561
|
+
"""Upload a document to LinkedIn using the Documents API.
|
|
562
|
+
|
|
563
|
+
Uses the REST API endpoint /rest/documents as per LinkedIn documentation:
|
|
564
|
+
https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/documents-api
|
|
405
565
|
|
|
406
566
|
Args:
|
|
407
567
|
file_path: Path to document file or URL.
|
|
408
|
-
title: Document title.
|
|
568
|
+
title: Document title (currently unused, reserved for future use).
|
|
409
569
|
|
|
410
570
|
Returns:
|
|
411
|
-
MediaAsset with
|
|
571
|
+
MediaAsset with document URN (urn:li:document:xxx).
|
|
412
572
|
|
|
413
573
|
Raises:
|
|
414
574
|
MediaUploadError: If upload fails.
|
|
415
575
|
ValidationError: If file is invalid.
|
|
416
576
|
|
|
577
|
+
Supported formats:
|
|
578
|
+
PDF, PPT, PPTX, DOC, DOCX (max 100MB, 300 pages)
|
|
579
|
+
|
|
417
580
|
Example:
|
|
418
581
|
>>> asset = await manager.upload_document(
|
|
419
582
|
... "presentation.pdf",
|
|
@@ -424,11 +587,12 @@ class LinkedInMediaManager:
|
|
|
424
587
|
if file_path.startswith(("http://", "https://")):
|
|
425
588
|
file_path = await download_file(file_path)
|
|
426
589
|
|
|
427
|
-
# Validate
|
|
590
|
+
# Validate MIME type
|
|
428
591
|
mime_type = detect_mime_type(file_path)
|
|
429
|
-
if mime_type
|
|
592
|
+
if mime_type not in SUPPORTED_DOCUMENT_TYPES:
|
|
430
593
|
raise ValidationError(
|
|
431
|
-
f"
|
|
594
|
+
f"Unsupported document type: {mime_type}. "
|
|
595
|
+
f"Supported: PDF, PPT, PPTX, DOC, DOCX",
|
|
432
596
|
platform="linkedin",
|
|
433
597
|
)
|
|
434
598
|
|
|
@@ -442,21 +606,27 @@ class LinkedInMediaManager:
|
|
|
442
606
|
platform="linkedin",
|
|
443
607
|
)
|
|
444
608
|
|
|
445
|
-
#
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
"recipes": ["urn:li:digitalmediaRecipe:feedshare-document"],
|
|
449
|
-
"owner": self.person_urn,
|
|
450
|
-
"serviceRelationships": [
|
|
451
|
-
{
|
|
452
|
-
"relationshipType": "OWNER",
|
|
453
|
-
"identifier": "urn:li:userGeneratedContent",
|
|
454
|
-
}
|
|
455
|
-
],
|
|
456
|
-
}
|
|
457
|
-
}
|
|
609
|
+
# Step 1: Initialize upload using REST documents API
|
|
610
|
+
# Per docs: POST /rest/documents?action=initializeUpload
|
|
611
|
+
init_payload = {"initializeUploadRequest": {"owner": self.person_urn}}
|
|
458
612
|
|
|
459
|
-
|
|
613
|
+
try:
|
|
614
|
+
response = await self.client.post(
|
|
615
|
+
f"{self.base_url}/documents?action=initializeUpload",
|
|
616
|
+
json=init_payload,
|
|
617
|
+
)
|
|
618
|
+
response.raise_for_status()
|
|
619
|
+
init_result = response.json()
|
|
620
|
+
except httpx.HTTPError as e:
|
|
621
|
+
raise MediaUploadError(
|
|
622
|
+
f"Failed to initialize document upload: {e}",
|
|
623
|
+
platform="linkedin",
|
|
624
|
+
media_type="document",
|
|
625
|
+
) from e
|
|
626
|
+
|
|
627
|
+
# Extract upload URL and document URN from response
|
|
628
|
+
upload_url = init_result["value"]["uploadUrl"]
|
|
629
|
+
document_urn = init_result["value"]["document"]
|
|
460
630
|
|
|
461
631
|
# Notify start
|
|
462
632
|
await self._emit_progress(
|
|
@@ -464,16 +634,35 @@ class LinkedInMediaManager:
|
|
|
464
634
|
progress=0,
|
|
465
635
|
total=100,
|
|
466
636
|
message="Registering document upload",
|
|
467
|
-
entity_id=
|
|
637
|
+
entity_id=document_urn,
|
|
468
638
|
bytes_uploaded=0,
|
|
469
639
|
total_bytes=file_size,
|
|
470
640
|
)
|
|
471
641
|
|
|
472
|
-
#
|
|
473
|
-
|
|
642
|
+
# Step 2: Upload document binary via PUT
|
|
643
|
+
await self._emit_progress(
|
|
644
|
+
status=ProgressStatus.UPLOADING,
|
|
645
|
+
progress=0,
|
|
646
|
+
total=100,
|
|
647
|
+
message="Uploading document",
|
|
648
|
+
entity_id=document_urn,
|
|
649
|
+
bytes_uploaded=0,
|
|
650
|
+
total_bytes=file_size,
|
|
651
|
+
)
|
|
474
652
|
|
|
475
|
-
|
|
476
|
-
|
|
653
|
+
try:
|
|
654
|
+
upload_response = await self.client.put(
|
|
655
|
+
upload_url,
|
|
656
|
+
content=file_bytes,
|
|
657
|
+
headers={"Content-Type": "application/octet-stream"},
|
|
658
|
+
)
|
|
659
|
+
upload_response.raise_for_status()
|
|
660
|
+
except httpx.HTTPError as e:
|
|
661
|
+
raise MediaUploadError(
|
|
662
|
+
f"Failed to upload document binary: {e}",
|
|
663
|
+
platform="linkedin",
|
|
664
|
+
media_type="document",
|
|
665
|
+
) from e
|
|
477
666
|
|
|
478
667
|
# Notify completion
|
|
479
668
|
await self._emit_progress(
|
|
@@ -481,171 +670,91 @@ class LinkedInMediaManager:
|
|
|
481
670
|
progress=100,
|
|
482
671
|
total=100,
|
|
483
672
|
message="Document upload completed",
|
|
484
|
-
entity_id=
|
|
673
|
+
entity_id=document_urn,
|
|
485
674
|
bytes_uploaded=file_size,
|
|
486
675
|
total_bytes=file_size,
|
|
487
676
|
)
|
|
488
677
|
|
|
489
|
-
logger.info(f"Document uploaded successfully: {
|
|
490
|
-
return MediaAsset(asset_id=
|
|
678
|
+
logger.info(f"Document uploaded successfully: {document_urn}")
|
|
679
|
+
return MediaAsset(asset_id=document_urn, status="AVAILABLE")
|
|
680
|
+
|
|
681
|
+
async def get_video_status(self, video_urn: str) -> dict[str, Any]:
|
|
682
|
+
"""Get processing status of a video using the REST Videos API.
|
|
491
683
|
|
|
492
|
-
|
|
493
|
-
"""Get processing status of a video asset.
|
|
684
|
+
Uses the REST API endpoint: GET /rest/videos/{videoUrn}
|
|
494
685
|
|
|
495
686
|
Args:
|
|
496
|
-
|
|
687
|
+
video_urn: LinkedIn video URN (urn:li:video:xxx).
|
|
497
688
|
|
|
498
689
|
Returns:
|
|
499
|
-
Dictionary with video status information
|
|
690
|
+
Dictionary with video status information including:
|
|
691
|
+
- status: PROCESSING, PROCESSING_FAILED, AVAILABLE, WAITING_UPLOAD
|
|
692
|
+
- downloadUrl: URL to download/view the video (when AVAILABLE)
|
|
693
|
+
- duration: Video length in milliseconds
|
|
694
|
+
- aspectRatioWidth/Height: Video dimensions
|
|
500
695
|
|
|
501
696
|
Example:
|
|
502
|
-
>>> status = await manager.get_video_status(
|
|
697
|
+
>>> status = await manager.get_video_status("urn:li:video:C5505AQH...")
|
|
503
698
|
>>> print(f"Status: {status['status']}")
|
|
504
699
|
"""
|
|
700
|
+
from urllib.parse import quote
|
|
701
|
+
|
|
702
|
+
encoded_urn = quote(video_urn, safe="")
|
|
505
703
|
|
|
506
704
|
@retry_async(config=STANDARD_BACKOFF)
|
|
507
705
|
async def _get_status() -> dict[str, Any]:
|
|
508
706
|
response = await self.client.get(
|
|
509
|
-
f"{self.base_url}/
|
|
707
|
+
f"{self.base_url}/videos/{encoded_urn}",
|
|
510
708
|
)
|
|
511
709
|
response.raise_for_status()
|
|
512
710
|
return response.json()
|
|
513
711
|
|
|
514
712
|
return await _get_status()
|
|
515
713
|
|
|
516
|
-
async def _register_upload(self, register_data: dict[str, Any]) -> str:
|
|
517
|
-
"""Register an upload and get asset ID."""
|
|
518
|
-
|
|
519
|
-
@retry_async(config=STANDARD_BACKOFF)
|
|
520
|
-
async def _register() -> str:
|
|
521
|
-
response = await self.client.post(
|
|
522
|
-
f"{self.base_url}/assets?action=registerUpload",
|
|
523
|
-
json=register_data,
|
|
524
|
-
)
|
|
525
|
-
response.raise_for_status()
|
|
526
|
-
result = response.json()
|
|
527
|
-
return result["value"]["asset"]
|
|
528
|
-
|
|
529
|
-
try:
|
|
530
|
-
return await _register()
|
|
531
|
-
except httpx.HTTPError as e:
|
|
532
|
-
raise MediaUploadError(
|
|
533
|
-
f"Failed to register upload: {e}",
|
|
534
|
-
platform="linkedin",
|
|
535
|
-
) from e
|
|
536
|
-
|
|
537
|
-
async def _get_upload_url(self, asset_id: str) -> str:
|
|
538
|
-
"""Get upload URL for an asset."""
|
|
539
|
-
|
|
540
|
-
@retry_async(config=STANDARD_BACKOFF)
|
|
541
|
-
async def _get_url() -> str:
|
|
542
|
-
response = await self.client.get(
|
|
543
|
-
f"{self.base_url}/assets/{asset_id}",
|
|
544
|
-
)
|
|
545
|
-
response.raise_for_status()
|
|
546
|
-
result = response.json()
|
|
547
|
-
return result["uploadMechanism"][
|
|
548
|
-
"com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"
|
|
549
|
-
]["uploadUrl"]
|
|
550
|
-
|
|
551
|
-
try:
|
|
552
|
-
return await _get_url()
|
|
553
|
-
except httpx.HTTPError as e:
|
|
554
|
-
raise MediaUploadError(
|
|
555
|
-
f"Failed to get upload URL: {e}",
|
|
556
|
-
platform="linkedin",
|
|
557
|
-
) from e
|
|
558
|
-
|
|
559
|
-
async def _upload_to_url(
|
|
560
|
-
self,
|
|
561
|
-
upload_url: str,
|
|
562
|
-
file_bytes: bytes,
|
|
563
|
-
file_size: int,
|
|
564
|
-
asset_id: str,
|
|
565
|
-
) -> None:
|
|
566
|
-
"""Upload file bytes to the upload URL."""
|
|
567
|
-
|
|
568
|
-
@retry_async(config=STANDARD_BACKOFF)
|
|
569
|
-
async def _upload() -> None:
|
|
570
|
-
# LinkedIn requires specific headers for upload
|
|
571
|
-
headers = {
|
|
572
|
-
"Content-Type": "application/octet-stream",
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
# Notify upload start
|
|
576
|
-
await self._emit_progress(
|
|
577
|
-
status=ProgressStatus.UPLOADING,
|
|
578
|
-
progress=0,
|
|
579
|
-
total=100,
|
|
580
|
-
message="Uploading file",
|
|
581
|
-
entity_id=asset_id,
|
|
582
|
-
bytes_uploaded=0,
|
|
583
|
-
total_bytes=file_size,
|
|
584
|
-
)
|
|
585
|
-
|
|
586
|
-
response = await self.client.put(
|
|
587
|
-
upload_url,
|
|
588
|
-
content=file_bytes,
|
|
589
|
-
headers=headers,
|
|
590
|
-
)
|
|
591
|
-
response.raise_for_status()
|
|
592
|
-
|
|
593
|
-
# Notify upload complete
|
|
594
|
-
await self._emit_progress(
|
|
595
|
-
status=ProgressStatus.UPLOADING,
|
|
596
|
-
progress=100,
|
|
597
|
-
total=100,
|
|
598
|
-
message="File uploaded",
|
|
599
|
-
entity_id=asset_id,
|
|
600
|
-
bytes_uploaded=file_size,
|
|
601
|
-
total_bytes=file_size,
|
|
602
|
-
)
|
|
603
|
-
|
|
604
|
-
try:
|
|
605
|
-
await _upload()
|
|
606
|
-
except httpx.HTTPError as e:
|
|
607
|
-
raise MediaUploadError(
|
|
608
|
-
f"Failed to upload file: {e}",
|
|
609
|
-
platform="linkedin",
|
|
610
|
-
) from e
|
|
611
|
-
|
|
612
714
|
async def _wait_for_video_processing(
|
|
613
715
|
self,
|
|
614
|
-
|
|
716
|
+
video_urn: str,
|
|
615
717
|
*,
|
|
616
718
|
timeout: int = VIDEO_PROCESSING_TIMEOUT,
|
|
617
719
|
check_interval: int = 5,
|
|
618
720
|
) -> None:
|
|
619
|
-
"""Wait for video processing to complete.
|
|
721
|
+
"""Wait for video processing to complete using the REST Videos API.
|
|
722
|
+
|
|
723
|
+
Args:
|
|
724
|
+
video_urn: LinkedIn video URN (urn:li:video:xxx).
|
|
725
|
+
timeout: Maximum time to wait in seconds.
|
|
726
|
+
check_interval: Time between status checks in seconds.
|
|
727
|
+
|
|
728
|
+
Raises:
|
|
729
|
+
MediaUploadError: If processing fails or times out.
|
|
730
|
+
"""
|
|
620
731
|
elapsed = 0
|
|
621
|
-
logger.info(f"Waiting for video {
|
|
732
|
+
logger.info(f"Waiting for video {video_urn} to process...")
|
|
622
733
|
|
|
623
734
|
while elapsed < timeout:
|
|
624
|
-
status_data = await self.get_video_status(
|
|
735
|
+
status_data = await self.get_video_status(video_urn)
|
|
625
736
|
status = status_data.get("status")
|
|
626
737
|
|
|
627
|
-
if status
|
|
628
|
-
|
|
629
|
-
VideoProcessingState.AVAILABLE.value,
|
|
630
|
-
):
|
|
631
|
-
logger.info(f"Video {asset_id} processing complete")
|
|
738
|
+
if status == VideoProcessingState.AVAILABLE.value:
|
|
739
|
+
logger.info(f"Video {video_urn} processing complete")
|
|
632
740
|
return
|
|
633
741
|
|
|
634
|
-
if status == VideoProcessingState.
|
|
742
|
+
if status == VideoProcessingState.PROCESSING_FAILED.value:
|
|
743
|
+
failure_reason = status_data.get("processingFailureReason", "Unknown")
|
|
635
744
|
raise MediaUploadError(
|
|
636
|
-
f"Video processing failed for {
|
|
745
|
+
f"Video processing failed for {video_urn}: {failure_reason}",
|
|
637
746
|
platform="linkedin",
|
|
638
747
|
media_type="video",
|
|
639
748
|
)
|
|
640
749
|
|
|
641
750
|
# Notify progress
|
|
642
|
-
progress_pct = min(int((elapsed / timeout) * 90),
|
|
751
|
+
progress_pct = min(int((elapsed / timeout) * 90) + 85, 99)
|
|
643
752
|
await self._emit_progress(
|
|
644
753
|
status=ProgressStatus.PROCESSING,
|
|
645
754
|
progress=progress_pct,
|
|
646
755
|
total=100,
|
|
647
756
|
message=f"Processing video ({status})",
|
|
648
|
-
entity_id=
|
|
757
|
+
entity_id=video_urn,
|
|
649
758
|
)
|
|
650
759
|
|
|
651
760
|
await asyncio.sleep(check_interval)
|
|
@@ -12,9 +12,9 @@ marqetive/platforms/instagram/exceptions.py,sha256=TcD_pX4eSx_k4yW8DgfA6SGPiAz3V
|
|
|
12
12
|
marqetive/platforms/instagram/media.py,sha256=0ZbUbpwJ025_hccL9X8qced_-LJGoL_-NdS84Op97VE,23228
|
|
13
13
|
marqetive/platforms/instagram/models.py,sha256=20v3m1037y3b_WlsKF8zAOgV23nFu63tfmmUN1CefOI,2769
|
|
14
14
|
marqetive/platforms/linkedin/__init__.py,sha256=_FrdZpqcXjcUW6C-25zGV7poGih9yzs6y1AFnuizTUQ,1384
|
|
15
|
-
marqetive/platforms/linkedin/client.py,sha256=
|
|
15
|
+
marqetive/platforms/linkedin/client.py,sha256=NHVjMfZb9Mp6zloCXORBEHOxOGPK5DHLctOzCiseQbs,73092
|
|
16
16
|
marqetive/platforms/linkedin/exceptions.py,sha256=i5fARUkZik46bS3htZBwUInVzetsZx1APpKEXLrCKf0,9762
|
|
17
|
-
marqetive/platforms/linkedin/media.py,sha256
|
|
17
|
+
marqetive/platforms/linkedin/media.py,sha256=-kpkL5brYhx8cZlFS5ueDgY4eSZWqHoB6xp8jZU2cyI,25931
|
|
18
18
|
marqetive/platforms/linkedin/models.py,sha256=n7DqwVxYSbGYBmeEJ1woCZ6XhUIHcLx8Gpm8uCBACzI,12620
|
|
19
19
|
marqetive/platforms/tiktok/__init__.py,sha256=BqjkXTZDyBlcY3lvREy13yP9h3RcDga8E6Rl6f5KPp8,238
|
|
20
20
|
marqetive/platforms/tiktok/client.py,sha256=c-XYV3XsLU04jv61mBEYivP-oO-aZhuEq6g7J55OEY8,17387
|
|
@@ -33,6 +33,6 @@ marqetive/utils/helpers.py,sha256=Sh5HZD6AOJig_6T84n6JsKLosIkKIkpkiYTl69rnOOw,13
|
|
|
33
33
|
marqetive/utils/media.py,sha256=reVousdueG-h5jeI6uLGqVCfjYxlsMiWhx6XZwg-iHY,14664
|
|
34
34
|
marqetive/utils/oauth.py,sha256=x30XAW5VlND6TAPBsw9kZShko_Jsmn_NE-KOZjnBxGo,14359
|
|
35
35
|
marqetive/utils/retry.py,sha256=UcgrmVBVG5zd30_11mZnRnTaSFrbUYXBO1DrXPR0f8E,7627
|
|
36
|
-
marqetive_lib-0.1.
|
|
37
|
-
marqetive_lib-0.1.
|
|
38
|
-
marqetive_lib-0.1.
|
|
36
|
+
marqetive_lib-0.1.18.dist-info/METADATA,sha256=NxSKJr6IOZzVngZLGwmTgDE97Wu8uyewyVK_fH-MZpI,7876
|
|
37
|
+
marqetive_lib-0.1.18.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
38
|
+
marqetive_lib-0.1.18.dist-info/RECORD,,
|
|
File without changes
|