marqetive-lib 0.1.0__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.
Files changed (43) hide show
  1. marqetive/__init__.py +113 -0
  2. marqetive/core/__init__.py +5 -0
  3. marqetive/core/account_factory.py +212 -0
  4. marqetive/core/base_manager.py +303 -0
  5. marqetive/core/client.py +108 -0
  6. marqetive/core/progress.py +291 -0
  7. marqetive/core/registry.py +257 -0
  8. marqetive/platforms/__init__.py +55 -0
  9. marqetive/platforms/base.py +390 -0
  10. marqetive/platforms/exceptions.py +238 -0
  11. marqetive/platforms/instagram/__init__.py +7 -0
  12. marqetive/platforms/instagram/client.py +786 -0
  13. marqetive/platforms/instagram/exceptions.py +311 -0
  14. marqetive/platforms/instagram/factory.py +106 -0
  15. marqetive/platforms/instagram/manager.py +112 -0
  16. marqetive/platforms/instagram/media.py +669 -0
  17. marqetive/platforms/linkedin/__init__.py +7 -0
  18. marqetive/platforms/linkedin/client.py +733 -0
  19. marqetive/platforms/linkedin/exceptions.py +335 -0
  20. marqetive/platforms/linkedin/factory.py +130 -0
  21. marqetive/platforms/linkedin/manager.py +119 -0
  22. marqetive/platforms/linkedin/media.py +549 -0
  23. marqetive/platforms/models.py +345 -0
  24. marqetive/platforms/tiktok/__init__.py +0 -0
  25. marqetive/platforms/twitter/__init__.py +7 -0
  26. marqetive/platforms/twitter/client.py +647 -0
  27. marqetive/platforms/twitter/exceptions.py +311 -0
  28. marqetive/platforms/twitter/factory.py +151 -0
  29. marqetive/platforms/twitter/manager.py +121 -0
  30. marqetive/platforms/twitter/media.py +779 -0
  31. marqetive/platforms/twitter/threads.py +442 -0
  32. marqetive/py.typed +0 -0
  33. marqetive/registry_init.py +66 -0
  34. marqetive/utils/__init__.py +45 -0
  35. marqetive/utils/file_handlers.py +438 -0
  36. marqetive/utils/helpers.py +99 -0
  37. marqetive/utils/media.py +399 -0
  38. marqetive/utils/oauth.py +265 -0
  39. marqetive/utils/retry.py +239 -0
  40. marqetive/utils/token_validator.py +240 -0
  41. marqetive_lib-0.1.0.dist-info/METADATA +261 -0
  42. marqetive_lib-0.1.0.dist-info/RECORD +43 -0
  43. marqetive_lib-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,733 @@
