#![allow(clippy::doc_markdown, clippy::map_unwrap_or)]
use anyhow::{Context, Result, bail};
use serde_json::Value;
use std::fmt::Write as _;
use super::helpers::{extract_csrf_token, extract_username_from_url, parse_voyager_activity};
use super::types::VoyagerActivityResponse;
use crate::impersonate_client;
use crate::site::SiteContent;
const ACTIVITY_COUNT: u32 = 10;
const ACCEPT_VND_LINKEDIN: &str = "application/vnd.linkedin.normalized+json+2.1";
const RESTLI_VERSION: &str = "2.0.0";
fn voyager_headers(csrf: &str) -> Vec<(String, String)> {
vec![
("csrf-token".to_string(), csrf.to_string()),
("accept".to_string(), ACCEPT_VND_LINKEDIN.to_string()),
(
"x-restli-protocol-version".to_string(),
RESTLI_VERSION.to_string(),
),
("x-li-lang".to_string(), "en_US".to_string()),
(
"referer".to_string(),
"https://www.linkedin.com/".to_string(),
),
]
}
async fn resolve_profile_urn(
username: &str,
cookies: &str,
csrf: &str,
) -> Result<(String, String)> {
let url = format!(
"https://www.linkedin.com/voyager/api/identity/dash/profiles\
?q=memberIdentity&memberIdentity={username}\
&decorationId=com.linkedin.voyager.dash.deco.identity.profile.FullProfileWithEntities-103"
);
let headers = voyager_headers(csrf);
let resp = impersonate_client::fetch_impersonated(&url, Some(cookies), Some(&headers)).await?;
if !resp.status.is_success() {
bail!(
"Voyager dash/profiles returned HTTP {} for username `{}`",
resp.status.as_u16(),
username
);
}
let json: Value =
serde_json::from_str(&resp.body).context("Voyager dash/profiles response was not JSON")?;
let display_name = {
let first = json
.pointer("/elements/0/firstName")
.and_then(Value::as_str)
.or_else(|| {
json.pointer("/data/elements/0/firstName")
.and_then(Value::as_str)
})
.unwrap_or("");
let last = json
.pointer("/elements/0/lastName")
.and_then(Value::as_str)
.or_else(|| {
json.pointer("/data/elements/0/lastName")
.and_then(Value::as_str)
})
.unwrap_or("");
format!("{first} {last}").trim().to_string()
};
if let Some(urn) = json
.pointer("/elements/0/entityUrn")
.and_then(Value::as_str)
.or_else(|| {
json.pointer("/data/elements/0/entityUrn")
.and_then(Value::as_str)
})
{
return Ok((urn.to_string(), display_name));
}
for needle in ["urn:li:fsd_profile:", "urn:li:fs_profile:"] {
if let Some(start) = resp.body.find(needle) {
let tail = &resp.body[start..];
let end = tail.find(['"', ',', '}', ')', '&']).unwrap_or(tail.len());
return Ok((tail[..end].to_string(), display_name));
}
}
bail!("could not extract profile URN for username `{username}`")
}
const FEED_QUERY_IDS: &[&str] = &[
"7f16f6612fc18a3623688ca7a74d7696",
"8f05a4e5ad12d9cb2b56eaa22afbcab9",
"3a42619bc23360ce8c29e737277e2ea9",
"4af00b28d60ed0f1488018948daad822",
"11595bab074f70dab009cecc3a585768",
];
async fn fetch_member_share_feed(profile_urn: &str, cookies: &str, csrf: &str) -> Result<String> {
let encoded_urn = urlencoding::encode(profile_urn);
let mut candidates: Vec<String> = FEED_QUERY_IDS
.iter()
.map(|hash| {
format!(
"https://www.linkedin.com/voyager/api/graphql\
?variables=(profileUrn:{encoded_urn},count:{ACTIVITY_COUNT},start:0)\
&queryId=voyagerFeedDashProfileUpdates.{hash}"
)
})
.collect();
candidates.extend([
format!(
"https://www.linkedin.com/voyager/api/feed/updates\
?profileUrn={encoded_urn}&q=memberShareFeed&count={ACTIVITY_COUNT}"
),
format!(
"https://www.linkedin.com/voyager/api/feed/updatesV2\
?profileUrn={encoded_urn}\
&q=memberShareFeed&moduleKey=member-shares%3Aphone&count={ACTIVITY_COUNT}"
),
format!(
"https://www.linkedin.com/voyager/api/feed/updatesV2\
?profileUrn={encoded_urn}&q=memberShareFeed&count={ACTIVITY_COUNT}"
),
format!(
"https://www.linkedin.com/voyager/api/identity/profileView\
?id={encoded_urn}"
),
]);
let headers = voyager_headers(csrf);
let mut last_err: Option<String> = None;
for endpoint in &candidates {
let resp =
impersonate_client::fetch_impersonated(endpoint, Some(cookies), Some(&headers)).await?;
if resp.status.is_success() && !resp.body.trim().is_empty() {
tracing::debug!(
"Voyager feed call succeeded via {} (body {} bytes)",
endpoint,
resp.body.len()
);
return Ok(resp.body);
}
let preview: String = resp.body.chars().take(160).collect();
last_err = Some(format!(
"HTTP {} from {} (body[0..160]: {})",
resp.status.as_u16(),
endpoint,
preview
));
tracing::debug!(
"Voyager candidate {} returned HTTP {}",
endpoint,
resp.status.as_u16()
);
}
bail!(
"all Voyager activity endpoints failed for `{}`: {}",
profile_urn,
last_err.unwrap_or_else(|| "(no responses)".to_string())
);
}
#[derive(Debug, Default, Clone)]
struct SocialCounts {
likes: u64,
comments: u64,
shares: u64,
impressions: u64,
}
#[derive(Debug, Clone)]
struct PostRecord {
activity_urn: String,
actor_name: String,
body: String,
reshare_of: Option<String>,
counts: SocialCounts,
}
impl PostRecord {
fn url(&self) -> String {
format!(
"https://www.linkedin.com/feed/update/{}/",
self.activity_urn
)
}
}
fn activity_urn_from_update_urn(update_urn: &str) -> Option<String> {
let inner = update_urn.strip_prefix("urn:li:fsd_update:(")?;
let first = inner.split(',').next()?;
if first.starts_with("urn:li:activity:") {
Some(first.to_string())
} else {
None
}
}
fn activity_urn_from_counts_urn(urn: &str) -> Option<String> {
if urn.starts_with("urn:li:activity:") {
Some(urn.to_string())
} else {
None
}
}
fn collect_social_counts(included: &[Value]) -> std::collections::HashMap<String, SocialCounts> {
let mut map = std::collections::HashMap::new();
for entry in included {
let Some(t) = entry.get("$type").and_then(Value::as_str) else {
continue;
};
if t != "com.linkedin.voyager.dash.feed.SocialActivityCounts" {
continue;
}
let Some(urn) = entry.get("urn").and_then(Value::as_str) else {
continue;
};
let Some(activity_urn) = activity_urn_from_counts_urn(urn) else {
continue;
};
let counts = SocialCounts {
likes: entry.get("numLikes").and_then(Value::as_u64).unwrap_or(0),
comments: entry
.get("numComments")
.and_then(Value::as_u64)
.unwrap_or(0),
shares: entry.get("numShares").and_then(Value::as_u64).unwrap_or(0),
impressions: entry
.get("numImpressions")
.and_then(Value::as_u64)
.unwrap_or(0),
};
map.insert(activity_urn, counts);
}
map
}
fn collect_posts(
included: &[Value],
counts_map: &std::collections::HashMap<String, SocialCounts>,
) -> Vec<PostRecord> {
let mut posts = Vec::new();
for entry in included {
let Some(t) = entry.get("$type").and_then(Value::as_str) else {
continue;
};
if t != "com.linkedin.voyager.dash.feed.Update" {
continue;
}
let Some(update_urn) = entry.get("entityUrn").and_then(Value::as_str) else {
continue;
};
let Some(activity_urn) = activity_urn_from_update_urn(update_urn) else {
continue;
};
let actor_name = entry
.pointer("/actor/name/text")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let body = entry
.pointer("/commentary/text/text")
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.to_string();
let reshare_of = entry.get("resharedUpdate").and_then(|rs| {
rs.pointer("/actor/name/text")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| {
if rs.is_object() || rs.is_string() {
Some("(original author)".to_string())
} else {
None
}
})
});
let counts = counts_map.get(&activity_urn).cloned().unwrap_or_default();
posts.push(PostRecord {
activity_urn,
actor_name,
body,
reshare_of,
counts,
});
}
posts.sort_by(|a, b| b.activity_urn.cmp(&a.activity_urn));
posts
}
fn fmt_engagement(c: &SocialCounts) -> String {
if c.likes == 0 && c.comments == 0 && c.shares == 0 && c.impressions == 0 {
return String::new();
}
let mut parts = Vec::new();
if c.impressions > 0 {
parts.push(format!("{} impressions", c.impressions));
}
if c.likes > 0 {
parts.push(format!("{} reactions", c.likes));
}
if c.comments > 0 {
parts.push(format!("{} comments", c.comments));
}
if c.shares > 0 {
parts.push(format!("{} reposts", c.shares));
}
parts.join(" · ")
}
fn render_voyager_body(body: &str, expected_actor_name: &str) -> String {
if let Ok(typed) = serde_json::from_str::<VoyagerActivityResponse>(body) {
let md = parse_voyager_activity(&typed);
if !md.trim().is_empty() {
return md;
}
}
let Ok(value) = serde_json::from_str::<Value>(body) else {
return String::new();
};
let included: &[Value] = value
.get("included")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[]);
if included.is_empty() {
return String::new();
}
let counts_map = collect_social_counts(included);
let posts = collect_posts(included, &counts_map);
if posts.is_empty() {
return String::new();
}
let mut md = String::new();
for post in posts.iter().take(ACTIVITY_COUNT as usize) {
let tag = if let Some(orig) = &post.reshare_of {
format!("RESHARE of {orig}")
} else if !expected_actor_name.is_empty()
&& !post.actor_name.is_empty()
&& post.actor_name != expected_actor_name
{
format!("RESHARE? (actor: {})", post.actor_name)
} else {
"POST".to_string()
};
let _ = writeln!(md, "---");
let _ = writeln!(md, "**[{tag}]** · <{}>", post.url());
let engagement = fmt_engagement(&post.counts);
if !engagement.is_empty() {
let _ = writeln!(md, "_{engagement}_");
}
let _ = writeln!(md);
if post.body.is_empty() {
let _ = writeln!(md, "_(no commentary)_");
} else {
let _ = writeln!(md, "{}", post.body);
}
let _ = writeln!(md);
}
md
}
pub async fn fetch_activity_via_voyager(url: &str, cookies: &str) -> Result<Option<SiteContent>> {
let username =
extract_username_from_url(url).context("activity URL did not contain /in/{username}")?;
let csrf = extract_csrf_token(cookies)
.context("no JSESSIONID cookie — cannot derive Voyager csrf-token")?;
let (profile_urn, display_name) = resolve_profile_urn(&username, cookies, &csrf).await?;
let body = fetch_member_share_feed(&profile_urn, cookies, &csrf).await?;
if let Ok(path) = std::env::var("NAB_DUMP_VOYAGER")
&& let Err(e) = std::fs::write(&path, &body)
{
tracing::warn!("NAB_DUMP_VOYAGER write to {path} failed: {e}");
}
let posts_md = render_voyager_body(&body, &display_name);
if posts_md.trim().is_empty() {
return Ok(None);
}
let mut md = String::new();
md.push_str("## Recent Activity\n\n");
md.push_str(&posts_md);
let _ = writeln!(md, "[View on LinkedIn]({url})");
Ok(Some(SiteContent {
markdown: md,
metadata: super::super::SiteMetadata {
author: Some(username),
title: Some("LinkedIn Activity".to_string()),
published: None,
platform: "linkedin".to_string(),
canonical_url: url.to_string(),
media_urls: Vec::new(),
engagement: None,
},
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_typed_response() {
let body = r#"{"elements":[
{"value":{"commentary":{"text":{"text":"first post"}}}},
{"value":{"commentary":{"text":{"text":"second post"}}}}
]}"#;
let md = render_voyager_body(body, "");
assert!(md.contains("first post"), "missing first post: {md}");
assert!(md.contains("second post"), "missing second post: {md}");
}
#[test]
fn render_decorator_envelope_only_includes_update_entities() {
let body = r#"{"data":{},"included":[
{"$type":"com.linkedin.voyager.dash.feed.Update",
"entityUrn":"urn:li:fsd_update:(urn:li:activity:7000000000000000001,MEMBER_FEED,DEBUG_REASON,DEFAULT,false)",
"actor":{"name":{"text":"Mikko Parkkola"}},
"commentary":{"text":{"text":"my actual post"}}},
{"$type":"com.linkedin.voyager.dash.social.Comment",
"entityUrn":"urn:li:fsd_comment:(activity:7000000000000000001,1)",
"commentary":{"text":{"text":"a one-line comment that should NOT appear as a post"}}},
{"$type":"com.linkedin.voyager.dash.identity.profile.Profile",
"firstName":"Other","lastName":"Person"}
]}"#;
let md = render_voyager_body(body, "Mikko Parkkola");
assert!(md.contains("my actual post"), "missing post: {md}");
assert!(
!md.contains("one-line comment"),
"leaked Comment entity into post output: {md}"
);
assert!(
!md.contains("Other Person"),
"leaked Profile entity into post output: {md}"
);
assert!(md.contains("[POST]"), "missing POST tag: {md}");
}
#[test]
fn render_reshare_does_not_attribute_inner_text_to_user() {
let body = r#"{"data":{},"included":[
{"$type":"com.linkedin.voyager.dash.feed.Update",
"entityUrn":"urn:li:fsd_update:(urn:li:activity:7000000000000000002,MEMBER_FEED,DEBUG_REASON,DEFAULT,false)",
"actor":{"name":{"text":"Mikko Parkkola"}},
"commentary":{"text":{"text":""}},
"resharedUpdate":{
"actor":{"name":{"text":"Mitko Vasilev"}},
"commentary":{"text":{"text":"I just deleted all my MCPs"}}
}}
]}"#;
let md = render_voyager_body(body, "Mikko Parkkola");
assert!(
md.contains("RESHARE of Mitko Vasilev"),
"missing reshare tag: {md}"
);
assert!(
!md.contains("deleted all my MCPs"),
"leaked reshared content into Mikko's post body: {md}"
);
}
#[test]
fn render_attaches_engagement_counts_via_activity_join() {
let body = r#"{"data":{},"included":[
{"$type":"com.linkedin.voyager.dash.feed.Update",
"entityUrn":"urn:li:fsd_update:(urn:li:activity:7000000000000000003,MEMBER_FEED,DEBUG_REASON,DEFAULT,false)",
"actor":{"name":{"text":"Mikko Parkkola"}},
"commentary":{"text":{"text":"a post with engagement"}}},
{"$type":"com.linkedin.voyager.dash.feed.SocialActivityCounts",
"urn":"urn:li:activity:7000000000000000003",
"numLikes":42,"numComments":5,"numShares":1,"numImpressions":900}
]}"#;
let md = render_voyager_body(body, "Mikko Parkkola");
assert!(md.contains("900 impressions"), "missing impressions: {md}");
assert!(md.contains("42 reactions"), "missing reactions: {md}");
assert!(md.contains("5 comments"), "missing comments: {md}");
assert!(md.contains("1 reposts"), "missing reposts: {md}");
}
#[test]
fn render_includes_post_url_for_each_post() {
let body = r#"{"data":{},"included":[
{"$type":"com.linkedin.voyager.dash.feed.Update",
"entityUrn":"urn:li:fsd_update:(urn:li:activity:7000000000000000004,MEMBER_FEED,DEBUG_REASON,DEFAULT,false)",
"actor":{"name":{"text":"Mikko Parkkola"}},
"commentary":{"text":{"text":"link me"}}}
]}"#;
let md = render_voyager_body(body, "Mikko Parkkola");
assert!(
md.contains(
"https://www.linkedin.com/feed/update/urn:li:activity:7000000000000000004/"
),
"missing per-post URL: {md}"
);
}
#[test]
fn render_sorts_newest_first_by_activity_urn() {
let body = r#"{"data":{},"included":[
{"$type":"com.linkedin.voyager.dash.feed.Update",
"entityUrn":"urn:li:fsd_update:(urn:li:activity:7000000000000000010,MEMBER_FEED,DEBUG_REASON,DEFAULT,false)",
"actor":{"name":{"text":"Mikko Parkkola"}},
"commentary":{"text":{"text":"older post"}}},
{"$type":"com.linkedin.voyager.dash.feed.Update",
"entityUrn":"urn:li:fsd_update:(urn:li:activity:7000000000000000099,MEMBER_FEED,DEBUG_REASON,DEFAULT,false)",
"actor":{"name":{"text":"Mikko Parkkola"}},
"commentary":{"text":{"text":"newer post"}}}
]}"#;
let md = render_voyager_body(body, "Mikko Parkkola");
let newer = md.find("newer post").expect("newer post missing");
let older = md.find("older post").expect("older post missing");
assert!(newer < older, "newer should render first; md: {md}");
}
#[test]
fn render_empty_response() {
let body = r#"{"elements":[]}"#;
let md = render_voyager_body(body, "Mikko Parkkola");
assert!(md.trim().is_empty());
}
#[test]
fn activity_urn_extraction_from_compound_update_urn() {
let urn = "urn:li:fsd_update:(urn:li:activity:7451014806356230146,MEMBER_FEED,DEBUG_REASON,DEFAULT,false)";
assert_eq!(
activity_urn_from_update_urn(urn).as_deref(),
Some("urn:li:activity:7451014806356230146")
);
}
#[test]
fn voyager_headers_includes_csrf() {
let h = voyager_headers("ajax:1234");
assert!(h.iter().any(|(k, v)| k == "csrf-token" && v == "ajax:1234"));
assert!(h.iter().any(|(k, _)| k == "x-restli-protocol-version"));
assert!(h.iter().any(|(k, _)| k == "accept"));
}
}