Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

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 }));
}