Files
wehub-resource-sync 9b395f5cc3
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
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

395 lines
15 KiB
JavaScript

import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
const FACEBOOK_HOME = 'https://www.facebook.com/';
const MAX_LIMIT = 50;
function requireLimit(value) {
const n = Number(value);
if (!Number.isInteger(n) || n < 1 || n > MAX_LIMIT) {
throw new ArgumentError(`facebook feed --limit must be an integer between 1 and ${MAX_LIMIT}`);
}
return n;
}
function unwrapBrowserResult(value) {
if (value && typeof value === 'object' && 'data' in value) {
return value.data;
}
return value;
}
function buildFeedExtractScript(limit) {
return `(() => {
const limit = ${limit};
function clean(value) {
// Strip zero-width / bidi control chars first: Facebook injects them into
// decoy nodes to poison scrapers. See issue #2089.
return String(value || '')
.replace(/[\\u200b-\\u200f\\u202a-\\u202e\\u2060\\ufeff]/g, '')
.replace(/\\s+/g, ' ')
.trim();
}
// FB anti-scrape decoys: long spaceless digit runs, spaced single-char
// strings ("a b c d e"), and hidden-domain .com spam. Real author/content
// text never looks like this.
function isDecoyText(text) {
if (!text) return true;
if (/^\\d{8,}$/.test(text)) return true;
if (/^(?:\\S ){4,}\\S$/.test(text) && text.replace(/\\s/g, '').length <= 12) return true;
return false;
}
function isReelsOrCarouselChrome(text) {
return /^(Reels|Reels and short videos|Reels 和短视频|快拍|短视频|People you may know)/i.test(text);
}
function textOf(el) {
return clean(el && el.textContent);
}
function labelOf(el) {
return clean(el && el.getAttribute && el.getAttribute('aria-label'));
}
function isAuthPage() {
const path = window.location && window.location.pathname ? window.location.pathname : '';
const body = textOf(document.body);
return /^\\/(login|checkpoint)(\\/|$|\\.php)/.test(path)
|| /^(Log in to Facebook|Facebook登录|登录 Facebook)/i.test(body)
|| /You must log in to continue/i.test(body);
}
function isExplicitEmptyFeed() {
const body = textOf(document.body);
return /No posts available|Nothing to show|暂无动态|没有更多动态|还没有帖子/i.test(body);
}
function isSuggestionOrChrome(text) {
return /^(People you may know|People You May Know|可能认识的人?|你可能认识的人?)/i.test(text)
|| /^(Suggested for you|Suggested Groups|推荐小组|推荐内容)/i.test(text);
}
function isSponsored(text) {
return /(^|\\s)(Sponsored|赞助|广告)(\\s|$)/i.test(text);
}
function isActionText(text) {
return /^(Like|Comment|Share|Send|Follow|赞|评论|分享|发送|关注)$/i.test(text);
}
function isMetricText(text) {
return /^(All:|所有心情:)/i.test(text)
|| /\\b(likes?|reactions?|comments?|shares?)\\b/i.test(text)
|| /(条评论|次分享)$/.test(text);
}
function isTimestampText(text) {
return /^(\\d+\\s*(s|m|h|d|w|mo|yr|min|sec|second|minute|hour|day|week|month|year)s?|Just now|Yesterday|刚刚|昨天|\\d+小时|\\d+天)(\\s*[·•.])?$/i.test(text);
}
function postUrlFrom(root) {
const links = Array.from(root.querySelectorAll('a[href]'));
for (const link of links) {
const href = link.href || link.getAttribute('href') || '';
if (/\\/posts\\/|\\/permalink\\.php|\\/story\\.php|\\/photo\\/\\?fbid=|\\/groups\\/[^/]+\\/posts\\//i.test(href)) {
return href;
}
}
return '';
}
function actionKinds(root) {
const kinds = new Set();
for (const el of root.querySelectorAll('[aria-label]')) {
const label = labelOf(el);
if (/^(Like|赞)$/i.test(label)) kinds.add('like');
if (/^(Comment|评论)$/i.test(label)) kinds.add('comment');
if (/^(Share|分享)$/i.test(label)) kinds.add('share');
}
return kinds;
}
function visibleBlocks(root) {
const seen = new Set();
return Array.from(root.querySelectorAll('[dir="auto"]'))
.map(textOf)
.filter((text) => {
if (!text || text.length > 600 || seen.has(text)) return false;
seen.add(text);
return true;
});
}
function findAuthor(root) {
const links = [
root.querySelector('h2 a[href], h3 a[href], h4 a[href], strong a[href]'),
...Array.from(root.querySelectorAll('a[role="link"][href]')),
].filter(Boolean);
for (const link of links) {
const text = textOf(link);
const href = link.href || link.getAttribute('href') || '';
if (text.length > 1 && text.length <= 80
&& !isActionText(text)
&& !isMetricText(text)
&& !isTimestampText(text)
&& !isDecoyText(text)
&& !/^[\\d\\s.,-]+$/.test(text) // reject all-digit decoy names, but keep "Class of 2024" (#2089)
&& !/\\/groups\\/|\\/watch\\/|\\/reel\\/|\\/events\\/|\\/friends\\//i.test(href)) {
return text;
}
}
return '';
}
function contentBlocks(root, author) {
return visibleBlocks(root).filter((text) => {
if (text === author) return false;
if (text.length <= 10) return false;
if (isSuggestionOrChrome(text) || isSponsored(text)) return false;
if (isActionText(text) || isMetricText(text) || isTimestampText(text)) return false;
if (isDecoyText(text) || isReelsOrCarouselChrome(text)) return false;
if (/^(See more|查看更多|更多)$/i.test(text)) return false;
return true;
});
}
function extractPost(root, index) {
const fullText = textOf(root);
if (!fullText || isSuggestionOrChrome(fullText) || isSponsored(fullText)) return null;
const author = findAuthor(root);
const blocks = contentBlocks(root, author);
const content = clean(blocks.join(' '));
const postUrl = postUrlFrom(root);
const kinds = actionKinds(root);
if (!author && !content) return null;
if (!content && !postUrl && kinds.size < 2) return null;
const likesMatch = fullText.match(/所有心情:\\s*(\\d[\\d,.\\s万亿KMk]*)/)
|| fullText.match(/All:\\s*(\\d[\\d,.KMk]*)/)
|| fullText.match(/(\\d[\\d,.KMk]*)\\s*(?:likes?|reactions?)/i);
const commentsMatch = fullText.match(/([\\d,.]+\\s*[万亿]?)\\s*条评论/)
|| fullText.match(/(\\d[\\d,.KMk]*)\\s*comments?/i);
const sharesMatch = fullText.match(/([\\d,.]+\\s*[万亿]?)\\s*次分享/)
|| fullText.match(/(\\d[\\d,.KMk]*)\\s*shares?/i);
return {
index,
author: author.substring(0, 50),
content: content.substring(0, 120),
likes: likesMatch ? clean(likesMatch[1]) : '-',
comments: commentsMatch ? clean(commentsMatch[1]) : '-',
shares: sharesMatch ? clean(sharesMatch[1]) : '-',
};
}
function primaryContainers() {
return Array.from(document.querySelectorAll('[role="article"]'))
.filter((el) => textOf(el).length > 30);
}
// Modern Facebook no longer wraps posts in [role="article"] nor exposes the
// Like/Comment/Share aria-labels the fallback keyed on. Every post still
// carries one "Actions for this post" control (the ⋯ menu). Anchor on it and
// walk up to the HIGHEST ancestor that still contains exactly one such
// control — that bounds the node to a single post. See issue #2089.
function actionMenuAnchors() {
// Post menus only — NOT "Actions for this comment", so the anchor set,
// countMenus, and loadFeedPosts all key on the same thing (#2089).
return Array.from(document.querySelectorAll('[aria-label]')).filter((el) =>
/^(Actions for this post|此帖子的操作|针对此帖子的操作|贴文的操作)$/i.test(labelOf(el)));
}
function actionAnchoredContainers() {
const menus = actionMenuAnchors();
const seen = new WeakSet();
const containers = [];
const countMenus = (node) => {
let n = 0;
for (const el of node.querySelectorAll('[aria-label]')) {
if (/^(Actions for this post|此帖子的操作|针对此帖子的操作|贴文的操作)$/i.test(labelOf(el))) n += 1;
}
return n;
};
const LANDMARK_ROLES = new Set(['main', 'feed', 'banner', 'navigation', 'complementary', 'contentinfo', 'region']);
for (const menu of menus) {
let node = menu.parentElement;
let best = null;
for (let depth = 0; depth < 22 && node && node !== document.body && node !== document.documentElement; depth += 1, node = node.parentElement) {
// Never let a single-post page climb the container up to a page
// landmark (role=main/feed/...) and emit the whole region as a post.
if (LANDMARK_ROLES.has(node.getAttribute('role') || '')) break;
if (countMenus(node) === 1) best = node; else break;
}
if (best && !seen.has(best) && textOf(best).length > 30) {
seen.add(best);
containers.push(best);
}
}
return containers;
}
function fallbackContainers() {
const main = document.querySelector('[role="main"]');
if (!main) return [];
const buttons = Array.from(main.querySelectorAll('[aria-label="Like"], [aria-label="赞"], [aria-label="Comment"], [aria-label="评论"], [aria-label="Share"], [aria-label="分享"]'));
const seen = new WeakSet();
const containers = [];
for (const button of buttons) {
let node = button.parentElement;
for (let depth = 0; depth < 16 && node && node !== main && node !== document.body; depth += 1, node = node.parentElement) {
const text = textOf(node);
const kinds = actionKinds(node);
const blocks = visibleBlocks(node);
const hasPostEvidence = Boolean(postUrlFrom(node)) || blocks.some((block) => block.length > 20 && !isActionText(block) && !isMetricText(block));
if (text.length >= 80 && kinds.has('like') && (kinds.has('comment') || kinds.has('share')) && hasPostEvidence) {
if (!seen.has(node)) {
seen.add(node);
containers.push(node);
}
break;
}
}
}
return containers;
}
function dedupe(containers) {
const seen = new Set();
const result = [];
for (const node of containers) {
const key = postUrlFrom(node) || contentBlocks(node, findAuthor(node)).join('|').substring(0, 200);
if (!key || seen.has(key)) continue;
seen.add(key);
result.push(node);
}
return result;
}
if (isAuthPage()) return { status: 'auth', rows: [], diagnostics: {} };
const primary = primaryContainers();
const actionAnchored = actionAnchoredContainers();
const combined = dedupe([...primary, ...actionAnchored, ...fallbackContainers()]);
const rows = [];
for (const container of combined) {
const row = extractPost(container, rows.length + 1);
if (row) rows.push(row);
if (rows.length >= limit) break;
}
return {
status: rows.length ? 'ok' : (isExplicitEmptyFeed() ? 'empty' : 'no_rows'),
rows,
diagnostics: {
articleCount: document.querySelectorAll('[role="article"]').length,
primaryCount: primary.length,
actionMenuCount: actionMenuAnchors().length,
fallbackActionCount: document.querySelectorAll('[role="main"] [aria-label="Like"], [role="main"] [aria-label="赞"], [role="main"] [aria-label="Comment"], [role="main"] [aria-label="评论"]').length,
mainTextLength: textOf(document.querySelector('[role="main"]')).length,
},
};
})()`;
}
// Facebook streams feed posts in lazily as you scroll, so a single extraction
// off the initial viewport under-returns. Scroll a bounded number of times
// until enough post action-menus are present (or we stop growing). See #2089.
async function loadFeedPosts(page, limit) {
const scrollStep = `(async () => {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
window.scrollTo(0, document.body.scrollHeight);
await sleep(800);
return document.querySelectorAll('[aria-label]').length
? document.querySelectorAll('[role="article"]').length
+ Array.from(document.querySelectorAll('[aria-label]')).filter((el) => /^(Actions for this post|此帖子的操作|针对此帖子的操作|贴文的操作)$/i.test((el.getAttribute('aria-label') || '').trim())).length
: 0;
})()`;
let prev = -1;
for (let i = 0; i < 8; i += 1) {
let count = 0;
try { count = Number(unwrapBrowserResult(await page.evaluate(scrollStep))) || 0; } catch { break; }
if (count >= limit || count === prev) break;
prev = count;
}
}
async function getFacebookFeed(page, kwargs) {
const limit = requireLimit(kwargs.limit ?? 10);
try {
await page.goto(FACEBOOK_HOME, { settleMs: 4000 });
} catch (err) {
throw new CommandExecutionError(
`Failed to navigate to facebook feed: ${err instanceof Error ? err.message : err}`,
'Check that facebook.com is reachable and the browser extension is connected.',
);
}
await loadFeedPosts(page, limit);
let payload;
try {
payload = unwrapBrowserResult(await page.evaluate(buildFeedExtractScript(limit)));
} catch (err) {
throw new CommandExecutionError(
`Failed to read facebook feed: ${err instanceof Error ? err.message : err}`,
'Facebook may not have rendered or the feed markup may have changed.',
);
}
if (!payload || typeof payload !== 'object' || !Array.isArray(payload.rows)) {
throw new CommandExecutionError('facebook feed returned malformed extraction payload');
}
if (payload.status === 'auth') {
throw new AuthRequiredError('www.facebook.com', 'Open Chrome and log in to Facebook before retrying.');
}
if (payload.rows.length > 0) {
return payload.rows;
}
if (payload.status === 'empty') {
throw new EmptyResultError('facebook feed', 'Facebook did not show any feed posts for this account.');
}
const diagnostics = payload.diagnostics || {};
if (diagnostics.articleCount || diagnostics.actionMenuCount || diagnostics.fallbackActionCount || diagnostics.mainTextLength > 200) {
throw new CommandExecutionError(
'facebook feed page rendered but no feed rows could be extracted',
`Diagnostics: articles=${diagnostics.articleCount || 0}, actions=${diagnostics.fallbackActionCount || 0}, mainTextLength=${diagnostics.mainTextLength || 0}.`,
);
}
throw new EmptyResultError('facebook feed', 'No Facebook feed content was visible in the current browser session.');
}
const command = {
site: 'facebook',
name: 'feed',
access: 'read',
description: 'Get your Facebook news feed',
domain: 'www.facebook.com',
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'limit', type: 'int', default: 10, help: 'Number of posts' },
],
columns: ['index', 'author', 'content', 'likes', 'comments', 'shares'],
func: getFacebookFeed,
};
cli(command);
export const __test__ = {
buildFeedExtractScript,
command,
getFacebookFeed,
requireLimit,
};