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
129 lines
6.3 KiB
JavaScript
129 lines
6.3 KiB
JavaScript
import { AuthRequiredError, EmptyResultError, selectorError } from '@jackwener/opencli/errors';
|
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
/**
|
|
* band mentions — Show Band notifications where you were @mentioned.
|
|
*
|
|
* Band.us signs every API request with a per-request HMAC (`md` header) generated
|
|
* by its own JavaScript, so we cannot replicate it externally. Instead we use
|
|
* Strategy.INTERCEPT: install an XHR interceptor, open the notification panel by
|
|
* clicking the bell to trigger the get_news XHR call, then apply client-side
|
|
* filtering to extract notifications matching the requested filter/unread options.
|
|
*/
|
|
cli({
|
|
site: 'band',
|
|
name: 'mentions',
|
|
access: 'read',
|
|
description: 'Show Band notifications where you are @mentioned',
|
|
domain: 'www.band.us',
|
|
strategy: Strategy.INTERCEPT,
|
|
browser: true,
|
|
args: [
|
|
{
|
|
name: 'filter',
|
|
default: 'mentioned',
|
|
choices: ['mentioned', 'all', 'post', 'comment'],
|
|
help: 'Filter: mentioned (default) | all | post | comment',
|
|
},
|
|
{ name: 'limit', type: 'int', default: 20, help: 'Max results' },
|
|
{ name: 'unread', type: 'bool', default: false, help: 'Show only unread notifications' },
|
|
],
|
|
columns: ['time', 'band', 'type', 'from', 'text', 'url'],
|
|
func: async (page, kwargs) => {
|
|
const filter = kwargs.filter;
|
|
const limit = kwargs.limit;
|
|
const unreadOnly = kwargs.unread;
|
|
// Navigate with a timestamp param to force a fresh page load each run.
|
|
// Without this, same-URL navigation may skip the reload (preserving the JS context
|
|
// and leaving the notification panel open from a previous run).
|
|
await page.goto(`https://www.band.us/?_=${Date.now()}`);
|
|
const cookies = await page.getCookies({ domain: 'band.us' });
|
|
const isLoggedIn = cookies.some(c => c.name === 'band_session');
|
|
if (!isLoggedIn)
|
|
throw new AuthRequiredError('band.us', 'Not logged in to Band');
|
|
// Install XHR interceptor before any clicks so all get_news responses are captured.
|
|
await page.installInterceptor('get_news');
|
|
// Wait for the bell button to appear (React hydration) instead of a fixed sleep.
|
|
let bellReady = false;
|
|
for (let i = 0; i < 20; i++) {
|
|
const exists = await page.evaluate(`() => !!document.querySelector('button._btnWidgetIcon')`);
|
|
if (exists) {
|
|
bellReady = true;
|
|
break;
|
|
}
|
|
await page.wait(0.5);
|
|
}
|
|
if (!bellReady) {
|
|
throw selectorError('button._btnWidgetIcon', 'Notification bell not found. The Band.us UI may have changed.');
|
|
}
|
|
// Poll until a capture containing result_data.news arrives, up to maxSecs seconds.
|
|
// getInterceptedRequests() clears the array on each call, so captures are accumulated
|
|
// locally. The interceptor pattern 'get_news' also matches 'get_news_count' responses
|
|
// which don't have result_data.news — keep polling until the real news response arrives.
|
|
const waitForOneCapture = async (maxSecs = 8) => {
|
|
const captures = [];
|
|
for (let i = 0; i < maxSecs * 2; i++) {
|
|
await page.wait(0.5); // 0.5 seconds per iteration (page.wait takes seconds)
|
|
const reqs = await page.getInterceptedRequests();
|
|
if (reqs.length > 0) {
|
|
captures.push(...reqs);
|
|
if (captures.some((r) => Array.isArray(r?.result_data?.news)))
|
|
return captures;
|
|
}
|
|
}
|
|
return captures;
|
|
};
|
|
// Click the bell. Guard against the element disappearing between the readiness
|
|
// check and the click (e.g. due to a React re-render) to surface a clear error.
|
|
const bellClicked = await page.evaluate(`() => {
|
|
const el = document.querySelector('button._btnWidgetIcon');
|
|
if (!el) return false;
|
|
el.click();
|
|
return true;
|
|
}`);
|
|
if (!bellClicked) {
|
|
throw selectorError('button._btnWidgetIcon', 'Notification bell disappeared before click. The Band.us UI may have changed.');
|
|
}
|
|
const requests = await waitForOneCapture();
|
|
// Find the get_news response (has result_data.news); get_news_count responses do not.
|
|
const newsReq = requests.find((r) => Array.isArray(r?.result_data?.news));
|
|
if (!newsReq) {
|
|
throw new EmptyResultError('band mentions', 'Failed to capture get_news response from Band.us. Try running the command again.');
|
|
}
|
|
let items = newsReq.result_data.news ?? [];
|
|
if (items.length === 0) {
|
|
throw new EmptyResultError('band mentions', 'No notifications found');
|
|
}
|
|
// Apply filters client-side from the full notification list.
|
|
if (unreadOnly) {
|
|
items = items.filter((n) => n.is_new === true);
|
|
}
|
|
if (filter === 'mentioned') {
|
|
// 'filters' is Band's server-side tag array; 'referred' means you were @mentioned.
|
|
items = items.filter((n) => n.filters?.includes('referred'));
|
|
}
|
|
else if (filter === 'post') {
|
|
items = items.filter((n) => n.category === 'post');
|
|
}
|
|
else if (filter === 'comment') {
|
|
items = items.filter((n) => n.category === 'comment');
|
|
}
|
|
// Band markup tags (<band:mention uid="...">, <band:sticker>, etc.) appear in
|
|
// notification text; strip them to get plain readable content.
|
|
const stripBandTags = (s) => s.replace(/<\/?band:[^>]+>/g, '');
|
|
return items.slice(0, limit).map((n) => {
|
|
const ts = n.created_at ? new Date(n.created_at) : null;
|
|
return {
|
|
time: ts
|
|
? ts.toLocaleString('ja-JP', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
|
: '',
|
|
band: n.band?.name ?? '',
|
|
// 'filters' is Band's server-side tag array; 'referred' means you were @mentioned.
|
|
type: n.filters?.includes('referred') ? '@mention' : n.category ?? '',
|
|
from: n.actor?.name ?? '',
|
|
text: stripBandTags(n.subtext ?? '').slice(0, 100),
|
|
url: n.action?.pc ?? '',
|
|
};
|
|
});
|
|
},
|
|
});
|