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

512 lines
19 KiB
JavaScript

import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
import { compactRepeatedText } from './shared.js';
function normalizeWhitespace(value) {
return String(value ?? '').replace(/\s+/g, ' ').trim();
}
function parseMetric(value) {
const raw = normalizeWhitespace(value).toLowerCase();
if (!raw)
return 0;
const compact = raw.replace(/,/g, '');
const match = compact.match(/(\d+(?:\.\d+)?)(k|m)?/i);
if (!match)
return 0;
const base = Number(match[1]);
const suffix = (match[2] || '').toLowerCase();
if (suffix === 'k')
return Math.round(base * 1000);
if (suffix === 'm')
return Math.round(base * 1000000);
return Math.round(base);
}
function buildPostId(post) {
const url = normalizeWhitespace(post.url);
if (url)
return url;
const author = compactRepeatedText(post.author);
const text = normalizeWhitespace(post.text);
const postedAt = normalizeTimestamp(post.posted_at);
return `${author}::${postedAt}::${text.slice(0, 120)}`;
}
function mergeTimelinePosts(existing, batch) {
const seen = new Set(existing.map(post => post.id));
const merged = [...existing];
for (const rawPost of batch) {
const post = {
id: buildPostId(rawPost),
author: compactRepeatedText(rawPost.author),
author_url: normalizeWhitespace(rawPost.author_url),
headline: compactRepeatedText(rawPost.headline),
text: normalizeWhitespace(rawPost.text),
posted_at: normalizeTimestamp(rawPost.posted_at),
reactions: Number(rawPost.reactions) || 0,
comments: Number(rawPost.comments) || 0,
url: normalizeWhitespace(rawPost.url),
};
if (!post.author || !post.text)
continue;
if (seen.has(post.id))
continue;
seen.add(post.id);
merged.push(post);
}
return merged;
}
function normalizeTimestamp(value) {
const text = normalizeWhitespace(value).replace(/[•·.]$/g, '').trim();
const match = text.match(/\b(\d+\s*(?:s|m|h|d|w|mo|yr|min))\b/i);
return match ? match[1].replace(/\s+/g, '') : text;
}
async function extractVisiblePosts(page) {
return page.evaluate(`(function () {
function normalize(value) {
return String(value || '').replace(/\\s+/g, ' ').trim();
}
function textOf(root, selector) {
var el = root.querySelector(selector);
return el ? el.textContent : '';
}
function hrefOf(root, selector) {
var el = root.querySelector(selector);
return el && el.href ? el.href : '';
}
function attrOf(root, selector, attr) {
var el = root.querySelector(selector);
return el ? el.getAttribute(attr) : '';
}
function cleanTimestamp(value) {
return normalize(String(value || '').replace(/[•.]/g, ' '));
}
function parseMetric(value) {
var raw = normalize(value).toLowerCase();
var match;
var base;
var suffix;
if (!raw) return 0;
match = raw.replace(/,/g, '').match(/(\\d+(?:\\.\\d+)?)(k|m)?/i);
if (!match) return 0;
base = Number(match[1]);
suffix = (match[2] || '').toLowerCase();
if (suffix === 'k') return Math.round(base * 1000);
if (suffix === 'm') return Math.round(base * 1000000);
return Math.round(base);
}
function splitBlocks(text) {
var lines = String(text || '').split('\\n');
var blocks = [];
var current = [];
var i;
var line;
for (i = 0; i < lines.length; i += 1) {
line = normalize(lines[i]);
if (!line) {
if (current.length) {
blocks.push(normalize(current.join(' ')));
current = [];
}
continue;
}
current.push(line);
}
if (current.length) blocks.push(normalize(current.join(' ')));
return blocks;
}
function looksLikeTimestamp(value) {
var lower = String(value || '').toLowerCase();
return /^\\d+\\s*(s|m|h|d|w|mo|yr|min)(\\s*[•.])?$/i.test(lower);
}
function looksLikeBadge(value) {
var lower = String(value || '').toLowerCase();
return String(value || '').indexOf('•') === 0
|| lower === '1st'
|| lower === '2nd'
|| lower === '3rd'
|| lower === 'degree connection';
}
function looksLikeAction(value) {
return /^(follow|send message|connect|visit my website|view my newsletter|subscribe)$/i.test((value || '').toLowerCase());
}
function looksLikeCta(value) {
return /^(book an appointment|view my services|visit my website|view my newsletter|subscribe|learn more|contact us)$/i.test((value || '').toLowerCase());
}
function looksLikeEngagement(value) {
return /(reactions?|comments?|reposts?)/i.test(String(value || ''));
}
function looksLikeFooterAction(value) {
return /^(like|comment|repost|send|reply|load more comments)$/i.test((value || '').toLowerCase());
}
function findActivityUrn(root) {
var elements = [root].concat(Array.from(root.querySelectorAll('*')));
var i;
var j;
var attrs;
var value;
var match;
for (i = 0; i < elements.length; i += 1) {
attrs = Array.from(elements[i].attributes || []);
for (j = 0; j < attrs.length; j += 1) {
value = String(attrs[j].value || '');
match = value.match(/urn:li:activity:\\d+/);
if (match) return match[0];
}
}
return '';
}
function parseReactionCount(root, blocks) {
var direct = textOf(root, '.social-details-social-counts__reactions-count');
var rootText = String(root.innerText || '');
var i;
var value;
value = rootText.match(/and\\s+(\\d[\\d,]*)\\s+others\\s+reacted/i);
if (value) return parseMetric(value[1]) + 1;
value = rootText.match(/and\\s+(\\d[\\d,]*)\\s+others(?!\\s+comments?)(?!\\s+reposts?)/i);
if (value) return parseMetric(value[1]) + 1;
value = rootText.match(/(\\d[\\d,]*)\\s+reactions?/i);
if (value) return parseMetric(value[0]);
if (direct) return parseMetric(direct);
for (i = 0; i < blocks.length; i += 1) {
value = blocks[i];
if (/and\\s+\\d[\\d,]*\\s+others(?!\\s+comments?)(?!\\s+reposts?)/i.test(value)) {
return parseMetric(value) + 1;
}
if (/reactions?/i.test(value)) return parseMetric(value);
if (/and\\s+\\d+[\\d,]*\\s+others\\s+reacted/i.test(value)) return parseMetric(value) + 1;
}
return 0;
}
function parseCommentCount(blocks) {
var i;
var text = blocks.join(' ');
var match = text.match(/(\\d[\\d,]*)\\s+comments?/i);
if (match) return parseMetric(match[0]);
for (i = 0; i < blocks.length; i += 1) {
if (/comments?/i.test(blocks[i])) return parseMetric(blocks[i]);
}
return 0;
}
function selectProfileLink(root, author) {
var links = Array.from(root.querySelectorAll('a[href*="/in/"], a[href*="/company/"]'));
var normalizedAuthor = normalize(author).toLowerCase();
var i;
var label;
for (i = 0; i < links.length; i += 1) {
label = normalize(links[i].textContent || links[i].getAttribute('aria-label')).toLowerCase();
if (!links[i].href) continue;
if (normalizedAuthor && label.indexOf(normalizedAuthor) >= 0) return links[i];
}
return links[0] || null;
}
function selectProfileUrl(root, author) {
var link = selectProfileLink(root, author);
return link && link.href ? link.href : '';
}
function parseActorLinkMeta(root, author) {
var link = selectProfileLink(root, author);
var text = normalize(link ? link.textContent : '');
var normalizedAuthor = normalize(author);
var match;
var rest;
var headline = '';
var postedAt = '';
if (!text || !normalizedAuthor) return { headline: '', postedAt: '' };
if (text.indexOf(normalizedAuthor) === 0) {
rest = normalize(text.slice(normalizedAuthor.length));
} else {
rest = text;
}
rest = normalize(rest.replace(/^[•·]\\s*(1st|2nd|3rd\\+?|3rd|degree connection)/i, ''));
match = rest.match(/(\\d+\\s*(?:s|m|h|d|w|mo|yr|min))\\s*[•·]?$/i);
if (match) {
postedAt = cleanTimestamp(match[1]);
headline = normalize(rest.slice(0, rest.length - match[0].length));
} else {
headline = rest;
}
headline = normalize(headline.replace(/^(book an appointment|view my services|visit my website|view my newsletter)\\s*/i, ''));
return { headline: headline, postedAt: postedAt };
}
function stripBodyTail(value) {
return normalize(String(value || '')
.replace(/\\s+\\d[\\d,]*\\s+reactions?[\\s\\S]*$/i, '')
.replace(/\\s+\\d[\\d,]*\\s+comments?[\\s\\S]*$/i, '')
.replace(/\\s+[A-Z][^\\n]+\\s+and\\s+\\d[\\d,]*\\s+others\\s+reacted[\\s\\S]*$/i, '')
.replace(/\\s+Like\\s+Comment\\s+Repost\\s+Send[\\s\\S]*$/i, '')
.replace(/\\s+Reaction button state:[\\s\\S]*$/i, '')
.replace(/^\\d+\\s*(?:s|m|h|d|w|mo|yr|min)\\s*[•.]?\\s*Follow\\s+/i, '')
);
}
function parseActorMeta(root) {
var actorLink = root.querySelector('a[href*="/in/"], a[href*="/company/"]');
var actorText = normalize(actorLink ? actorLink.textContent : '');
var author = '';
var headline = '';
var postedAt = '';
var match;
if (actorText) {
match = actorText.match(/^(.+?)\\s+[•·]\\s+(1st|2nd|3rd\\+?|3rd|degree connection)(.*)$/i);
if (match) {
author = normalize(match[1]);
actorText = normalize(match[3]);
}
}
match = actorText.match(/(.+?)\\s+(\\d+\\s*(?:s|m|h|d|w|mo|yr|min))\\s*[•·]?$/i);
if (match) {
headline = normalize(match[1]);
postedAt = cleanTimestamp(match[2]);
} else if (actorText) {
headline = actorText;
}
return {
author: author,
headline: headline,
postedAt: postedAt,
authorUrl: actorLink && actorLink.href ? actorLink.href : '',
};
}
function extractFromListItem(root) {
var blocks = splitBlocks(root.innerText || '');
var filtered = [];
var i;
var value;
var author = '';
var authorUrl = '';
var headline = '';
var postedAt = '';
var text = '';
var bodyStart = -1;
var permalink;
var url;
var reactions;
var comments;
var endIndex = -1;
var urn;
if (blocks.length < 5) return null;
if (blocks[0] !== 'Feed post') return null;
for (i = 1; i < blocks.length; i += 1) {
value = blocks[i];
if (!value) continue;
if (/commented on this|reposted this|liked this|suggested/i.test(value)) continue;
filtered.push(value);
}
if (filtered.length < 4) return null;
for (i = 0; i < filtered.length; i += 1) {
value = filtered[i];
if (!author && !looksLikeBadge(value) && !looksLikeAction(value) && !looksLikeTimestamp(value)) {
author = value;
continue;
}
if (author && !headline && !looksLikeBadge(value) && !looksLikeAction(value) && !looksLikeTimestamp(value) && !looksLikeCta(value)) {
headline = value;
continue;
}
if (!postedAt && looksLikeTimestamp(value)) {
postedAt = cleanTimestamp(value);
continue;
}
}
if (!author) return null;
authorUrl = selectProfileUrl(root, author);
if (!headline || !postedAt) {
var actorMeta = parseActorLinkMeta(root, author);
if (!headline && actorMeta.headline) headline = actorMeta.headline;
if (!postedAt && actorMeta.postedAt) postedAt = actorMeta.postedAt;
}
for (i = 0; i < filtered.length; i += 1) {
value = filtered[i];
if (looksLikeAction(value)) {
bodyStart = i + 1;
break;
}
}
if (bodyStart < 0 && postedAt) {
bodyStart = filtered.indexOf(postedAt) + 1;
}
if (bodyStart < 0) bodyStart = Math.min(filtered.length, headline ? 2 : 1);
for (i = bodyStart; i < filtered.length; i += 1) {
value = filtered[i];
if (looksLikeEngagement(value) || looksLikeFooterAction(value)) {
endIndex = i;
break;
}
}
if (endIndex < 0) endIndex = filtered.length;
text = stripBodyTail(filtered.slice(bodyStart, endIndex).join('\\n\\n'));
if (!text) return null;
permalink = root.querySelector('a[href*="/feed/update/"], a[href*="/posts/"], a[href*="/pulse/"]');
url = permalink ? permalink.href : '';
urn = findActivityUrn(root);
if (!url && urn) url = 'https://www.linkedin.com/feed/update/' + urn + '/';
reactions = parseReactionCount(root, filtered);
comments = parseCommentCount(filtered);
return {
id: url || (author + '::' + postedAt + '::' + text.slice(0, 120)),
author: author,
author_url: authorUrl,
headline: headline,
text: text,
posted_at: postedAt,
reactions: reactions,
comments: comments,
url: url,
};
}
function commentMetric(root) {
var links = Array.from(root.querySelectorAll('button, a'));
var i;
var label;
for (i = 0; i < links.length; i += 1) {
label = normalize(links[i].textContent || links[i].getAttribute('aria-label'));
if (/comment/i.test(label)) return parseMetric(label);
}
return 0;
}
var currentUrl = window.location.href;
var path = String(window.location.pathname || '');
var loginRequired = path.indexOf('/login') >= 0
|| path.indexOf('/checkpoint/') >= 0
|| Boolean(document.querySelector('input[name="session_key"], form.login__form'));
var moreButtons = Array.from(document.querySelectorAll('button, a[role="button"]'))
.filter(function (el) {
return /see more|more/i.test(normalize(el.textContent))
|| /see more|more/i.test(normalize(el.getAttribute('aria-label')));
})
.slice(0, 8);
var cards = Array.from(document.querySelectorAll('article, [role="article"], .feed-shared-update-v2, .occludable-update, [role="listitem"]'));
var seen = new Set();
var posts = [];
var i;
var card;
var root;
var author;
var headline;
var text;
var postedAt;
var permalink;
var url;
var reactions;
var comments;
for (i = 0; i < moreButtons.length; i += 1) {
try { moreButtons[i].click(); } catch (err) {}
}
for (i = 0; i < cards.length; i += 1) {
card = cards[i];
root = card.closest('article, [role="article"], .feed-shared-update-v2, .occludable-update, [role="listitem"]') || card;
if (!root || seen.has(root)) continue;
seen.add(root);
if (String(root.getAttribute('role') || '') === 'listitem') {
var extracted = extractFromListItem(root);
if (extracted) posts.push(extracted);
continue;
}
author = normalize(
textOf(root, '.update-components-actor__title span[dir="ltr"]')
|| textOf(root, '.update-components-actor__title')
|| textOf(root, '[data-control-name="actor"] span[dir="ltr"]')
|| textOf(root, '[data-control-name="actor"]')
);
headline = normalize(
textOf(root, '.update-components-actor__description')
|| textOf(root, '.update-components-actor__sub-description')
);
text = normalize(
textOf(root, '.update-components-text span[dir="ltr"]')
|| textOf(root, '.update-components-text')
|| textOf(root, '.feed-shared-inline-show-more-text span[dir="ltr"]')
|| textOf(root, '.feed-shared-inline-show-more-text')
|| textOf(root, '[data-test-id="main-feed-activity-card"] .break-words')
);
postedAt = normalize(
textOf(root, '.update-components-actor__sub-description a')
|| textOf(root, '.update-components-actor__sub-description span[aria-hidden="true"]')
|| textOf(root, 'time')
);
permalink = root.querySelector('a[href*="/feed/update/"], a[href*="/posts/"], a[href*="/pulse/"]');
url = permalink ? permalink.href : '';
if (url && url.indexOf('/') === 0) url = new URL(url, currentUrl).toString();
reactions = parseMetric(
textOf(root, '.social-details-social-counts__reactions-count')
|| attrOf(root, '[aria-label*="reaction"]', 'aria-label')
|| attrOf(root, '[aria-label*="like"]', 'aria-label')
);
comments = commentMetric(root);
if (!author || !text) continue;
posts.push({
id: url || (author + '::' + postedAt + '::' + text.slice(0, 120)),
author: author,
author_url: hrefOf(root, 'a[href*="/in/"], a[href*="/company/"]'),
headline: headline,
text: text,
posted_at: postedAt,
reactions: reactions,
comments: comments,
url: url,
});
}
return { loginRequired: loginRequired, posts: posts };
})()`);
}
cli({
site: 'linkedin',
name: 'timeline',
access: 'read',
description: 'Read LinkedIn home timeline posts',
domain: 'www.linkedin.com',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'limit', type: 'int', default: 20, help: 'Number of posts to return (max 100)' },
],
columns: ['rank', 'author', 'author_url', 'headline', 'text', 'posted_at', 'reactions', 'comments', 'url'],
func: async (page, kwargs) => {
const limit = Math.max(1, Math.min(kwargs.limit ?? 20, 100));
await page.goto('https://www.linkedin.com/feed/');
await page.wait(4);
let posts = [];
let sawLoginWall = false;
for (let i = 0; i < 6 && posts.length < limit; i++) {
const batch = await extractVisiblePosts(page);
if (batch?.loginRequired)
sawLoginWall = true;
posts = mergeTimelinePosts(posts, Array.isArray(batch?.posts) ? batch.posts : []);
if (posts.length >= limit)
break;
await page.autoScroll({ times: 1, delayMs: 1200 });
await page.wait(1);
}
if (sawLoginWall && posts.length === 0) {
throw new AuthRequiredError('linkedin.com', 'LinkedIn timeline requires an active signed-in browser session');
}
if (posts.length === 0) {
throw new EmptyResultError('linkedin timeline', 'Make sure your LinkedIn home feed is visible in the browser.');
}
return posts.slice(0, limit).map((post, index) => ({
rank: index + 1,
...post,
}));
},
});
export const __test__ = {
parseMetric,
buildPostId,
mergeTimelinePosts,
normalizeTimestamp,
};