1
+ """LinkedIn API client implementation.
2
+
3
+ This module provides a concrete implementation of the SocialMediaPlatform
4
+ ABC for LinkedIn, using the LinkedIn Marketing API and Share API.
5
+
6
+ API Documentation: https://learn.microsoft.com/en-us/linkedin/
7
+ """
8
+
9
+ from datetime import datetime
10
+ from typing import Any, cast
11
+
12
+ import httpx
13
+ from pydantic import HttpUrl
14
+
15
+ from marqetive.platforms.base import SocialMediaPlatform
16
+ from marqetive.platforms.exceptions import (
17
+ MediaUploadError,
18
+ PlatformAuthError,
19
+ PlatformError,
20
+ PostNotFoundError,
21
+ ValidationError,
22
+ )
23
+ from marqetive.platforms.linkedin.media import LinkedInMediaManager, MediaAsset
24
+ from marqetive.platforms.models import (
25
+ AuthCredentials,
26
+ Comment,
27
+ CommentStatus,
28
+ MediaAttachment,
29
+ MediaType,
30
+ Post,
31
+ PostCreateRequest,
32
+ PostStatus,
33
+ PostUpdateRequest,
34
+ )
35
+
36
+
37
+ class LinkedInClient(SocialMediaPlatform):
38
+ """LinkedIn API client.
39
+
40
+ This client implements the SocialMediaPlatform interface for LinkedIn,
41
+ using the LinkedIn Share API and Marketing API. It supports creating
42
+ posts (shares), managing comments, and uploading media.
43
+
44
+ Note:
45
+ - Requires LinkedIn Developer app with appropriate permissions
46
+ - Requires OAuth 2.0 authentication
47
+ - Supports both personal profiles and organization pages
48
+ - Rate limits vary by API endpoint
49
+
50
+ Example:
51
+ >>> credentials = AuthCredentials(
52
+ ... platform="linkedin",
53
+ ... access_token="your_access_token",
54
+ ... user_id="urn:li:person:abc123"
55
+ ... )
56
+ >>> async with LinkedInClient(credentials) as client:
57
+ ... request = PostCreateRequest(
58
+ ... content="Excited to share our latest update!",
59
+ ... link="https://example.com"
60
+ ... )
61
+ ... post = await client.create_post(request)
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ credentials: AuthCredentials,
67
+ timeout: float = 30.0,
68
+ api_version: str = "v2",
69
+ ) -> None:
70
+ """Initialize LinkedIn client.
71
+
72
+ Args:
73
+ credentials: LinkedIn authentication credentials
74
+ timeout: Request timeout in seconds
75
+ api_version: LinkedIn API version
76
+
77
+ Raises:
78
+ PlatformAuthError: If credentials are invalid
79
+ """
80
+ base_url = f"https://api.linkedin.com/{api_version}"
81
+ super().__init__(
82
+ platform_name="linkedin",
83
+ credentials=credentials,
84
+ base_url=base_url,
85
+ timeout=timeout,
86
+ )
87
+ self.author_urn = (
88
+ credentials.user_id
89
+ ) # urn:li:person:xxx or urn:li:organization:xxx
90
+ self.api_version = api_version
91
+
92
+ # Media manager (initialized in __aenter__)
93
+ self._media_manager: LinkedInMediaManager | None = None
94
+
95
+ async def __aenter__(self) -> "LinkedInClient":
96
+ """Async context manager entry."""
97
+ await super().__aenter__()
98
+
99
+ # Initialize media manager
100
+ if not self.author_urn:
101
+ raise PlatformAuthError(
102
+ "LinkedIn author URN (user_id) is required in credentials",
103
+ platform=self.platform_name,
104
+ )
105
+
106
+ self._media_manager = LinkedInMediaManager(
107
+ person_urn=self.author_urn,
108
+ access_token=self.credentials.access_token,
109
+ api_version=self.api_version,
110
+ timeout=self.timeout,
111
+ )
112
+
113
+ return self
114
+
115
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
116
+ """Async context manager exit."""
117
+ # Cleanup media manager
118
+ if self._media_manager:
119
+ await self._media_manager.__aexit__(exc_type, exc_val, exc_tb)
120
+ self._media_manager = None
121
+
122
+ await super().__aexit__(exc_type, exc_val, exc_tb)
123
+
124
+ # ==================== Authentication Methods ====================
125
+
126
+ async def authenticate(self) -> AuthCredentials:
127
+ """Perform LinkedIn authentication flow.
128
+
129
+ Note: This method assumes you already have a valid OAuth 2.0 access token.
130
+ For the full OAuth flow, use LinkedIn's OAuth 2.0 implementation.
131
+
132
+ Returns:
133
+ Current credentials if valid.
134
+
135
+ Raises:
136
+ PlatformAuthError: If authentication fails.
137
+ """
138
+ if await self.is_authenticated():
139
+ return self.credentials
140
+
141
+ raise PlatformAuthError(
142
+ "Invalid or expired credentials. Please re-authenticate via LinkedIn OAuth 2.0.",
143
+ platform=self.platform_name,
144
+ )
145
+
146
+ async def refresh_token(self) -> AuthCredentials:
147
+ """Refresh LinkedIn access token.
148
+
149
+ LinkedIn access tokens typically expire after 60 days. Use the
150
+ refresh token to obtain a new access token.
151
+
152
+ Returns:
153
+ Updated credentials with new access token.
154
+
155
+ Raises:
156
+ PlatformAuthError: If token refresh fails.
157
+ """
158
+ if not self.credentials.refresh_token:
159
+ raise PlatformAuthError(
160
+ "No refresh token available",
161
+ platform=self.platform_name,
162
+ )
163
+
164
+ # Note: LinkedIn OAuth token refresh requires making a request to
165
+ # https://www.linkedin.com/oauth/v2/accessToken
166
+ # This is simplified for demonstration
167
+ raise PlatformAuthError(
168
+ "Token refresh not yet implemented. Please re-authenticate.",
169
+ platform=self.platform_name,
170
+ )
171
+
172
+ async def is_authenticated(self) -> bool:
173
+ """Check if LinkedIn credentials are valid.
174
+
175
+ Returns:
176
+ True if authenticated and token is valid.
177
+ """
178
+ if not self.api_client:
179
+ raise RuntimeError("Client must be used as async context manager")
180
+
181
+ try:
182
+ # Verify credentials by fetching user profile
183
+ await self.api_client.get("/me")
184
+ return True
185
+ except httpx.HTTPError:
186
+ return False
187
+
188
+ # ==================== Post CRUD Methods ====================
189
+
190
+ async def create_post(self, request: PostCreateRequest) -> Post:
191
+ """Create and publish a LinkedIn post (share).
192
+
193
+ Args:
194
+ request: Post creation request.
195
+
196
+ Returns:
197
+ Created Post object.
198
+
199
+ Raises:
200
+ ValidationError: If request is invalid.
201
+ MediaUploadError: If media upload fails.
202
+ """
203
+ if not self.api_client:
204
+ raise RuntimeError("Client must be used as async context manager")
205
+
206
+ if not request.content:
207
+ raise ValidationError(
208
+ "LinkedIn posts require content",
209
+ platform=self.platform_name,
210
+ field="content",
211
+ )
212
+
213
+ # Validate content length (3000 characters for posts)
214
+ if len(request.content) > 3000:
215
+ raise ValidationError(
216
+ f"Post content exceeds 3000 characters ({len(request.content)} characters)",
217
+ platform=self.platform_name,
218
+ field="content",
219
+ )
220
+
221
+ try:
222
+ # Build share payload
223
+ share_payload: dict[str, Any] = {
224
+ "author": self.author_urn,
225
+ "lifecycleState": "PUBLISHED",
226
+ "specificContent": {
227
+ "com.linkedin.ugc.ShareContent": {
228
+ "shareCommentary": {"text": request.content},
229
+ "shareMediaCategory": "NONE",
230
+ }
231
+ },
232
+ "visibility": {"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"},
233
+ }
234
+
235
+ # Add media if provided
236
+ if request.media_ids:
237
+ share_payload["specificContent"]["com.linkedin.ugc.ShareContent"][
238
+ "shareMediaCategory"
239
+ ] = "IMAGE"
240
+ share_payload["specificContent"]["com.linkedin.ugc.ShareContent"][
241
+ "media"
242
+ ] = [
243
+ {"status": "READY", "media": media_id}
244
+ for media_id in request.media_ids
245
+ ]
246
+
247
+ # Add link if provided
248
+ if request.link:
249
+ share_payload["specificContent"]["com.linkedin.ugc.ShareContent"][
250
+ "shareMediaCategory"
251
+ ] = "ARTICLE"
252
+ share_payload["specificContent"]["com.linkedin.ugc.ShareContent"][
253
+ "media"
254
+ ] = [
255
+ {
256
+ "status": "READY",
257
+ "originalUrl": request.link,
258
+ }
259
+ ]
260
+
261
+ # Create the share
262
+ response = await self.api_client.post("/ugcPosts", data=share_payload)
263
+
264
+ post_id = response.data["id"]
265
+
266
+ # Fetch full post details
267
+ return await self.get_post(post_id)
268
+
269
+ except httpx.HTTPError as e:
270
+ raise PlatformError(
271
+ f"Failed to create LinkedIn post: {e}",
272
+ platform=self.platform_name,
273
+ ) from e
274
+
275
+ async def get_post(self, post_id: str) -> Post:
276
+ """Retrieve a LinkedIn post by ID.
277
+
278
+ Args:
279
+ post_id: LinkedIn post URN (e.g., urn:li:share:123).
280
+
281
+ Returns:
282
+ Post object with current data.
283
+
284
+ Raises:
285
+ PostNotFoundError: If post doesn't exist.
286
+ """
287
+ if not self.api_client:
288
+ raise RuntimeError("Client must be used as async context manager")
289
+
290
+ try:
291
+ response = await self.api_client.get(f"/ugcPosts/{post_id}")
292
+ data = response.data
293
+ return self._parse_post(data)
294
+
295
+ except httpx.HTTPStatusError as e:
296
+ if e.response.status_code == 404:
297
+ raise PostNotFoundError(
298
+ post_id=post_id,
299
+ platform=self.platform_name,
300
+ status_code=404,
301
+ ) from e
302
+ raise PlatformError(
303
+ f"Failed to fetch post: {e}",
304
+ platform=self.platform_name,
305
+ ) from e
306
+ except httpx.HTTPError as e:
307
+ raise PlatformError(
308
+ f"Failed to fetch post: {e}",
309
+ platform=self.platform_name,
310
+ ) from e
311
+
312
+ async def update_post(
313
+ self,
314
+ post_id: str, # noqa: ARG002
315
+ request: PostUpdateRequest, # noqa: ARG002
316
+ ) -> Post:
317
+ """Update a LinkedIn post.
318
+
319
+ Note: LinkedIn has limited support for editing posts. Only certain
320
+ fields can be updated, and there are time restrictions.
321
+
322
+ Args:
323
+ post_id: LinkedIn post URN.
324
+ request: Post update request.
325
+
326
+ Raises:
327
+ PlatformError: If post cannot be edited.
328
+ """
329
+ raise PlatformError(
330
+ "LinkedIn does not support editing published posts",
331
+ platform=self.platform_name,
332
+ )
333
+
334
+ async def delete_post(self, post_id: str) -> bool:
335
+ """Delete a LinkedIn post.
336
+
337
+ Args:
338
+ post_id: LinkedIn post URN.
339
+
340
+ Returns:
341
+ True if deletion was successful.
342
+
343
+ Raises:
344
+ PostNotFoundError: If post doesn't exist.
345
+ """
346
+ if not self.api_client:
347
+ raise RuntimeError("Client must be used as async context manager")
348
+
349
+ try:
350
+ # LinkedIn uses DELETE method for removing posts
351
+ if not self.api_client._client:
352
+ raise RuntimeError("API client not initialized")
353
+
354
+ await self.api_client._client.delete(f"{self.base_url}/ugcPosts/{post_id}")
355
+ return True
356
+
357
+ except httpx.HTTPStatusError as e:
358
+ if e.response.status_code == 404:
359
+ raise PostNotFoundError(
360
+ post_id=post_id,
361
+ platform=self.platform_name,
362
+ status_code=404,
363
+ ) from e
364
+ raise PlatformError(
365
+ f"Failed to delete post: {e}",
366
+ platform=self.platform_name,
367
+ ) from e
368
+ except httpx.HTTPError as e:
369
+ raise PlatformError(
370
+ f"Failed to delete post: {e}",
371
+ platform=self.platform_name,
372
+ ) from e
373
+
374
+ # ==================== Comment Methods ====================
375
+
376
+ async def get_comments(
377
+ self,
378
+ post_id: str,
379
+ limit: int = 50,
380
+ offset: int = 0,
381
+ ) -> list[Comment]:
382
+ """Retrieve comments for a LinkedIn post.
383
+
384
+ Args:
385
+ post_id: LinkedIn post URN.
386
+ limit: Maximum number of comments to retrieve.
387
+ offset: Number of comments to skip.
388
+
389
+ Returns:
390
+ List of Comment objects.
391
+ """
392
+ if not self.api_client:
393
+ raise RuntimeError("Client must be used as async context manager")
394
+
395
+ try:
396
+ # LinkedIn Social API endpoint for comments
397
+ response = await self.api_client.get(
398
+ f"/socialActions/{post_id}/comments",
399
+ params={
400
+ "count": limit,
401
+ "start": offset,
402
+ },
403
+ )
404
+
405
+ comments = []
406
+ for comment_data in response.data.get("elements", []):
407
+ comments.append(self._parse_comment(comment_data, post_id))
408
+
409
+ return comments
410
+
411
+ except httpx.HTTPError as e:
412
+ raise PlatformError(
413
+ f"Failed to fetch comments: {e}",
414
+ platform=self.platform_name,
415
+ ) from e
416
+
417
+ async def create_comment(self, post_id: str, content: str) -> Comment:
418
+ """Add a comment to a LinkedIn post.
419
+
420
+ Args:
421
+ post_id: LinkedIn post URN.
422
+ content: Text content of the comment.
423
+
424
+ Returns:
425
+ Created Comment object.
426
+
427
+ Raises:
428
+ ValidationError: If comment content is invalid.
429
+ """
430
+ if not self.api_client:
431
+ raise RuntimeError("Client must be used as async context manager")
432
+
433
+ if not content or len(content) == 0:
434
+ raise ValidationError(
435
+ "Comment content cannot be empty",
436
+ platform=self.platform_name,
437
+ field="content",
438
+ )
439
+
440
+ # LinkedIn comment length limit
441
+ if len(content) > 1250:
442
+ raise ValidationError(
443
+ f"Comment exceeds 1250 characters ({len(content)} characters)",
444
+ platform=self.platform_name,
445
+ field="content",
446
+ )
447
+
448
+ try:
449
+ comment_payload = {
450
+ "actor": self.author_urn,
451
+ "message": {"text": content},
452
+ "object": post_id,
453
+ }
454
+
455
+ response = await self.api_client.post(
456
+ f"/socialActions/{post_id}/comments",
457
+ data=comment_payload,
458
+ )
459
+
460
+ comment_id = response.data["id"]
461
+
462
+ # Fetch full comment details
463
+ comment_response = await self.api_client.get(
464
+ f"/socialActions/{post_id}/comments/{comment_id}"
465
+ )
466
+
467
+ return self._parse_comment(comment_response.data, post_id)
468
+
469
+ except httpx.HTTPError as e:
470
+ raise PlatformError(
471
+ f"Failed to create comment: {e}",
472
+ platform=self.platform_name,
473
+ ) from e
474
+
475
+ async def delete_comment(self, comment_id: str) -> bool:
476
+ """Delete a LinkedIn comment.
477
+
478
+ Args:
479
+ comment_id: LinkedIn comment URN.
480
+
481
+ Returns:
482
+ True if deletion was successful.
483
+ """
484
+ if not self.api_client:
485
+ raise RuntimeError("Client must be used as async context manager")
486
+
487
+ try:
488
+ if not self.api_client._client:
489
+ raise RuntimeError("API client not initialized")
490
+
491
+ # Extract post ID from comment URN if needed
492
+ # Note: This is simplified; actual implementation may vary
493
+ await self.api_client._client.delete(
494
+ f"{self.base_url}/comments/{comment_id}"
495
+ )
496
+ return True
497
+
498
+ except httpx.HTTPError as e:
499
+ raise PlatformError(
500
+ f"Failed to delete comment: {e}",
501
+ platform=self.platform_name,
502
+ ) from e
503
+
504
+ # ==================== Media Methods ====================
505
+
506
+ async def upload_media(
507
+ self,
508
+ media_url: str,
509
+ media_type: str,
510
+ alt_text: str | None = None,
511
+ ) -> MediaAttachment:
512
+ """Upload media to LinkedIn.
513
+
514
+ Automatically handles images, videos, and documents with progress tracking.
515
+
516
+ Args:
517
+ media_url: URL or file path of the media.
518
+ media_type: Type of media ("image", "video", or "document").
519
+ alt_text: Alternative text for accessibility.
520
+
521
+ Returns:
522
+ MediaAttachment object with LinkedIn media URN.
523
+
524
+ Raises:
525
+ MediaUploadError: If upload fails.
526
+ RuntimeError: If client not used as context manager.
527
+
528
+ Example:
529
+ >>> async with LinkedInClient(credentials) as client:
530
+ ... media = await client.upload_media(
531
+ ... "/path/to/image.jpg",
532
+ ... "image",
533
+ ... alt_text="Company logo"
534
+ ... )
535
+ """
536
+ if not self._media_manager:
537
+ raise RuntimeError("Client must be used as async context manager")
538
+
539
+ try:
540
+ # Convert URL to string if needed
541
+ file_path = str(media_url)
542
+
543
+ # Upload based on type
544
+ if media_type.lower() == "image":
545
+ asset = await self._media_manager.upload_image(
546
+ file_path, alt_text=alt_text
547
+ )
548
+ elif media_type.lower() == "video":
549
+ asset = await self._media_manager.upload_video(
550
+ file_path, wait_for_processing=True
551
+ )
552
+ elif media_type.lower() == "document":
553
+ asset = await self._media_manager.upload_document(file_path)
554
+ else:
555
+ raise ValidationError(
556
+ f"Unsupported media type: {media_type}. "
557
+ "Must be 'image', 'video', or 'document'",
558
+ platform=self.platform_name,
559
+ field="media_type",
560
+ )
561
+
562
+ return MediaAttachment(
563
+ media_id=asset.asset_id,
564
+ media_type=(
565
+ MediaType.IMAGE
566
+ if media_type.lower() == "image"
567
+ else (
568
+ MediaType.VIDEO
569
+ if media_type.lower() == "video"
570
+ else MediaType.IMAGE
571
+ ) # Document
572
+ ),
573
+ url=cast(HttpUrl, media_url),
574
+ alt_text=alt_text,
575
+ )
576
+
577
+ except Exception as e:
578
+ raise MediaUploadError(
579
+ f"Failed to upload media: {e}",
580
+ platform=self.platform_name,
581
+ media_type=media_type,
582
+ ) from e
583
+
584
+ async def upload_image(
585
+ self,
586
+ file_path: str,
587
+ *,
588
+ alt_text: str | None = None,
589
+ ) -> MediaAsset:
590
+ """Upload an image to LinkedIn.
591
+
592
+ Convenience method for image uploads.
593
+
594
+ Args:
595
+ file_path: Path to image file or URL.
596
+ alt_text: Alternative text for accessibility.
597
+
598
+ Returns:
599
+ MediaAsset with asset ID.
600
+
601
+ Example:
602
+ >>> async with LinkedInClient(credentials) as client:
603
+ ... asset = await client.upload_image("photo.jpg")
604
+ """
605
+ if not self._media_manager:
606
+ raise RuntimeError("Client must be used as async context manager")
607
+
608
+ return await self._media_manager.upload_image(file_path, alt_text=alt_text)
609
+
610
+ async def upload_video(
611
+ self,
612
+ file_path: str,
613
+ *,
614
+ wait_for_processing: bool = True,
615
+ ) -> MediaAsset:
616
+ """Upload a video to LinkedIn.
617
+
618
+ Convenience method for video uploads.
619
+
620
+ Args:
621
+ file_path: Path to video file or URL.
622
+ wait_for_processing: Wait for video processing to complete.
623
+
624
+ Returns:
625
+ MediaAsset with asset ID.
626
+
627
+ Example:
628
+ >>> async with LinkedInClient(credentials) as client:
629
+ ... asset = await client.upload_video("video.mp4")
630
+ """
631
+ if not self._media_manager:
632
+ raise RuntimeError("Client must be used as async context manager")
633
+
634
+ return await self._media_manager.upload_video(
635
+ file_path, wait_for_processing=wait_for_processing
636
+ )
637
+
638
+ async def upload_document(
639
+ self,
640
+ file_path: str,
641
+ *,
642
+ title: str | None = None,
643
+ ) -> MediaAsset:
644
+ """Upload a document/PDF to LinkedIn.
645
+
646
+ Convenience method for document uploads.
647
+
648
+ Args:
649
+ file_path: Path to PDF file or URL.
650
+ title: Document title.
651
+
652
+ Returns:
653
+ MediaAsset with asset ID.
654
+
655
+ Example:
656
+ >>> async with LinkedInClient(credentials) as client:
657
+ ... asset = await client.upload_document("report.pdf")
658
+ """
659
+ if not self._media_manager:
660
+ raise RuntimeError("Client must be used as async context manager")
661
+
662
+ return await self._media_manager.upload_document(file_path, title=title)
663
+
664
+ # ==================== Helper Methods ====================
665
+
666
+ def _parse_post(self, data: dict[str, Any]) -> Post:
667
+ """Parse LinkedIn API response into Post model.
668
+
669
+ Args:
670
+ data: Raw API response data.
671
+
672
+ Returns:
673
+ Post object.
674
+ """
675
+ content = ""
676
+ if "specificContent" in data:
677
+ share_content = data["specificContent"].get(
678
+ "com.linkedin.ugc.ShareContent", {}
679
+ )
680
+ commentary = share_content.get("shareCommentary", {})
681
+ content = commentary.get("text", "")
682
+
683
+ # Extract timestamps
684
+ created_timestamp = data.get("created", {}).get("time", 0)
685
+ created_at = (
686
+ datetime.fromtimestamp(created_timestamp / 1000)
687
+ if created_timestamp
688
+ else datetime.now()
689
+ )
690
+
691
+ return Post(
692
+ post_id=data["id"],
693
+ platform=self.platform_name,
694
+ content=content,
695
+ media=[], # Media parsing would go here
696
+ status=(
697
+ PostStatus.PUBLISHED
698
+ if data.get("lifecycleState") == "PUBLISHED"
699
+ else PostStatus.DRAFT
700
+ ),
701
+ created_at=created_at,
702
+ author_id=data.get("author"),
703
+ raw_data=data,
704
+ )
705
+
706
+ def _parse_comment(self, data: dict[str, Any], post_id: str) -> Comment:
707
+ """Parse LinkedIn API response into Comment model.
708
+
709
+ Args:
710
+ data: Raw API response data.
711
+ post_id: ID of the post this comment belongs to.
712
+
713
+ Returns:
714
+ Comment object.
715
+ """
716
+ content = data.get("message", {}).get("text", "")
717
+ created_timestamp = data.get("created", {}).get("time", 0)
718
+ created_at = (
719
+ datetime.fromtimestamp(created_timestamp / 1000)
720
+ if created_timestamp
721
+ else datetime.now()
722
+ )
723
+
724
+ return Comment(
725
+ comment_id=data["id"],
726
+ post_id=post_id,
727
+ platform=self.platform_name,
728
+ content=content,
729
+ author_id=data.get("actor", "unknown"),
730
+ created_at=created_at,
731
+ status=CommentStatus.VISIBLE,
732
+ raw_data=data,
733
+ )