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
334 lines
13 KiB
JavaScript
334 lines
13 KiB
JavaScript
// Facebook notifications — pulls the rendered notification feed from
|
|
// `www.facebook.com/notifications` via in-page DOM walk.
|
|
//
|
|
// Replaces the legacy `pipeline:[]` form. The previous adapter had four
|
|
// overlapping silent failures the new shape resolves:
|
|
// 1. silent-bad-shape — `text.substring(0, 150)` truncated long
|
|
// notification bodies without telling the caller.
|
|
// 2. silent-bad-shape — the old time field used a dash sentinel for
|
|
// unknown values; typed unknown should be `null` so an agent caller
|
|
// does not have to learn in-band sentinel strings.
|
|
// 3. silent-column-drop — the IIFE saw the `<div>未读</div>` /
|
|
// `<div>Unread</div>` badge child and the anchor's `<a href>` URL
|
|
// but kept neither, so callers got a bare text blob with no way
|
|
// to follow up on a notification.
|
|
// 4. silent-empty-row — empty `[role="listitem"]` (login expired,
|
|
// empty inbox, FB redirected to `/login`) returned `[]` instead
|
|
// of throwing a typed error.
|
|
//
|
|
// New behavior:
|
|
// - `func` form + `Strategy.COOKIE` + `browser:true`.
|
|
// - Upfront `--limit` validation: positive integer in [1, 100], no
|
|
// silent clamp; `ArgumentError` on bad input.
|
|
// - Pure extractor `extractNotificationRowsFromDoc` is a Node-side
|
|
// export — JSDOM-against-frozen-fixture tests call it directly while
|
|
// the live IIFE embeds it via `${fn.toString()}` (mirrors hupu
|
|
// #1387 / xiaoe #1388 / dianping #1313 pattern). Helpers
|
|
// `stripMarkAsReadPrefix`, `stripAnchorChrome`, `parseNotifQuery`
|
|
// are also pure exports for the same reason.
|
|
// - Seven columns instead of three:
|
|
// index, unread (bool), text (full, no truncation),
|
|
// time (string|null), url (string), notif_id (string|null),
|
|
// notif_type (string|null)
|
|
// - Auth detection: if the IIFE runs while window.location is on a
|
|
// login / checkpoint path, throw `AUTH_REQUIRED:` sentinel which
|
|
// the Node side maps to `AuthRequiredError`.
|
|
// - Empty list (after settle + auth check passes) → `EmptyResultError`,
|
|
// never silent `[]`.
|
|
|
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
import {
|
|
ArgumentError,
|
|
AuthRequiredError,
|
|
CommandExecutionError,
|
|
EmptyResultError,
|
|
} from '@jackwener/opencli/errors';
|
|
|
|
export const FB_HOST = 'https://www.facebook.com';
|
|
export const NOTIFICATIONS_LIMIT_DEFAULT = 15;
|
|
export const NOTIFICATIONS_LIMIT_MAX = 100;
|
|
|
|
// Locale-specific "Mark as read" button prefixes Facebook attaches to
|
|
// the per-row mark-as-read action. We use this to recover the bare
|
|
// notification body text without the leading "未读" / "Unread" badge or
|
|
// the trailing time-ago suffix that the anchor's textContent contains.
|
|
//
|
|
// Each entry must be a complete, deterministic prefix — no regex — so a
|
|
// rare false-positive substring match elsewhere on the page does not
|
|
// silently truncate body text.
|
|
export const MARK_AS_READ_PREFIXES = [
|
|
'标记为已读,',
|
|
'Mark as read, ',
|
|
'Mark as Read, ',
|
|
'Marquer comme lu, ',
|
|
'Marcar como leído, ',
|
|
'既読にする, ',
|
|
];
|
|
|
|
// Localised "unread" badge labels that appear inside a `<div>` 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
|
|
// `<div role="button">` 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 `<abbr>` (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 `<a href>` 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 `<div>未读</div>` /
|
|
// `<div>Unread</div>` 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,
|
|
};
|