linkedin-secret-sauce 0.12.5 → 0.13.0
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.
- package/dist/index.d.ts +1 -1
- package/dist/linkedin-api.d.ts +113 -1
- package/dist/linkedin-api.js +836 -3
- package/dist/parsers/profile-parser.js +1 -0
- package/dist/types.d.ts +164 -0
- package/docs/api/assets/hierarchy.js +1 -1
- package/docs/api/assets/navigation.js +1 -1
- package/docs/api/assets/search.js +1 -1
- package/docs/api/classes/LinkedInClientError.html +4 -4
- package/docs/api/functions/_testGetAccountCookies.html +2 -2
- package/docs/api/functions/_testGetAccountEntry.html +2 -2
- package/docs/api/functions/_testGetAllAccountIds.html +2 -2
- package/docs/api/functions/_testGetPoolState.html +2 -2
- package/docs/api/functions/adminResetAccount.html +1 -1
- package/docs/api/functions/adminSetCooldown.html +1 -1
- package/docs/api/functions/analyzeProfilePosts.html +14 -0
- package/docs/api/functions/buildCookieHeader.html +1 -1
- package/docs/api/functions/clearAllSmartLeadTokens.html +2 -2
- package/docs/api/functions/clearRequestHistory.html +1 -1
- package/docs/api/functions/clearSessionAccount.html +1 -1
- package/docs/api/functions/clearSmartLeadToken.html +2 -2
- package/docs/api/functions/createEnrichmentClient.html +2 -2
- package/docs/api/functions/extractCsrfToken.html +1 -1
- package/docs/api/functions/extractLinkedInHandle.html +2 -2
- package/docs/api/functions/fetchCookiesFromCosiall.html +2 -2
- package/docs/api/functions/fetchProfileEmailsFromCosiall.html +2 -2
- package/docs/api/functions/forceRefreshCookies.html +1 -1
- package/docs/api/functions/getAccountForSession.html +1 -1
- package/docs/api/functions/getAccountsSummary.html +1 -1
- package/docs/api/functions/getCompaniesBatch.html +2 -2
- package/docs/api/functions/getCompanyById.html +2 -2
- package/docs/api/functions/getCompanyByUrl.html +1 -1
- package/docs/api/functions/getConfig.html +1 -1
- package/docs/api/functions/getCookiePoolHealth.html +1 -1
- package/docs/api/functions/getPostComments.html +11 -0
- package/docs/api/functions/getPostHistory.html +12 -0
- package/docs/api/functions/getPostReactions.html +12 -0
- package/docs/api/functions/getProfileByUrn.html +2 -2
- package/docs/api/functions/getProfileByVanity.html +2 -2
- package/docs/api/functions/getProfilesBatch.html +1 -1
- package/docs/api/functions/getRequestHistory.html +1 -1
- package/docs/api/functions/getSalesNavigatorProfileDetails.html +1 -1
- package/docs/api/functions/getSalesNavigatorProfileFull.html +2 -2
- package/docs/api/functions/getSmartLeadToken.html +1 -1
- package/docs/api/functions/getSmartLeadTokenCacheStats.html +2 -2
- package/docs/api/functions/getSmartLeadUser.html +2 -2
- package/docs/api/functions/getSnapshot.html +1 -1
- package/docs/api/functions/getYearsAtCompanyOptions.html +2 -2
- package/docs/api/functions/getYearsInPositionOptions.html +2 -2
- package/docs/api/functions/getYearsOfExperienceOptions.html +2 -2
- package/docs/api/functions/incrementMetric.html +1 -1
- package/docs/api/functions/initializeCookiePool.html +1 -1
- package/docs/api/functions/initializeLinkedInClient.html +1 -1
- package/docs/api/functions/isBusinessEmail.html +2 -2
- package/docs/api/functions/isDisposableDomain.html +2 -2
- package/docs/api/functions/isDisposableEmail.html +2 -2
- package/docs/api/functions/isPersonalDomain.html +2 -2
- package/docs/api/functions/isPersonalEmail.html +2 -2
- package/docs/api/functions/isRoleAccount.html +2 -2
- package/docs/api/functions/isValidEmailSyntax.html +2 -2
- package/docs/api/functions/parseFullProfile.html +2 -2
- package/docs/api/functions/parseSalesSearchResults.html +1 -1
- package/docs/api/functions/reportAccountFailure.html +1 -1
- package/docs/api/functions/reportAccountSuccess.html +1 -1
- package/docs/api/functions/resolveCompanyUniversalName.html +1 -1
- package/docs/api/functions/searchSalesLeads.html +2 -2
- package/docs/api/functions/selectAccountForRequest.html +1 -1
- package/docs/api/functions/setAccountForSession.html +1 -1
- package/docs/api/functions/typeahead.html +1 -1
- package/docs/api/functions/verifyEmailMx.html +1 -1
- package/docs/api/hierarchy.html +1 -1
- package/docs/api/index.html +2 -2
- package/docs/api/interfaces/AccountCookies.html +2 -2
- package/docs/api/interfaces/AnalyzedPost.html +8 -0
- package/docs/api/interfaces/BatchEnrichmentOptions.html +8 -8
- package/docs/api/interfaces/CacheAdapter.html +4 -4
- package/docs/api/interfaces/CanonicalEmail.html +8 -8
- package/docs/api/interfaces/CommentAuthor.html +8 -0
- package/docs/api/interfaces/Company.html +2 -2
- package/docs/api/interfaces/ConstructConfig.html +5 -5
- package/docs/api/interfaces/CosiallProfileEmailsResponse.html +6 -6
- package/docs/api/interfaces/EnrichmentCandidate.html +4 -4
- package/docs/api/interfaces/EnrichmentClient.html +6 -6
- package/docs/api/interfaces/EnrichmentClientConfig.html +7 -7
- package/docs/api/interfaces/EnrichmentLogger.html +3 -3
- package/docs/api/interfaces/EnrichmentOptions.html +6 -6
- package/docs/api/interfaces/HunterConfig.html +3 -3
- package/docs/api/interfaces/LddConfig.html +3 -3
- package/docs/api/interfaces/LddProfileData.html +2 -2
- package/docs/api/interfaces/LinkedInClientConfig.html +2 -2
- package/docs/api/interfaces/LinkedInCookie.html +2 -2
- package/docs/api/interfaces/LinkedInPosition.html +2 -2
- package/docs/api/interfaces/LinkedInProfile.html +2 -2
- package/docs/api/interfaces/LinkedInSpotlightBadge.html +2 -2
- package/docs/api/interfaces/LinkedInTenure.html +2 -2
- package/docs/api/interfaces/Metrics.html +2 -2
- package/docs/api/interfaces/MetricsSnapshot.html +2 -2
- package/docs/api/interfaces/PostAnalytics.html +11 -0
- package/docs/api/interfaces/PostComment.html +8 -0
- package/docs/api/interfaces/PostCommentsResult.html +7 -0
- package/docs/api/interfaces/PostHistoryResult.html +4 -0
- package/docs/api/interfaces/PostReaction.html +5 -0
- package/docs/api/interfaces/PostReactionsResult.html +5 -0
- package/docs/api/interfaces/ProfileAnalysisOptions.html +14 -0
- package/docs/api/interfaces/ProfileAnalysisResult.html +9 -0
- package/docs/api/interfaces/ProfileEducation.html +2 -2
- package/docs/api/interfaces/ProfileEmailsLookupOptions.html +5 -5
- package/docs/api/interfaces/ProfilePosition.html +2 -2
- package/docs/api/interfaces/ProfilePost.html +14 -0
- package/docs/api/interfaces/ProfileSkill.html +2 -2
- package/docs/api/interfaces/ProviderResult.html +6 -6
- package/docs/api/interfaces/ProvidersConfig.html +6 -6
- package/docs/api/interfaces/ReactionActor.html +9 -0
- package/docs/api/interfaces/RequestHistoryEntry.html +2 -2
- package/docs/api/interfaces/SalesLeadSearchResult.html +2 -2
- package/docs/api/interfaces/SalesNavigatorContactInfo.html +2 -2
- package/docs/api/interfaces/SalesNavigatorPosition.html +2 -2
- package/docs/api/interfaces/SalesNavigatorProfile.html +2 -2
- package/docs/api/interfaces/SalesNavigatorProfileFull.html +4 -4
- package/docs/api/interfaces/SearchSalesResult.html +2 -2
- package/docs/api/interfaces/SmartLeadAuthConfig.html +4 -4
- package/docs/api/interfaces/SmartLeadCredentials.html +2 -2
- package/docs/api/interfaces/SmartLeadLoginResponse.html +2 -2
- package/docs/api/interfaces/SmartLeadUser.html +2 -2
- package/docs/api/interfaces/SmartProspectConfig.html +8 -8
- package/docs/api/interfaces/SmartProspectContact.html +2 -2
- package/docs/api/interfaces/SmartProspectSearchFilters.html +21 -21
- package/docs/api/interfaces/TypeaheadItem.html +2 -2
- package/docs/api/interfaces/TypeaheadResult.html +2 -2
- package/docs/api/interfaces/VerificationResult.html +9 -9
- package/docs/api/types/CostCallback.html +2 -2
- package/docs/api/types/Geo.html +2 -2
- package/docs/api/types/LddApiResponse.html +1 -1
- package/docs/api/types/LinkedInReactionType.html +2 -0
- package/docs/api/types/ProviderFunc.html +2 -2
- package/docs/api/types/ProviderName.html +2 -2
- package/docs/api/types/SalesSearchFilters.html +2 -2
- package/docs/api/types/TypeaheadType.html +1 -1
- package/docs/api/variables/COMPANY_SIZE_OPTIONS.html +1 -1
- package/docs/api/variables/DEFAULT_PROVIDER_ORDER.html +2 -2
- package/docs/api/variables/DISPOSABLE_DOMAINS.html +2 -2
- package/docs/api/variables/FUNCTION_OPTIONS.html +1 -1
- package/docs/api/variables/INDUSTRY_OPTIONS.html +1 -1
- package/docs/api/variables/LANGUAGE_OPTIONS.html +1 -1
- package/docs/api/variables/PERSONAL_DOMAINS.html +2 -2
- package/docs/api/variables/PROVIDER_COSTS.html +2 -2
- package/docs/api/variables/REGION_OPTIONS.html +1 -1
- package/docs/api/variables/SENIORITY_OPTIONS.html +2 -2
- package/docs/api/variables/YEARS_OPTIONS.html +1 -1
- package/package.json +1 -1
package/dist/linkedin-api.js
CHANGED
|
@@ -37,6 +37,7 @@ exports.COMPANY_SIZE_OPTIONS = exports.INDUSTRY_OPTIONS = exports.LANGUAGE_OPTIO
|
|
|
37
37
|
exports.getProfileByVanity = getProfileByVanity;
|
|
38
38
|
exports.getProfileByUrn = getProfileByUrn;
|
|
39
39
|
exports.searchSalesLeads = searchSalesLeads;
|
|
40
|
+
exports.analyzeProfilePosts = analyzeProfilePosts;
|
|
40
41
|
exports.getProfilesBatch = getProfilesBatch;
|
|
41
42
|
exports.resolveCompanyUniversalName = resolveCompanyUniversalName;
|
|
42
43
|
exports.getCompanyById = getCompanyById;
|
|
@@ -49,6 +50,9 @@ exports.getYearsOfExperienceOptions = getYearsOfExperienceOptions;
|
|
|
49
50
|
exports.getSalesNavigatorProfileDetails = getSalesNavigatorProfileDetails;
|
|
50
51
|
exports.getSalesNavigatorProfileFull = getSalesNavigatorProfileFull;
|
|
51
52
|
exports.extractLinkedInHandle = extractLinkedInHandle;
|
|
53
|
+
exports.getPostReactions = getPostReactions;
|
|
54
|
+
exports.getPostComments = getPostComments;
|
|
55
|
+
exports.getPostHistory = getPostHistory;
|
|
52
56
|
const config_1 = require("./config");
|
|
53
57
|
const http_client_1 = require("./http-client");
|
|
54
58
|
const profile_parser_1 = require("./parsers/profile-parser");
|
|
@@ -93,7 +97,10 @@ const inflightByVanity = new Map();
|
|
|
93
97
|
*/
|
|
94
98
|
async function getProfileByVanity(vanity) {
|
|
95
99
|
const cfg = (0, config_1.getConfig)();
|
|
96
|
-
|
|
100
|
+
// Extract vanity from LinkedIn URL if full URL is provided
|
|
101
|
+
const input = String(vanity || "").trim();
|
|
102
|
+
const urlMatch = input.match(/linkedin\.com\/in\/([^/?#]+)/i);
|
|
103
|
+
const key = (urlMatch ? decodeURIComponent(urlMatch[1]) : input).toLowerCase();
|
|
97
104
|
try {
|
|
98
105
|
(0, logger_1.log)("info", "api.start", {
|
|
99
106
|
operation: "getProfileByVanity",
|
|
@@ -113,7 +120,7 @@ async function getProfileByVanity(vanity) {
|
|
|
113
120
|
(0, metrics_1.incrementMetric)("inflightDedupeHits");
|
|
114
121
|
return inflight;
|
|
115
122
|
}
|
|
116
|
-
const url = `${LINKEDIN_API_BASE}/identity/dash/profiles?q=memberIdentity&memberIdentity=${encodeURIComponent(
|
|
123
|
+
const url = `${LINKEDIN_API_BASE}/identity/dash/profiles?q=memberIdentity&memberIdentity=${encodeURIComponent(key)}&decorationId=com.linkedin.voyager.dash.deco.identity.profile.FullProfileWithEntities-35`;
|
|
117
124
|
const p = (async () => {
|
|
118
125
|
try {
|
|
119
126
|
let raw;
|
|
@@ -137,7 +144,7 @@ async function getProfileByVanity(vanity) {
|
|
|
137
144
|
}
|
|
138
145
|
let prof;
|
|
139
146
|
try {
|
|
140
|
-
prof = (0, profile_parser_1.parseFullProfile)(raw,
|
|
147
|
+
prof = (0, profile_parser_1.parseFullProfile)(raw, key);
|
|
141
148
|
}
|
|
142
149
|
catch {
|
|
143
150
|
try {
|
|
@@ -468,6 +475,199 @@ async function searchSalesLeads(keywords, options) {
|
|
|
468
475
|
(0, metrics_1.incrementMetric)("searchCacheMisses");
|
|
469
476
|
return result;
|
|
470
477
|
}
|
|
478
|
+
// ---------------------------------------------------------------------------
|
|
479
|
+
// High-Level Convenience API
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
/**
|
|
482
|
+
* Analyzes a LinkedIn profile's posts with engagement data (reactions and comments).
|
|
483
|
+
* This is a high-level convenience function that orchestrates multiple API calls:
|
|
484
|
+
* 1. Fetches the profile by vanity URL or URN
|
|
485
|
+
* 2. Retrieves the profile's post history
|
|
486
|
+
* 3. Fetches reactions and comments for each post
|
|
487
|
+
*
|
|
488
|
+
* @param profileIdentifier - LinkedIn vanity URL, full profile URL, or FSD profile URN
|
|
489
|
+
* @param options - Analysis options for controlling fetch behavior
|
|
490
|
+
* @returns Complete analysis result with profile, posts, engagement data, and summary statistics
|
|
491
|
+
*
|
|
492
|
+
* @example
|
|
493
|
+
* ```typescript
|
|
494
|
+
* // Analyze by vanity URL
|
|
495
|
+
* const analysis = await analyzeProfilePosts('john-doe');
|
|
496
|
+
* console.log(analysis.summary.totalReactions);
|
|
497
|
+
* console.log(analysis.summary.avgCommentsPerPost);
|
|
498
|
+
*
|
|
499
|
+
* // Analyze by full LinkedIn URL
|
|
500
|
+
* const analysis = await analyzeProfilePosts('https://www.linkedin.com/in/john-doe');
|
|
501
|
+
*
|
|
502
|
+
* // Analyze with options
|
|
503
|
+
* const analysis = await analyzeProfilePosts('john-doe', {
|
|
504
|
+
* postsCount: 20,
|
|
505
|
+
* reactionsPerPost: 50,
|
|
506
|
+
* commentsPerPost: 25,
|
|
507
|
+
* concurrency: 5,
|
|
508
|
+
* });
|
|
509
|
+
*
|
|
510
|
+
* // Access individual post engagement
|
|
511
|
+
* for (const analyzed of analysis.analyzedPosts) {
|
|
512
|
+
* console.log(`Post: ${analyzed.post.text?.substring(0, 50)}...`);
|
|
513
|
+
* console.log(` Reactions: ${analyzed.reactions.totalCount}`);
|
|
514
|
+
* console.log(` Comments: ${analyzed.comments.totalCount}`);
|
|
515
|
+
* }
|
|
516
|
+
* ```
|
|
517
|
+
*/
|
|
518
|
+
async function analyzeProfilePosts(profileIdentifier, options) {
|
|
519
|
+
const postsCount = Math.min(options?.postsCount ?? 10, 100);
|
|
520
|
+
const reactionsPerPost = Math.min(options?.reactionsPerPost ?? 10, 100);
|
|
521
|
+
const commentsPerPost = Math.min(options?.commentsPerPost ?? 10, 100);
|
|
522
|
+
const concurrency = Math.max(1, Math.min(options?.concurrency ?? 3, 10));
|
|
523
|
+
const skipReactions = options?.skipReactions ?? false;
|
|
524
|
+
const skipComments = options?.skipComments ?? false;
|
|
525
|
+
try {
|
|
526
|
+
(0, logger_1.log)("info", "api.start", {
|
|
527
|
+
operation: "analyzeProfilePosts",
|
|
528
|
+
selector: profileIdentifier,
|
|
529
|
+
options: { postsCount, reactionsPerPost, commentsPerPost, concurrency },
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
catch { }
|
|
533
|
+
// Step 1: Fetch profile
|
|
534
|
+
const input = String(profileIdentifier || "").trim();
|
|
535
|
+
let profile;
|
|
536
|
+
// Determine if it's a URN or vanity
|
|
537
|
+
const isUrn = input.startsWith("urn:li:") || /^[A-Za-z0-9_-]{20,}$/.test(input);
|
|
538
|
+
const isUrl = input.includes("linkedin.com/in/");
|
|
539
|
+
if (isUrn && !isUrl) {
|
|
540
|
+
profile = await getProfileByUrn(input);
|
|
541
|
+
}
|
|
542
|
+
else {
|
|
543
|
+
// Extract vanity from URL or use as-is
|
|
544
|
+
profile = await getProfileByVanity(input);
|
|
545
|
+
}
|
|
546
|
+
// Step 2: Fetch post history
|
|
547
|
+
const fsdKey = profile.fsdProfileUrn?.replace("urn:li:fsd_profile:", "") ||
|
|
548
|
+
profile.objectUrn?.replace("urn:li:member:", "");
|
|
549
|
+
if (!fsdKey) {
|
|
550
|
+
throw new errors_1.LinkedInClientError("Could not extract profile key for post history", errors_1.ERROR_CODES.PARSE_ERROR, 500);
|
|
551
|
+
}
|
|
552
|
+
const postHistory = await getPostHistory(fsdKey, { count: postsCount });
|
|
553
|
+
// Step 3: Fetch engagement data for each post with controlled concurrency
|
|
554
|
+
const analyzedPosts = [];
|
|
555
|
+
const postsToAnalyze = postHistory.posts.filter((p) => p.activityUrn);
|
|
556
|
+
// Worker function for concurrent processing
|
|
557
|
+
let idx = 0;
|
|
558
|
+
async function worker() {
|
|
559
|
+
while (idx < postsToAnalyze.length) {
|
|
560
|
+
const myIdx = idx++;
|
|
561
|
+
const post = postsToAnalyze[myIdx];
|
|
562
|
+
if (!post.activityUrn)
|
|
563
|
+
continue;
|
|
564
|
+
let reactions = {
|
|
565
|
+
reactions: [],
|
|
566
|
+
totalCount: 0,
|
|
567
|
+
page: { start: 0, count: 0, total: 0, hasMore: false },
|
|
568
|
+
};
|
|
569
|
+
let comments = {
|
|
570
|
+
comments: [],
|
|
571
|
+
totalCount: 0,
|
|
572
|
+
page: { start: 0, count: 0, total: 0, hasMore: false },
|
|
573
|
+
};
|
|
574
|
+
// Fetch reactions if not skipped
|
|
575
|
+
if (!skipReactions) {
|
|
576
|
+
try {
|
|
577
|
+
reactions = await getPostReactions(post.activityUrn, {
|
|
578
|
+
count: reactionsPerPost,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
catch (e) {
|
|
582
|
+
try {
|
|
583
|
+
(0, logger_1.log)("warn", "api.analyzeProfilePosts.reactionsFailed", {
|
|
584
|
+
activityUrn: post.activityUrn,
|
|
585
|
+
error: e.message,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
catch { }
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
// Fetch comments if not skipped
|
|
592
|
+
if (!skipComments) {
|
|
593
|
+
try {
|
|
594
|
+
comments = await getPostComments(post.activityUrn, {
|
|
595
|
+
commentsCount: commentsPerPost,
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
catch (e) {
|
|
599
|
+
try {
|
|
600
|
+
(0, logger_1.log)("warn", "api.analyzeProfilePosts.commentsFailed", {
|
|
601
|
+
activityUrn: post.activityUrn,
|
|
602
|
+
error: e.message,
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
catch { }
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
analyzedPosts[myIdx] = { post, reactions, comments };
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
// Run workers concurrently
|
|
612
|
+
const workers = Array.from({ length: Math.min(concurrency, postsToAnalyze.length) }, () => worker());
|
|
613
|
+
await Promise.all(workers);
|
|
614
|
+
// Filter out any undefined entries (shouldn't happen, but be safe)
|
|
615
|
+
const validAnalyzedPosts = analyzedPosts.filter((p) => p !== undefined);
|
|
616
|
+
// Step 4: Calculate summary statistics
|
|
617
|
+
let totalReactions = 0;
|
|
618
|
+
let totalComments = 0;
|
|
619
|
+
const reactionsByType = {
|
|
620
|
+
LIKE: 0,
|
|
621
|
+
CELEBRATE: 0,
|
|
622
|
+
SUPPORT: 0,
|
|
623
|
+
LOVE: 0,
|
|
624
|
+
INSIGHTFUL: 0,
|
|
625
|
+
FUNNY: 0,
|
|
626
|
+
INTEREST: 0,
|
|
627
|
+
APPRECIATION: 0,
|
|
628
|
+
PRAISE: 0,
|
|
629
|
+
EMPATHY: 0,
|
|
630
|
+
ENTERTAINMENT: 0,
|
|
631
|
+
};
|
|
632
|
+
for (const analyzed of validAnalyzedPosts) {
|
|
633
|
+
totalReactions += analyzed.reactions.totalCount;
|
|
634
|
+
totalComments += analyzed.comments.totalCount;
|
|
635
|
+
for (const reaction of analyzed.reactions.reactions) {
|
|
636
|
+
if (reaction.reactionType) {
|
|
637
|
+
reactionsByType[reaction.reactionType] =
|
|
638
|
+
(reactionsByType[reaction.reactionType] || 0) + 1;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
const totalPosts = validAnalyzedPosts.length;
|
|
643
|
+
const avgReactionsPerPost = totalPosts > 0 ? totalReactions / totalPosts : 0;
|
|
644
|
+
const avgCommentsPerPost = totalPosts > 0 ? totalComments / totalPosts : 0;
|
|
645
|
+
const result = {
|
|
646
|
+
profile,
|
|
647
|
+
analyzedPosts: validAnalyzedPosts,
|
|
648
|
+
summary: {
|
|
649
|
+
totalPosts,
|
|
650
|
+
totalReactions,
|
|
651
|
+
totalComments,
|
|
652
|
+
avgReactionsPerPost: Math.round(avgReactionsPerPost * 100) / 100,
|
|
653
|
+
avgCommentsPerPost: Math.round(avgCommentsPerPost * 100) / 100,
|
|
654
|
+
reactionsByType,
|
|
655
|
+
},
|
|
656
|
+
};
|
|
657
|
+
try {
|
|
658
|
+
(0, logger_1.log)("info", "api.ok", {
|
|
659
|
+
operation: "analyzeProfilePosts",
|
|
660
|
+
selector: profileIdentifier,
|
|
661
|
+
summary: {
|
|
662
|
+
totalPosts,
|
|
663
|
+
totalReactions,
|
|
664
|
+
totalComments,
|
|
665
|
+
},
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
catch { }
|
|
669
|
+
return result;
|
|
670
|
+
}
|
|
471
671
|
async function getProfilesBatch(vanities, concurrency = 4) {
|
|
472
672
|
const limit = Math.max(1, Math.min(concurrency || 1, 16));
|
|
473
673
|
const results = Array.from({ length: vanities.length }, () => null);
|
|
@@ -1053,3 +1253,636 @@ function extractLinkedInHandle(flagshipProfileUrl) {
|
|
|
1053
1253
|
const match = flagshipProfileUrl.match(/linkedin\.com\/in\/([^\/\?]+)/i);
|
|
1054
1254
|
return match ? match[1] : null;
|
|
1055
1255
|
}
|
|
1256
|
+
// ---------------------------------------------------------------------------
|
|
1257
|
+
// Post Engagement APIs (Voyager GraphQL)
|
|
1258
|
+
// ---------------------------------------------------------------------------
|
|
1259
|
+
// GraphQL query IDs for post engagement endpoints (discovered via network inspection)
|
|
1260
|
+
const REACTIONS_QUERY_ID = "voyagerSocialDashReactions.41ebf31a9f4c4a84e35a49d5abc9010b";
|
|
1261
|
+
// Comments use voyagerFeedDashUpdates endpoint (not voyagerSocialDashComments which returns 400)
|
|
1262
|
+
const FEED_UPDATES_QUERY_ID = "voyagerFeedDashUpdates.ca43379417f0bcc4a7e2031d6c063250";
|
|
1263
|
+
/**
|
|
1264
|
+
* Parses LinkedIn relative time strings into approximate Unix timestamps.
|
|
1265
|
+
* LinkedIn returns times like "2d", "1w", "3mo", "1yr" instead of absolute timestamps.
|
|
1266
|
+
*
|
|
1267
|
+
* @param relativeText - Relative time string (e.g., "2d", "1w", "3mo", "2 days ago")
|
|
1268
|
+
* @returns Unix timestamp in milliseconds, or undefined if parsing fails
|
|
1269
|
+
*/
|
|
1270
|
+
function parseRelativeTime(relativeText) {
|
|
1271
|
+
if (!relativeText)
|
|
1272
|
+
return undefined;
|
|
1273
|
+
const now = Date.now();
|
|
1274
|
+
const text = relativeText.toLowerCase().trim();
|
|
1275
|
+
// Match patterns like "2d", "1w", "3mo", "1yr", "2 days ago", "1 week ago", etc.
|
|
1276
|
+
// Also handles "2d •" format (with trailing bullet)
|
|
1277
|
+
const match = text.match(/^(\d+)\s*(s|m|h|d|w|mo|yr|sec|min|hour|day|week|month|year)s?\b/i);
|
|
1278
|
+
if (!match) {
|
|
1279
|
+
// Try "X time ago" format
|
|
1280
|
+
const agoMatch = text.match(/(\d+)\s*(second|minute|hour|day|week|month|year)s?\s*ago/i);
|
|
1281
|
+
if (!agoMatch)
|
|
1282
|
+
return undefined;
|
|
1283
|
+
const [, numStr, unit] = agoMatch;
|
|
1284
|
+
const num = parseInt(numStr, 10);
|
|
1285
|
+
if (isNaN(num))
|
|
1286
|
+
return undefined;
|
|
1287
|
+
const msPerUnit = {
|
|
1288
|
+
second: 1000,
|
|
1289
|
+
minute: 60 * 1000,
|
|
1290
|
+
hour: 60 * 60 * 1000,
|
|
1291
|
+
day: 24 * 60 * 60 * 1000,
|
|
1292
|
+
week: 7 * 24 * 60 * 60 * 1000,
|
|
1293
|
+
month: 30 * 24 * 60 * 60 * 1000,
|
|
1294
|
+
year: 365 * 24 * 60 * 60 * 1000,
|
|
1295
|
+
};
|
|
1296
|
+
const unitKey = unit.toLowerCase();
|
|
1297
|
+
const ms = msPerUnit[unitKey];
|
|
1298
|
+
if (!ms)
|
|
1299
|
+
return undefined;
|
|
1300
|
+
return now - num * ms;
|
|
1301
|
+
}
|
|
1302
|
+
const [, numStr, unit] = match;
|
|
1303
|
+
const num = parseInt(numStr, 10);
|
|
1304
|
+
if (isNaN(num))
|
|
1305
|
+
return undefined;
|
|
1306
|
+
// Map short units to milliseconds
|
|
1307
|
+
const msPerUnit = {
|
|
1308
|
+
s: 1000,
|
|
1309
|
+
sec: 1000,
|
|
1310
|
+
m: 60 * 1000,
|
|
1311
|
+
min: 60 * 1000,
|
|
1312
|
+
h: 60 * 60 * 1000,
|
|
1313
|
+
hour: 60 * 60 * 1000,
|
|
1314
|
+
d: 24 * 60 * 60 * 1000,
|
|
1315
|
+
day: 24 * 60 * 60 * 1000,
|
|
1316
|
+
w: 7 * 24 * 60 * 60 * 1000,
|
|
1317
|
+
week: 7 * 24 * 60 * 60 * 1000,
|
|
1318
|
+
mo: 30 * 24 * 60 * 60 * 1000,
|
|
1319
|
+
month: 30 * 24 * 60 * 60 * 1000,
|
|
1320
|
+
yr: 365 * 24 * 60 * 60 * 1000,
|
|
1321
|
+
year: 365 * 24 * 60 * 60 * 1000,
|
|
1322
|
+
};
|
|
1323
|
+
const unitKey = unit.toLowerCase();
|
|
1324
|
+
const ms = msPerUnit[unitKey];
|
|
1325
|
+
if (!ms)
|
|
1326
|
+
return undefined;
|
|
1327
|
+
return now - num * ms;
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* Extracts the activity ID from various LinkedIn URN formats.
|
|
1331
|
+
* @param activityUrnOrId - Activity URN (urn:li:activity:xxx) or bare ID
|
|
1332
|
+
* @returns The numeric activity ID
|
|
1333
|
+
*/
|
|
1334
|
+
function extractActivityId(activityUrnOrId) {
|
|
1335
|
+
const input = String(activityUrnOrId || "").trim();
|
|
1336
|
+
// Match urn:li:activity:1234567890
|
|
1337
|
+
const urnMatch = input.match(/^urn:li:activity:(\d+)$/i);
|
|
1338
|
+
if (urnMatch)
|
|
1339
|
+
return urnMatch[1];
|
|
1340
|
+
// Match bare numeric ID
|
|
1341
|
+
if (/^\d+$/.test(input))
|
|
1342
|
+
return input;
|
|
1343
|
+
throw new errors_1.LinkedInClientError("Invalid activity URN or ID format", errors_1.ERROR_CODES.INVALID_INPUT, 400);
|
|
1344
|
+
}
|
|
1345
|
+
/**
|
|
1346
|
+
* Fetches reactions (likes, celebrates, etc.) for a LinkedIn post.
|
|
1347
|
+
* Uses the Voyager GraphQL API with pagination support.
|
|
1348
|
+
*
|
|
1349
|
+
* @param activityUrn - The activity URN (e.g., "urn:li:activity:7419061111293083648") or bare ID
|
|
1350
|
+
* @param options - Pagination options
|
|
1351
|
+
* @param options.start - Pagination offset (default: 0)
|
|
1352
|
+
* @param options.count - Number of reactions to fetch (default: 10, max: 100)
|
|
1353
|
+
* @returns PostReactionsResult with reactions array and pagination info
|
|
1354
|
+
* @throws LinkedInClientError with code NOT_FOUND if post doesn't exist
|
|
1355
|
+
*
|
|
1356
|
+
* @example
|
|
1357
|
+
* ```typescript
|
|
1358
|
+
* // Get first 10 reactions
|
|
1359
|
+
* const reactions = await getPostReactions('urn:li:activity:7419061111293083648');
|
|
1360
|
+
* console.log(reactions.totalCount); // 37
|
|
1361
|
+
* console.log(reactions.reactions[0].reactionType); // "LIKE"
|
|
1362
|
+
*
|
|
1363
|
+
* // Paginate through all reactions
|
|
1364
|
+
* const page2 = await getPostReactions('7419061111293083648', { start: 10, count: 10 });
|
|
1365
|
+
* ```
|
|
1366
|
+
*/
|
|
1367
|
+
async function getPostReactions(activityUrn, options) {
|
|
1368
|
+
const activityId = extractActivityId(activityUrn);
|
|
1369
|
+
const start = Number.isFinite(options?.start) ? Number(options.start) : 0;
|
|
1370
|
+
const count = Math.min(Number.isFinite(options?.count) ? Number(options.count) : 10, 100);
|
|
1371
|
+
const threadUrn = `urn:li:activity:${activityId}`;
|
|
1372
|
+
const variables = `(count:${count},start:${start},threadUrn:${encodeURIComponent(threadUrn)})`;
|
|
1373
|
+
const url = `${LINKEDIN_API_BASE}/graphql?includeWebMetadata=true&variables=${variables}&queryId=${REACTIONS_QUERY_ID}`;
|
|
1374
|
+
try {
|
|
1375
|
+
(0, logger_1.log)("info", "api.start", {
|
|
1376
|
+
operation: "getPostReactions",
|
|
1377
|
+
selector: { activityId, start, count },
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
catch { }
|
|
1381
|
+
let raw;
|
|
1382
|
+
try {
|
|
1383
|
+
raw = await (0, http_client_1.executeLinkedInRequest)({ url }, "getPostReactions");
|
|
1384
|
+
}
|
|
1385
|
+
catch (e) {
|
|
1386
|
+
const status = e?.status ?? 0;
|
|
1387
|
+
if (status === 404) {
|
|
1388
|
+
try {
|
|
1389
|
+
(0, logger_1.log)("warn", "api.notFound", {
|
|
1390
|
+
operation: "getPostReactions",
|
|
1391
|
+
selector: activityId,
|
|
1392
|
+
status,
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
catch { }
|
|
1396
|
+
throw new errors_1.LinkedInClientError("Post not found", errors_1.ERROR_CODES.NOT_FOUND, 404);
|
|
1397
|
+
}
|
|
1398
|
+
throw e;
|
|
1399
|
+
}
|
|
1400
|
+
// Parse the GraphQL response
|
|
1401
|
+
// Response structure: { data: { data: { socialDashReactionsByReactionType: { paging, *elements } } } }
|
|
1402
|
+
const rr = raw;
|
|
1403
|
+
const outerData = rr?.data;
|
|
1404
|
+
const innerData = outerData?.data;
|
|
1405
|
+
const reactionsData = innerData?.socialDashReactionsByReactionType;
|
|
1406
|
+
// Extract paging info
|
|
1407
|
+
const paging = reactionsData?.paging;
|
|
1408
|
+
const total = paging?.total ?? 0;
|
|
1409
|
+
// Extract reactions from *elements (note the asterisk prefix)
|
|
1410
|
+
// Format: "urn:li:fsd_reaction:(urn:li:fsd_profile:ABC,urn:li:activity:123,0)"
|
|
1411
|
+
// The last number (0) is the reaction type: 0=LIKE, 1=CELEBRATE, 2=SUPPORT, 3=LOVE, 4=INSIGHTFUL, 5=FUNNY
|
|
1412
|
+
const elementsRefs = reactionsData?.["*elements"] || [];
|
|
1413
|
+
const reactions = [];
|
|
1414
|
+
const reactionTypeMap = {
|
|
1415
|
+
"0": "LIKE",
|
|
1416
|
+
"1": "CELEBRATE",
|
|
1417
|
+
"2": "SUPPORT",
|
|
1418
|
+
"3": "LOVE",
|
|
1419
|
+
"4": "INSIGHTFUL",
|
|
1420
|
+
"5": "FUNNY",
|
|
1421
|
+
};
|
|
1422
|
+
for (const urn of elementsRefs) {
|
|
1423
|
+
if (!urn || typeof urn !== "string")
|
|
1424
|
+
continue;
|
|
1425
|
+
// Parse URN: urn:li:fsd_reaction:(urn:li:fsd_profile:ABC,urn:li:activity:123,0)
|
|
1426
|
+
const match = urn.match(/fsd_reaction:\(([^,]+),([^,]+),(\d+)\)/);
|
|
1427
|
+
if (!match)
|
|
1428
|
+
continue;
|
|
1429
|
+
const [, actorUrn, , reactionTypeNum] = match;
|
|
1430
|
+
const reactionType = reactionTypeMap[reactionTypeNum] || "LIKE";
|
|
1431
|
+
// Extract actor URN (profile or company)
|
|
1432
|
+
const isCompany = actorUrn?.includes("fsd_company:");
|
|
1433
|
+
const actorId = actorUrn?.match(/:([\w-]+)$/)?.[1];
|
|
1434
|
+
reactions.push({
|
|
1435
|
+
reactionType,
|
|
1436
|
+
actor: actorId
|
|
1437
|
+
? {
|
|
1438
|
+
entityUrn: actorUrn,
|
|
1439
|
+
isCompany,
|
|
1440
|
+
}
|
|
1441
|
+
: undefined,
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
const result = {
|
|
1445
|
+
reactions,
|
|
1446
|
+
totalCount: total,
|
|
1447
|
+
page: {
|
|
1448
|
+
start,
|
|
1449
|
+
count,
|
|
1450
|
+
total,
|
|
1451
|
+
hasMore: start + count < total,
|
|
1452
|
+
},
|
|
1453
|
+
};
|
|
1454
|
+
try {
|
|
1455
|
+
(0, logger_1.log)("info", "api.ok", {
|
|
1456
|
+
operation: "getPostReactions",
|
|
1457
|
+
selector: activityId,
|
|
1458
|
+
totalCount: total,
|
|
1459
|
+
fetchedCount: reactions.length,
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
catch { }
|
|
1463
|
+
return result;
|
|
1464
|
+
}
|
|
1465
|
+
/**
|
|
1466
|
+
* Fetches comments for a LinkedIn post.
|
|
1467
|
+
* Uses the voyagerFeedDashUpdates GraphQL API.
|
|
1468
|
+
*
|
|
1469
|
+
* @param activityUrn - The activity URN (e.g., "urn:li:activity:7419061111293083648") or bare ID
|
|
1470
|
+
* @param options - Fetch options
|
|
1471
|
+
* @param options.commentsCount - Number of comments to fetch (default: 10, max: 100)
|
|
1472
|
+
* @returns PostCommentsResult with comments array
|
|
1473
|
+
* @throws LinkedInClientError with code NOT_FOUND if post doesn't exist
|
|
1474
|
+
*
|
|
1475
|
+
* @example
|
|
1476
|
+
* ```typescript
|
|
1477
|
+
* // Get comments for a post
|
|
1478
|
+
* const comments = await getPostComments('urn:li:activity:7419061111293083648');
|
|
1479
|
+
* console.log(comments.totalCount);
|
|
1480
|
+
*
|
|
1481
|
+
* // Get more comments
|
|
1482
|
+
* const moreComments = await getPostComments('7419061111293083648', { commentsCount: 50 });
|
|
1483
|
+
* ```
|
|
1484
|
+
*/
|
|
1485
|
+
async function getPostComments(activityUrn, options) {
|
|
1486
|
+
const activityId = extractActivityId(activityUrn);
|
|
1487
|
+
const commentsCount = Math.min(Number.isFinite(options?.commentsCount)
|
|
1488
|
+
? Number(options.commentsCount)
|
|
1489
|
+
: 10, 100);
|
|
1490
|
+
// Build variables for voyagerFeedDashUpdates endpoint
|
|
1491
|
+
// Format: (commentsCount:10,likesCount:10,includeCommentsFirstReply:true,includeReactions:false,moduleKey:feed-item:desktop,urnOrNss:urn:li:activity:XXX)
|
|
1492
|
+
const activityUrnFull = `urn:li:activity:${activityId}`;
|
|
1493
|
+
const variables = `(commentsCount:${commentsCount},likesCount:10,includeCommentsFirstReply:true,includeReactions:false,moduleKey:feed-item%3Adesktop,urnOrNss:${encodeURIComponent(activityUrnFull)})`;
|
|
1494
|
+
const url = `${LINKEDIN_API_BASE}/graphql?includeWebMetadata=true&variables=${variables}&queryId=${FEED_UPDATES_QUERY_ID}`;
|
|
1495
|
+
try {
|
|
1496
|
+
(0, logger_1.log)("info", "api.start", {
|
|
1497
|
+
operation: "getPostComments",
|
|
1498
|
+
selector: { activityId, commentsCount },
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
catch { }
|
|
1502
|
+
let raw;
|
|
1503
|
+
try {
|
|
1504
|
+
raw = await (0, http_client_1.executeLinkedInRequest)({ url }, "getPostComments");
|
|
1505
|
+
}
|
|
1506
|
+
catch (e) {
|
|
1507
|
+
const status = e?.status ?? 0;
|
|
1508
|
+
if (status === 404) {
|
|
1509
|
+
try {
|
|
1510
|
+
(0, logger_1.log)("warn", "api.notFound", {
|
|
1511
|
+
operation: "getPostComments",
|
|
1512
|
+
selector: activityId,
|
|
1513
|
+
status,
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
catch { }
|
|
1517
|
+
throw new errors_1.LinkedInClientError("Post not found", errors_1.ERROR_CODES.NOT_FOUND, 404);
|
|
1518
|
+
}
|
|
1519
|
+
throw e;
|
|
1520
|
+
}
|
|
1521
|
+
// Parse the voyagerFeedDashUpdates GraphQL response
|
|
1522
|
+
// Response has: { data: {...}, included: [...] }
|
|
1523
|
+
const rr = raw;
|
|
1524
|
+
const included = rr?.included;
|
|
1525
|
+
const comments = [];
|
|
1526
|
+
let totalCount = 0;
|
|
1527
|
+
if (Array.isArray(included)) {
|
|
1528
|
+
// Find comments in included array - they have $type containing "Comment"
|
|
1529
|
+
for (const item of included) {
|
|
1530
|
+
const type = item?.$type;
|
|
1531
|
+
const entityUrn = item?.entityUrn;
|
|
1532
|
+
// Look for comment entities
|
|
1533
|
+
if (type && type.includes("Comment") && entityUrn?.includes("comment")) {
|
|
1534
|
+
// Extract comment text - try multiple possible locations
|
|
1535
|
+
let text;
|
|
1536
|
+
// Try commentary.text.text (nested structure) or commentary.text directly
|
|
1537
|
+
const commentaryObj = item.commentary;
|
|
1538
|
+
if (commentaryObj) {
|
|
1539
|
+
const commentaryTextObj = commentaryObj.text;
|
|
1540
|
+
if (typeof commentaryTextObj === "string") {
|
|
1541
|
+
text = commentaryTextObj;
|
|
1542
|
+
}
|
|
1543
|
+
else if (commentaryTextObj &&
|
|
1544
|
+
typeof commentaryTextObj === "object") {
|
|
1545
|
+
text = commentaryTextObj.text;
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
// Try content field
|
|
1549
|
+
if (!text && item.content) {
|
|
1550
|
+
const contentObj = item.content;
|
|
1551
|
+
if (typeof contentObj === "string") {
|
|
1552
|
+
text = contentObj;
|
|
1553
|
+
}
|
|
1554
|
+
else if (contentObj && typeof contentObj === "object") {
|
|
1555
|
+
const contentText = contentObj.text;
|
|
1556
|
+
if (typeof contentText === "string") {
|
|
1557
|
+
text = contentText;
|
|
1558
|
+
}
|
|
1559
|
+
else if (contentText && typeof contentText === "object") {
|
|
1560
|
+
text = contentText.text;
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
// Try direct text field
|
|
1565
|
+
if (!text) {
|
|
1566
|
+
text = item.commentText;
|
|
1567
|
+
}
|
|
1568
|
+
// Skip if no text found (probably a hide action or other non-comment entity)
|
|
1569
|
+
if (!text)
|
|
1570
|
+
continue;
|
|
1571
|
+
// Extract author info from commenter lockup
|
|
1572
|
+
const commenterLockup = item.commenter;
|
|
1573
|
+
const titleObj = commenterLockup?.title;
|
|
1574
|
+
const authorName = titleObj?.text;
|
|
1575
|
+
const subtitleObj = commenterLockup?.subtitle;
|
|
1576
|
+
const authorHeadline = subtitleObj?.text;
|
|
1577
|
+
// Extract profile URL
|
|
1578
|
+
const navigationCtx = commenterLockup?.navigationContext;
|
|
1579
|
+
const authorProfileUrl = navigationCtx?.url;
|
|
1580
|
+
// Parse author name
|
|
1581
|
+
let authorFirstName;
|
|
1582
|
+
let authorLastName;
|
|
1583
|
+
if (authorName) {
|
|
1584
|
+
const nameParts = authorName.split(" ");
|
|
1585
|
+
authorFirstName = nameParts[0];
|
|
1586
|
+
authorLastName = nameParts.slice(1).join(" ") || undefined;
|
|
1587
|
+
}
|
|
1588
|
+
const author = authorName
|
|
1589
|
+
? {
|
|
1590
|
+
entityUrn: item.commenterProfileId,
|
|
1591
|
+
firstName: authorFirstName,
|
|
1592
|
+
lastName: authorLastName,
|
|
1593
|
+
headline: authorHeadline,
|
|
1594
|
+
profileUrl: authorProfileUrl,
|
|
1595
|
+
}
|
|
1596
|
+
: undefined;
|
|
1597
|
+
// Extract engagement metrics
|
|
1598
|
+
const socialDetail = item.socialDetail;
|
|
1599
|
+
const totalCounts = socialDetail?.totalSocialActivityCounts;
|
|
1600
|
+
const numLikes = totalCounts?.numLikes ??
|
|
1601
|
+
item.numLikes;
|
|
1602
|
+
const numReplies = totalCounts?.numComments ??
|
|
1603
|
+
item.numReplies;
|
|
1604
|
+
// Extract timestamp
|
|
1605
|
+
const createdAt = item.createdTime;
|
|
1606
|
+
comments.push({
|
|
1607
|
+
entityUrn,
|
|
1608
|
+
text,
|
|
1609
|
+
author,
|
|
1610
|
+
createdAt,
|
|
1611
|
+
numLikes,
|
|
1612
|
+
numReplies,
|
|
1613
|
+
});
|
|
1614
|
+
}
|
|
1615
|
+
// Also look for social counts to get total comment count
|
|
1616
|
+
if (entityUrn?.includes("socialActivityCounts") &&
|
|
1617
|
+
entityUrn.includes(activityId)) {
|
|
1618
|
+
totalCount = item.numComments ?? totalCount;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
// If we didn't find totalCount from socialActivityCounts, use the comments array length
|
|
1623
|
+
if (totalCount === 0) {
|
|
1624
|
+
totalCount = comments.length;
|
|
1625
|
+
}
|
|
1626
|
+
const result = {
|
|
1627
|
+
comments,
|
|
1628
|
+
totalCount,
|
|
1629
|
+
page: {
|
|
1630
|
+
start: 0,
|
|
1631
|
+
count: commentsCount,
|
|
1632
|
+
total: totalCount,
|
|
1633
|
+
hasMore: comments.length < totalCount,
|
|
1634
|
+
},
|
|
1635
|
+
};
|
|
1636
|
+
try {
|
|
1637
|
+
(0, logger_1.log)("info", "api.ok", {
|
|
1638
|
+
operation: "getPostComments",
|
|
1639
|
+
selector: activityId,
|
|
1640
|
+
totalCount,
|
|
1641
|
+
fetchedCount: comments.length,
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
catch { }
|
|
1645
|
+
return result;
|
|
1646
|
+
}
|
|
1647
|
+
/**
|
|
1648
|
+
* Fetches a user's post history (their published posts).
|
|
1649
|
+
* Uses the Voyager profileUpdatesV2 API with pagination support.
|
|
1650
|
+
*
|
|
1651
|
+
* @param profileUrn - The FSD profile URN (e.g., "urn:li:fsd_profile:ABC123xyz") or bare key
|
|
1652
|
+
* @param options - Pagination options
|
|
1653
|
+
* @param options.start - Pagination offset (default: 0)
|
|
1654
|
+
* @param options.count - Number of posts to fetch (default: 10, max: 100)
|
|
1655
|
+
* @returns PostHistoryResult with posts array and pagination info
|
|
1656
|
+
* @throws LinkedInClientError with code NOT_FOUND if profile doesn't exist
|
|
1657
|
+
*
|
|
1658
|
+
* @example
|
|
1659
|
+
* ```typescript
|
|
1660
|
+
* // Get first 10 posts
|
|
1661
|
+
* const posts = await getPostHistory('ABC123xyz');
|
|
1662
|
+
* console.log(posts.posts.length);
|
|
1663
|
+
*
|
|
1664
|
+
* // Paginate
|
|
1665
|
+
* const page2 = await getPostHistory('ABC123xyz', { start: 10, count: 10 });
|
|
1666
|
+
* ```
|
|
1667
|
+
*/
|
|
1668
|
+
async function getPostHistory(profileUrn, options) {
|
|
1669
|
+
const input = String(profileUrn || "").trim();
|
|
1670
|
+
// Extract FSD key from various URN formats
|
|
1671
|
+
const keyMatch = input.match(/^urn:li:fsd_profile:([^\s/]+)$/i)?.[1] ||
|
|
1672
|
+
input.match(/^urn:li:fs_salesProfile:\(([^,\s)]+)/i)?.[1] ||
|
|
1673
|
+
(input.match(/^[A-Za-z0-9_-]+$/) ? input : null);
|
|
1674
|
+
if (!keyMatch) {
|
|
1675
|
+
throw new errors_1.LinkedInClientError("Invalid profile URN or key", errors_1.ERROR_CODES.INVALID_INPUT, 400);
|
|
1676
|
+
}
|
|
1677
|
+
const start = Number.isFinite(options?.start) ? Number(options.start) : 0;
|
|
1678
|
+
const count = Math.min(Number.isFinite(options?.count) ? Number(options.count) : 10, 100);
|
|
1679
|
+
const profileUrnEncoded = encodeURIComponent(`urn:li:fsd_profile:${keyMatch}`);
|
|
1680
|
+
const url = `${LINKEDIN_API_BASE}/identity/profileUpdatesV2?count=${count}&includeLongTermHistory=true&moduleKey=member-shares%3Aphone&profileUrn=${profileUrnEncoded}&q=memberShareFeed&start=${start}`;
|
|
1681
|
+
try {
|
|
1682
|
+
(0, logger_1.log)("info", "api.start", {
|
|
1683
|
+
operation: "getPostHistory",
|
|
1684
|
+
selector: { profileKey: keyMatch, start, count },
|
|
1685
|
+
});
|
|
1686
|
+
}
|
|
1687
|
+
catch { }
|
|
1688
|
+
let raw;
|
|
1689
|
+
try {
|
|
1690
|
+
raw = await (0, http_client_1.executeLinkedInRequest)({ url }, "getPostHistory");
|
|
1691
|
+
}
|
|
1692
|
+
catch (e) {
|
|
1693
|
+
const status = e?.status ?? 0;
|
|
1694
|
+
if (status === 404) {
|
|
1695
|
+
try {
|
|
1696
|
+
(0, logger_1.log)("warn", "api.notFound", {
|
|
1697
|
+
operation: "getPostHistory",
|
|
1698
|
+
selector: keyMatch,
|
|
1699
|
+
status,
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
catch { }
|
|
1703
|
+
throw new errors_1.LinkedInClientError("Profile not found", errors_1.ERROR_CODES.NOT_FOUND, 404);
|
|
1704
|
+
}
|
|
1705
|
+
throw e;
|
|
1706
|
+
}
|
|
1707
|
+
// Parse the response
|
|
1708
|
+
const rr = raw;
|
|
1709
|
+
// The response can have two formats:
|
|
1710
|
+
// 1. Old format: { elements: [...], paging: {...} }
|
|
1711
|
+
// 2. New dash format: { data: {...}, included: [...] }
|
|
1712
|
+
const included = rr?.included;
|
|
1713
|
+
const dataObj = rr?.data;
|
|
1714
|
+
// Extract paging info
|
|
1715
|
+
const paging = (dataObj?.paging || rr?.paging);
|
|
1716
|
+
const total = paging?.total;
|
|
1717
|
+
// Extract posts - handle both formats
|
|
1718
|
+
let elements = [];
|
|
1719
|
+
if (Array.isArray(rr?.elements)) {
|
|
1720
|
+
// Old format
|
|
1721
|
+
elements = rr.elements;
|
|
1722
|
+
}
|
|
1723
|
+
else if (Array.isArray(included)) {
|
|
1724
|
+
// New dash format - posts are in included array with specific $type
|
|
1725
|
+
const dataElements = dataObj?.elements;
|
|
1726
|
+
if (Array.isArray(dataElements)) {
|
|
1727
|
+
elements = dataElements;
|
|
1728
|
+
}
|
|
1729
|
+
else {
|
|
1730
|
+
// Filter included array for update/activity types
|
|
1731
|
+
elements = included.filter((item) => {
|
|
1732
|
+
const type = item?.$type;
|
|
1733
|
+
return (type &&
|
|
1734
|
+
(type.includes("Update") ||
|
|
1735
|
+
type.includes("Activity") ||
|
|
1736
|
+
type.includes("share") ||
|
|
1737
|
+
type.includes("Share")));
|
|
1738
|
+
});
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
const posts = [];
|
|
1742
|
+
// Build a map of activity URN -> relative time from actor.subDescription
|
|
1743
|
+
// LinkedIn returns relative time like "2d", "1w", "3mo" instead of absolute timestamps
|
|
1744
|
+
const relativeTimeMap = new Map();
|
|
1745
|
+
if (Array.isArray(included)) {
|
|
1746
|
+
for (const item of included) {
|
|
1747
|
+
const entityUrn = item?.entityUrn;
|
|
1748
|
+
if (!entityUrn?.includes("fs_updateV2:"))
|
|
1749
|
+
continue;
|
|
1750
|
+
const activityMatch = entityUrn.match(/activity:(\d+)/);
|
|
1751
|
+
if (!activityMatch)
|
|
1752
|
+
continue;
|
|
1753
|
+
const activityUrn = `urn:li:activity:${activityMatch[1]}`;
|
|
1754
|
+
const actor = item.actor;
|
|
1755
|
+
const subDesc = actor?.subDescription;
|
|
1756
|
+
const relativeText = subDesc?.text || subDesc?.accessibilityText;
|
|
1757
|
+
if (relativeText) {
|
|
1758
|
+
const timestamp = parseRelativeTime(relativeText);
|
|
1759
|
+
if (timestamp) {
|
|
1760
|
+
relativeTimeMap.set(activityUrn, timestamp);
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
// Build a map of activity URN -> social counts from included array
|
|
1766
|
+
const socialCountsMap = new Map();
|
|
1767
|
+
if (Array.isArray(included)) {
|
|
1768
|
+
for (const item of included) {
|
|
1769
|
+
const urn = item?.entityUrn;
|
|
1770
|
+
// Match fs_socialActivityCounts:urn:li:activity:XXX
|
|
1771
|
+
if (urn && urn.includes("fs_socialActivityCounts")) {
|
|
1772
|
+
const activityMatch = urn.match(/activity:(\d+)/);
|
|
1773
|
+
if (activityMatch) {
|
|
1774
|
+
const activityUrn = `urn:li:activity:${activityMatch[1]}`;
|
|
1775
|
+
socialCountsMap.set(activityUrn, {
|
|
1776
|
+
numLikes: item?.numLikes,
|
|
1777
|
+
numComments: item?.numComments,
|
|
1778
|
+
numShares: item?.numShares,
|
|
1779
|
+
});
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
for (const elem of elements) {
|
|
1785
|
+
const el = elem;
|
|
1786
|
+
if (!el)
|
|
1787
|
+
continue;
|
|
1788
|
+
const entityUrn = el.entityUrn;
|
|
1789
|
+
// Only process actual posts (fs_updateV2 with activity in URN)
|
|
1790
|
+
// Skip action entries, socialActivityCounts, miniProfile, etc.
|
|
1791
|
+
if (!entityUrn || !entityUrn.includes("fs_updateV2:"))
|
|
1792
|
+
continue;
|
|
1793
|
+
// Extract activity URN from entity URN
|
|
1794
|
+
// Format: urn:li:fs_updateV2:(urn:li:activity:7418215535290662912,MEMBER_SHARES,...)
|
|
1795
|
+
const activityMatch = entityUrn.match(/activity:(\d+)/);
|
|
1796
|
+
const activityUrn = activityMatch
|
|
1797
|
+
? `urn:li:activity:${activityMatch[1]}`
|
|
1798
|
+
: undefined;
|
|
1799
|
+
// Extract text from commentary or directly
|
|
1800
|
+
const commentary = el.commentary;
|
|
1801
|
+
const textObj = commentary?.text;
|
|
1802
|
+
let text = textObj?.text;
|
|
1803
|
+
// Fallback: check direct text field
|
|
1804
|
+
if (!text && el.text) {
|
|
1805
|
+
text = el.text;
|
|
1806
|
+
}
|
|
1807
|
+
// Get social counts from map or from element directly
|
|
1808
|
+
let numLikes;
|
|
1809
|
+
let numComments;
|
|
1810
|
+
let numShares;
|
|
1811
|
+
if (activityUrn && socialCountsMap.has(activityUrn)) {
|
|
1812
|
+
const counts = socialCountsMap.get(activityUrn);
|
|
1813
|
+
numLikes = counts.numLikes;
|
|
1814
|
+
numComments = counts.numComments;
|
|
1815
|
+
numShares = counts.numShares;
|
|
1816
|
+
}
|
|
1817
|
+
else {
|
|
1818
|
+
// Try extracting from socialDetail in the element
|
|
1819
|
+
const socialDetail = el.socialDetail;
|
|
1820
|
+
const socialCounts = socialDetail?.totalSocialActivityCounts;
|
|
1821
|
+
numLikes = socialCounts?.numLikes;
|
|
1822
|
+
numComments = socialCounts?.numComments;
|
|
1823
|
+
numShares = socialCounts?.numShares;
|
|
1824
|
+
}
|
|
1825
|
+
// Extract timestamp - try multiple possible paths
|
|
1826
|
+
let createdAt = el.createdTime;
|
|
1827
|
+
if (!createdAt) {
|
|
1828
|
+
// Try actor.timestamp or publishedAt
|
|
1829
|
+
const actor = el.actor;
|
|
1830
|
+
createdAt =
|
|
1831
|
+
actor?.timestamp ||
|
|
1832
|
+
el.publishedAt ||
|
|
1833
|
+
el.createdAt;
|
|
1834
|
+
}
|
|
1835
|
+
// Fallback to parsed relative time from actor.subDescription (e.g., "2d", "1w")
|
|
1836
|
+
if (!createdAt && activityUrn && relativeTimeMap.has(activityUrn)) {
|
|
1837
|
+
createdAt = relativeTimeMap.get(activityUrn);
|
|
1838
|
+
}
|
|
1839
|
+
// Check if reshared
|
|
1840
|
+
const resharedUpdate = el.resharedUpdate;
|
|
1841
|
+
const reshared = !!resharedUpdate;
|
|
1842
|
+
const originalPostUrn = resharedUpdate?.activityUrn;
|
|
1843
|
+
// Only add posts that have text content (skip empty entries)
|
|
1844
|
+
if (text || activityUrn) {
|
|
1845
|
+
posts.push({
|
|
1846
|
+
activityUrn,
|
|
1847
|
+
entityUrn,
|
|
1848
|
+
text,
|
|
1849
|
+
createdAt,
|
|
1850
|
+
numLikes,
|
|
1851
|
+
numComments,
|
|
1852
|
+
numShares,
|
|
1853
|
+
reshared,
|
|
1854
|
+
originalPostUrn,
|
|
1855
|
+
});
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
// Deduplicate posts by activityUrn
|
|
1859
|
+
const seenUrns = new Set();
|
|
1860
|
+
const uniquePosts = posts.filter((p) => {
|
|
1861
|
+
if (!p.activityUrn)
|
|
1862
|
+
return true;
|
|
1863
|
+
if (seenUrns.has(p.activityUrn))
|
|
1864
|
+
return false;
|
|
1865
|
+
seenUrns.add(p.activityUrn);
|
|
1866
|
+
return true;
|
|
1867
|
+
});
|
|
1868
|
+
const hasMore = total ? start + count < total : elements.length === count;
|
|
1869
|
+
const result = {
|
|
1870
|
+
posts: uniquePosts,
|
|
1871
|
+
page: {
|
|
1872
|
+
start,
|
|
1873
|
+
count,
|
|
1874
|
+
total,
|
|
1875
|
+
hasMore,
|
|
1876
|
+
},
|
|
1877
|
+
};
|
|
1878
|
+
try {
|
|
1879
|
+
(0, logger_1.log)("info", "api.ok", {
|
|
1880
|
+
operation: "getPostHistory",
|
|
1881
|
+
selector: keyMatch,
|
|
1882
|
+
totalCount: total,
|
|
1883
|
+
fetchedCount: uniquePosts.length,
|
|
1884
|
+
});
|
|
1885
|
+
}
|
|
1886
|
+
catch { }
|
|
1887
|
+
return result;
|
|
1888
|
+
}
|