` inside
// the notification listitem. Used to set the typed `unread` boolean.
export const UNREAD_BADGE_LABELS = ['未读', 'Unread', 'No leído', '未読'];
export function normalizeNotificationsLimit(raw) {
if (raw === undefined || raw === null || raw === '') {
return NOTIFICATIONS_LIMIT_DEFAULT;
}
const n = typeof raw === 'number' ? raw : Number(raw);
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1 || n > NOTIFICATIONS_LIMIT_MAX) {
throw new ArgumentError(
`--limit must be a positive integer in [1, ${NOTIFICATIONS_LIMIT_MAX}], got ${JSON.stringify(raw)}`,
);
}
return n;
}
// Pure: strip a Facebook locale-specific "mark as read" prefix from a
// `
` aria-label so the caller gets the bare body
// text. Returns the stripped body, or `null` when the input does not
// start with a known prefix (i.e. we do not have a mark-as-read
// aria-label and the caller should fall through to anchor text).
//
// `prefixes` is injected so the same function works in JSDOM tests
// (passed the imported `MARK_AS_READ_PREFIXES`) and in the live IIFE
// (the array is inlined into the embedded script via `JSON.stringify`).
export function stripMarkAsReadPrefix(label, prefixes) {
if (!label) return null;
const text = String(label).trim();
if (!text) return null;
for (let i = 0; i < prefixes.length; i += 1) {
const p = prefixes[i];
if (text.startsWith(p)) return text.slice(p.length).trim();
}
return null;
}
// Pure: strip the leading "未读" / "Unread" badge prefix and the trailing
// time-ago suffix (e.g. "2天" / "5 hrs") from the rendered text of a
// notification anchor. Used as a fallback when the mark-as-read
// aria-label is not present (already-read rows).
//
// `badges` is the localized unread-badge labels list; `timeStr` is the
// trailing time string previously extracted from `
` (or `null`).
// Trailing `.` / `。` that Facebook inserts between body and time are
// also stripped — but only at the very end, never mid-text.
export function stripAnchorChrome(rawText, timeStr, badges) {
if (!rawText) return '';
let text = String(rawText).trim();
for (let i = 0; i < badges.length; i += 1) {
const b = badges[i];
if (text.startsWith(b)) {
text = text.slice(b.length).trim();
break;
}
}
if (timeStr) {
const t = String(timeStr).trim();
if (t && text.endsWith(t)) {
text = text.slice(0, text.length - t.length).trim();
}
}
text = text.replace(/[.。]+$/, '').trim();
return text;
}
// Pure: extract `notif_id` and `notif_t` query params from a
// notification anchor href. Both fields stay `null` when absent so the
// caller can detect "this row has no canonical notif handle" (rare,
// but happens for some notification types FB inlines a non-canonical
// URL for) — never an in-band sentinel.
export function parseNotifQuery(rawHref, fbHost) {
const out = { notif_id: null, notif_type: null };
if (!rawHref) return out;
let url;
try {
url = new URL(rawHref, fbHost);
} catch (_) {
return out;
}
out.notif_id = url.searchParams.get('notif_id') || null;
out.notif_type = url.searchParams.get('notif_t') || null;
return out;
}
export function isFacebookAuthRedirectPath(pathname) {
return /^\/(?:login|checkpoint)(?:\.php)?(?:\/|$)/i.test(String(pathname || ''));
}
// Pure extractor: walks `[role="listitem"]` containers in `doc` and
// returns at most `limit` notification rows. Header listitems (those
// without an `` child — e.g. the "新通知" / "Earlier" section
// heading FB inserts at the top of the feed) are skipped.
//
// `helpers` carries the four pure helpers as positional refs so the
// same function works in JSDOM (test imports them) and in the live
// IIFE (embeds them via `${fn.toString()}`):
// { stripMark, stripChrome, parseQuery, fbHost,
// markPrefixes, unreadBadges }
export function extractNotificationRowsFromDoc(doc, limit, helpers) {
const out = [];
const items = doc.querySelectorAll('[role="listitem"]');
for (let i = 0; i < items.length && out.length < limit; i += 1) {
const item = items[i];
const anchor = item.querySelector('a[href]');
if (!anchor) continue;
const href = anchor.href || anchor.getAttribute('href') || '';
if (!href) continue;
const abbr = item.querySelector('abbr');
const time = abbr ? ((abbr.textContent || '').trim() || null) : null;
// Unread badge: prefer the explicit `未读
` /
// `Unread
` child; fall back to anchor text prefix.
let unread = false;
const allDivs = item.querySelectorAll('div');
for (let j = 0; j < allDivs.length; j += 1) {
const t = (allDivs[j].textContent || '').trim();
if (helpers.unreadBadges.indexOf(t) !== -1) {
unread = true;
break;
}
}
if (!unread) {
const anchorText = (anchor.textContent || '').trim();
for (let k = 0; k < helpers.unreadBadges.length; k += 1) {
if (anchorText.startsWith(helpers.unreadBadges[k])) {
unread = true;
break;
}
}
}
// Body text — try every aria-label on descendants, take the
// first one that the mark-as-read prefix helper recognises.
// Fall back to the anchor's own textContent with badge / time
// / trailing punctuation chrome stripped.
let text = null;
const labelHosts = item.querySelectorAll('[aria-label]');
for (let k = 0; k < labelHosts.length; k += 1) {
const label = labelHosts[k].getAttribute('aria-label');
const stripped = helpers.stripMark(label, helpers.markPrefixes);
if (stripped !== null) {
const cleaned = stripped.replace(/[.。]+$/, '').trim();
text = cleaned || null;
break;
}
}
if (!text) {
const fallback = helpers.stripChrome(
anchor.textContent || '',
time,
helpers.unreadBadges,
);
text = fallback || null;
}
if (!text) continue;
const { notif_id, notif_type } = helpers.parseQuery(href, helpers.fbHost);
out.push({
index: out.length + 1,
unread,
text,
time,
url: href,
notif_id,
notif_type,
});
}
return out;
}
export function buildNotificationsScript(limit) {
return `
(async () => {
const FB_HOST = ${JSON.stringify(FB_HOST)};
const MARK_AS_READ_PREFIXES = ${JSON.stringify(MARK_AS_READ_PREFIXES)};
const UNREAD_BADGE_LABELS = ${JSON.stringify(UNREAD_BADGE_LABELS)};
${isFacebookAuthRedirectPath.toString()}
if (isFacebookAuthRedirectPath(window.location.pathname || '')) {
throw new Error('AUTH_REQUIRED: facebook.com redirected to login');
}
${stripMarkAsReadPrefix.toString()}
${stripAnchorChrome.toString()}
${parseNotifQuery.toString()}
${extractNotificationRowsFromDoc.toString()}
// Wait briefly for [role="listitem"] rows to render in case the SPA
// is still hydrating. 8s ceiling keeps a slow network from hanging
// the command — empty list after that surfaces as EmptyResultError
// on the Node side.
const start = Date.now();
while (document.querySelectorAll('[role="listitem"]').length === 0 && Date.now() - start < 8000) {
await new Promise(function(r) { setTimeout(r, 200); });
}
return extractNotificationRowsFromDoc(document, ${JSON.stringify(limit)}, {
stripMark: stripMarkAsReadPrefix,
stripChrome: stripAnchorChrome,
parseQuery: parseNotifQuery,
fbHost: FB_HOST,
markPrefixes: MARK_AS_READ_PREFIXES,
unreadBadges: UNREAD_BADGE_LABELS,
});
})()
`;
}
async function getFacebookNotifications(page, args) {
const limit = normalizeNotificationsLimit(args.limit);
try {
await page.goto(`${FB_HOST}/notifications`, { waitUntil: 'load', settleMs: 3000 });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new CommandExecutionError(
`Failed to navigate to facebook notifications: ${message}`,
'facebook.com may be unreachable',
);
}
let rows;
try {
rows = await page.evaluate(buildNotificationsScript(limit));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (/AUTH_REQUIRED/i.test(message)) {
throw new AuthRequiredError(
'facebook.com',
'Open Chrome and log in to Facebook before retrying',
);
}
throw new CommandExecutionError(
`Failed to read facebook notifications: ${message}`,
'facebook.com page may not have rendered or markup may have changed',
);
}
if (!Array.isArray(rows) || rows.length === 0) {
throw new EmptyResultError(
'facebook/notifications',
'No notifications found — login session may have expired or you have no recent notifications',
);
}
return rows;
}
export const notificationsCommand = cli({
site: 'facebook',
name: 'notifications',
access: 'read',
description: 'Get recent Facebook notifications (含 unread / time / url / notif_id / notif_type 列)',
domain: 'www.facebook.com',
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{
name: 'limit',
type: 'int',
default: NOTIFICATIONS_LIMIT_DEFAULT,
help: `Number of notifications (1-${NOTIFICATIONS_LIMIT_MAX})`,
},
],
columns: ['index', 'unread', 'text', 'time', 'url', 'notif_id', 'notif_type'],
func: getFacebookNotifications,
});
export const __test__ = {
buildNotificationsScript,
};