9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
242 lines
11 KiB
JavaScript
242 lines
11 KiB
JavaScript
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
import {
|
|
assertLinkedInAuthenticated,
|
|
assertSafeLinkedinUrl,
|
|
compactRepeatedText,
|
|
normalizeHttpUrl,
|
|
normalizeWhitespace,
|
|
parseLimit,
|
|
unwrapEvaluateResult,
|
|
} from './shared.js';
|
|
|
|
export const DEFAULT_POSTS_LIMIT = 20;
|
|
export const MAX_POSTS_LIMIT = 100;
|
|
|
|
export function activityUrl(profileUrl) {
|
|
const url = assertSafeLinkedinUrl(profileUrl || 'https://www.linkedin.com/in/me/', 'profile-url', '/in/me/');
|
|
const parsed = new URL(url);
|
|
if (!/^\/in\/[^/?#]+\/?$/.test(parsed.pathname)) {
|
|
throw new CommandExecutionError('linkedin posts requires a /in/<handle>/ profile URL');
|
|
}
|
|
return `https://www.linkedin.com${parsed.pathname.replace(/\/?$/, '/') }recent-activity/all/`;
|
|
}
|
|
|
|
export function parseMetric(value) {
|
|
const raw = normalizeWhitespace(value).toLowerCase().replace(/,/g, '');
|
|
const match = raw.match(/(\d+(?:\.\d+)?)(k|m)?/i);
|
|
if (!match) return 0;
|
|
const base = Number(match[1]);
|
|
if (match[2]?.toLowerCase() === 'k') return Math.round(base * 1000);
|
|
if (match[2]?.toLowerCase() === 'm') return Math.round(base * 1000000);
|
|
return Math.round(base);
|
|
}
|
|
|
|
export function parseReactionText(value) {
|
|
const text = normalizeWhitespace(value);
|
|
const explicit = text.match(/(\d[\d,.]*\s*(?:k|m)?\s+reactions?)/i);
|
|
if (explicit) return parseMetric(explicit[1]);
|
|
const namedOthers = text.match(/(?:^|\s)(\d[\d,.]*\s*(?:k|m)?)\s+[A-Z][^.!?\n]{0,100}\s+and\s+\d[\d,.]*\s+others/i);
|
|
if (namedOthers) return parseMetric(namedOthers[1]);
|
|
const beforeComments = text.match(/(?:^|\s)(\d[\d,.]*\s*(?:k|m)?)\s+(?:\d[\d,.]*\s+comments?|\d[\d,.]*\s+reposts?)/i);
|
|
if (beforeComments) return parseMetric(beforeComments[1]);
|
|
const trailingNumber = text.match(/(?:^|\s)(\d[\d,.]*\s*(?:k|m)?)\s*$/i);
|
|
return trailingNumber ? parseMetric(trailingNumber[1]) : 0;
|
|
}
|
|
|
|
export function normalizePost(row) {
|
|
if (!row || typeof row !== 'object') {
|
|
throw new CommandExecutionError('LinkedIn posts returned malformed row');
|
|
}
|
|
const body = normalizeWhitespace(row.body);
|
|
const url = normalizeHttpUrl(row.url);
|
|
if (!body && !url) throw new CommandExecutionError('LinkedIn posts returned a row without body or URL');
|
|
return {
|
|
author: compactRepeatedText(row.author),
|
|
posted_at: normalizeWhitespace(row.posted_at),
|
|
body,
|
|
reactions: Number(row.reactions) || 0,
|
|
comments: Number(row.comments) || 0,
|
|
reposts: Number(row.reposts) || 0,
|
|
impressions: Number(row.impressions) || 0,
|
|
media: normalizeWhitespace(row.media),
|
|
media_urls: normalizeWhitespace(row.media_urls)
|
|
.split(/\s*\|\s*/)
|
|
.map((url) => normalizeHttpUrl(url))
|
|
.filter(Boolean)
|
|
.join(' | '),
|
|
url,
|
|
raw_text: normalizeWhitespace(row.raw_text),
|
|
};
|
|
}
|
|
|
|
export function buildPostsScript() {
|
|
return String.raw`(() => {
|
|
const clean = (s) => String(s || '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
const parseMetric = (s) => {
|
|
const raw = clean(s).toLowerCase().replace(/,/g, '');
|
|
const m = raw.match(/(\d+(?:\.\d+)?)(k|m)?/i);
|
|
if (!m) return 0;
|
|
const n = Number(m[1]);
|
|
if ((m[2] || '').toLowerCase() === 'k') return Math.round(n * 1000);
|
|
if ((m[2] || '').toLowerCase() === 'm') return Math.round(n * 1000000);
|
|
return Math.round(n);
|
|
};
|
|
const parseReactionText = (value) => {
|
|
const text = clean(value);
|
|
const explicit = text.match(/(\d[\d,.]*\s*(?:k|m)?\s+reactions?)/i);
|
|
if (explicit) return parseMetric(explicit[1]);
|
|
const namedOthers = text.match(/(?:^|\s)(\d[\d,.]*\s*(?:k|m)?)\s+[A-Z][^.!?\n]{0,100}\s+and\s+\d[\d,.]*\s+others/i);
|
|
if (namedOthers) return parseMetric(namedOthers[1]);
|
|
const beforeComments = text.match(/(?:^|\s)(\d[\d,.]*\s*(?:k|m)?)\s+(?:\d[\d,.]*\s+comments?|\d[\d,.]*\s+reposts?)/i);
|
|
if (beforeComments) return parseMetric(beforeComments[1]);
|
|
const trailingNumber = text.match(/(?:^|\s)(\d[\d,.]*\s*(?:k|m)?)\s*$/i);
|
|
return trailingNumber ? parseMetric(trailingNumber[1]) : 0;
|
|
};
|
|
const expanders = Array.from(document.querySelectorAll('button, a'))
|
|
.filter((el) => /\b(see more|show more|more)\b|…more/i.test(clean(el.innerText || el.textContent || el.getAttribute('aria-label') || '')));
|
|
for (const expander of expanders.slice(0, 20)) {
|
|
try { expander.click(); } catch {}
|
|
}
|
|
const cards = Array.from(document.querySelectorAll('article, [role="article"], .feed-shared-update-v2, .occludable-update'))
|
|
.filter((card) => clean(card.innerText || card.textContent || '').length > 60);
|
|
const rows = [];
|
|
const seen = new Set();
|
|
const stopLine = (line) => /^(like|comment|repost|send|share|copy link|follow|following|connect|message|activate to view|promoted|show more|see more)$/i.test(line)
|
|
|| /^\d[\d,.]*\s*(?:k|m)?\s+(?:reactions?|comments?|reposts?|shares?|impressions?)$/i.test(line)
|
|
|| /^[A-Z][A-Za-z ]+\s+and\s+\d[\d,.]*\s+others$/i.test(line);
|
|
const readBody = (root, lines) => {
|
|
const selectors = [
|
|
'.feed-shared-update-v2__description',
|
|
'.update-components-text',
|
|
'.feed-shared-text',
|
|
'[data-test-id*="main-feed-activity-card"] [dir="ltr"]',
|
|
'[class*="update-components-text"]'
|
|
];
|
|
for (const selector of selectors) {
|
|
const node = root.querySelector(selector);
|
|
const value = clean(node?.innerText || node?.textContent || '');
|
|
if (value && value.length > 8) return value.replace(/…more$/i, '').trim();
|
|
}
|
|
const timestampIndex = lines.findIndex((line) => /^\d+\s*(?:s|m|h|d|w|mo|yr|min)\b/i.test(line));
|
|
const start = timestampIndex >= 0 ? timestampIndex + 1 : Math.min(3, lines.length);
|
|
const body = [];
|
|
for (const line of lines.slice(start)) {
|
|
if (stopLine(line)) break;
|
|
if (/^(visible to anyone|edited|author|view .* profile)$/i.test(line)) continue;
|
|
body.push(line);
|
|
}
|
|
return clean(body.join(' ')).replace(/…more$/i, '').trim();
|
|
};
|
|
const readMedia = (root) => {
|
|
const media = [];
|
|
const mediaUrls = [];
|
|
const isDecorativeImageUrl = (src) => /profile-displayphoto|profile-framedphoto|company-logo|emoji|reaction|ghost-person|100_100/i.test(src || '');
|
|
const safeHttpUrl = (value) => {
|
|
try {
|
|
const parsed = new URL(value, location.origin);
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
|
|
if (parsed.username || parsed.password) return '';
|
|
return parsed.toString();
|
|
} catch {
|
|
return '';
|
|
}
|
|
};
|
|
const images = Array.from(root.querySelectorAll('img[alt]'))
|
|
.map((img) => ({ alt: clean(img.getAttribute('alt')), src: img.currentSrc || img.src || '' }))
|
|
.filter((img) => img.alt
|
|
&& !/profile|photo of|emoji|reaction|^like$|^love$|^celebrate$|^support$|^funny$|^insightful$|^curious$/i.test(img.alt));
|
|
for (const image of images) {
|
|
media.push('image: ' + image.alt);
|
|
const imageUrl = safeHttpUrl(image.src);
|
|
if (imageUrl && !isDecorativeImageUrl(imageUrl)) mediaUrls.push(imageUrl);
|
|
}
|
|
const videos = Array.from(root.querySelectorAll('video'));
|
|
for (const video of videos) {
|
|
media.push('video');
|
|
const src = video.currentSrc || video.src || video.querySelector('source')?.src || '';
|
|
const videoUrl = safeHttpUrl(src);
|
|
if (videoUrl) mediaUrls.push(videoUrl);
|
|
}
|
|
const externalLinks = Array.from(root.querySelectorAll('a[href]'))
|
|
.map((link) => ({ href: link.href, label: clean(link.innerText || link.textContent || '') }))
|
|
.map((link) => ({ ...link, href: safeHttpUrl(link.href) }))
|
|
.filter((link) => link.href && !/linkedin\.com/.test(link.href))
|
|
.slice(0, 5);
|
|
for (const link of externalLinks) {
|
|
media.push(clean('link: ' + (link.label || link.href) + ' ' + link.href));
|
|
mediaUrls.push(link.href);
|
|
}
|
|
return {
|
|
labels: Array.from(new Set(media)).join(' | '),
|
|
urls: Array.from(new Set(mediaUrls)).join(' | '),
|
|
};
|
|
};
|
|
for (const card of cards) {
|
|
const root = card.closest('article, [role="article"], .feed-shared-update-v2, .occludable-update') || card;
|
|
if (!root || seen.has(root)) continue;
|
|
seen.add(root);
|
|
const rawFullText = String(root.innerText || root.textContent || '');
|
|
const rawText = clean(rawFullText);
|
|
if (!rawText || rawText.length < 20) continue;
|
|
const permalink = root.querySelector('a[href*="/feed/update/"], a[href*="/posts/"], a[href*="/pulse/"]');
|
|
const url = permalink?.href ? new URL(permalink.href, location.origin).toString() : '';
|
|
const authorLink = root.querySelector('a[href*="/in/"], a[href*="/company/"]');
|
|
const lines = rawFullText.split(/\n+/).map(clean).filter(Boolean);
|
|
const author = clean(authorLink?.innerText || authorLink?.textContent || '')
|
|
|| lines.find((line) => line && !/^feed post/i.test(line) && !/verified|you|senior|engineer|developer/i.test(line)) || '';
|
|
const timestamp = (rawText.match(/\b\d+\s*(?:s|m|h|d|w|mo|yr|min)\b/i) || [''])[0];
|
|
const reactions = parseReactionText(rawText);
|
|
const comments = parseMetric((rawText.match(/(\d[\d,.]*\s*(?:k|m)?\s+comments?)/i) || [''])[0]);
|
|
const reposts = parseMetric((rawText.match(/(\d[\d,.]*\s*(?:k|m)?\s+reposts?)/i) || [''])[0]);
|
|
const impressions = parseMetric((rawText.match(/(\d[\d,.]*\s*(?:k|m)?\s+impressions?)/i) || [''])[0]);
|
|
const body = readBody(root, lines);
|
|
const mediaData = readMedia(root);
|
|
if (!body && !url) continue;
|
|
rows.push({
|
|
author,
|
|
posted_at: timestamp,
|
|
body,
|
|
reactions,
|
|
comments,
|
|
reposts,
|
|
impressions,
|
|
media: mediaData.labels,
|
|
media_urls: mediaData.urls,
|
|
url,
|
|
raw_text: rawText,
|
|
});
|
|
}
|
|
return { rows, url: location.href, title: document.title || '' };
|
|
})()`;
|
|
}
|
|
|
|
export async function collectPosts(page, args) {
|
|
if (!page) throw new CommandExecutionError('Browser session required for linkedin posts');
|
|
const limit = parseLimit(args.limit, DEFAULT_POSTS_LIMIT, MAX_POSTS_LIMIT);
|
|
await page.goto(activityUrl(args['profile-url']));
|
|
await page.wait(5);
|
|
await assertLinkedInAuthenticated(page, 'LinkedIn posts');
|
|
let rows = [];
|
|
for (let i = 0; i < 6 && rows.length < limit; i++) {
|
|
const payload = unwrapEvaluateResult(await page.evaluate(buildPostsScript()));
|
|
if (!payload || !Array.isArray(payload.rows)) {
|
|
throw new CommandExecutionError('LinkedIn posts returned malformed extraction payload');
|
|
}
|
|
rows = rows.concat(payload.rows.map(normalizePost));
|
|
const seen = new Set();
|
|
rows = rows.filter((row) => {
|
|
const key = row.url || `${row.author}::${row.posted_at}::${row.body.slice(0, 80)}`;
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
if (rows.length >= limit) break;
|
|
await page.autoScroll({ times: 1, delayMs: 900 });
|
|
await page.wait(1);
|
|
}
|
|
if (rows.length === 0) {
|
|
throw new EmptyResultError('linkedin posts', 'No visible posts were found on the LinkedIn activity page.');
|
|
}
|
|
return rows.slice(0, limit).map((row, index) => ({ rank: index + 1, ...row }));
|
|
}
|