diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 5b4a58cd..de9f6697 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -68,6 +68,7 @@ export default defineConfig({ { text: 'Jimeng', link: '/adapters/browser/jimeng' }, { text: 'Yollomi', link: '/adapters/browser/yollomi' }, { text: 'LINUX DO', link: '/adapters/browser/linux-do' }, + { text: 'Band', link: '/adapters/browser/band' }, { text: 'Chaoxing', link: '/adapters/browser/chaoxing' }, { text: 'Grok', link: '/adapters/browser/grok' }, { text: 'WeRead', link: '/adapters/browser/weread' }, diff --git a/docs/adapters/browser/band.md b/docs/adapters/browser/band.md new file mode 100644 index 00000000..6581dce6 --- /dev/null +++ b/docs/adapters/browser/band.md @@ -0,0 +1,63 @@ +# Band + +**Mode**: πŸ” Browser Β· **Domain**: `www.band.us` + +Read posts, comments, and notifications from [Band](https://www.band.us), a private community platform. Authentication uses your logged-in Chrome session (cookie-based). + +## Commands + +| Command | Description | +|---------|-------------| +| `opencli band bands` | List all Bands you belong to | +| `opencli band posts ` | List posts from a Band | +| `opencli band post ` | Export full post content including nested comments | +| `opencli band mentions` | Show notifications where you were @mentioned | + +## Usage Examples + +```bash +# List all your bands (get band_no from here) +opencli band bands + +# List recent posts in a band +opencli band posts 12345678 --limit 10 + +# Export a post with comments +opencli band post 12345678 987654321 + +# Export post body only (skip comments) +opencli band post 12345678 987654321 --comments false + +# Export post and download attached photos +opencli band post 12345678 987654321 --output ./band-photos + +# Show recent @mention notifications +opencli band mentions --limit 20 + +# Show only unread mentions +opencli band mentions --unread true + +# Show all notification types +opencli band mentions --filter all +``` + +### `band mentions` filter options + +| Filter | Description | +|--------|-------------| +| `mentioned` | Only notifications where you were @mentioned (default) | +| `all` | All notifications | +| `post` | Post-related notifications | +| `comment` | Comment-related notifications | + +## Prerequisites + +- Chrome running and **logged into** [band.us](https://www.band.us) +- [Browser Bridge extension](/guide/browser-bridge) installed + +## Notes + +- `band_no` is the numeric ID in the Band URL: `band.us/band/{band_no}/post` +- `band bands` lists all your bands with their `band_no` values +- `band post` output rows: `type=post` (the post itself), `type=comment` (top-level comment), `type=reply` (nested reply) +- Photo downloads use the full-resolution URL (thumbnail query params are stripped automatically) diff --git a/src/clis/band/bands.ts b/src/clis/band/bands.ts new file mode 100644 index 00000000..37d3a5d5 --- /dev/null +++ b/src/clis/band/bands.ts @@ -0,0 +1,76 @@ +import { AuthRequiredError, EmptyResultError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; + +/** + * band bands β€” List all Bands you belong to. + * + * Band.us renders the full band list in the left sidebar of the home page for + * logged-in users, so we can extract everything we need from the DOM without + * XHR interception or any secondary navigation. + * + * Each sidebar item is an link whose text and + * data attributes carry the band name and member count. + */ +cli({ + site: 'band', + name: 'bands', + description: 'List all Bands you belong to', + domain: 'www.band.us', + strategy: Strategy.COOKIE, + browser: true, + args: [], + columns: ['band_no', 'name', 'members'], + + func: async (page, _kwargs) => { + 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'); + + // Extract the band list from the sidebar. Poll until at least one band card + // appears (React hydration may take a moment after navigation). + // Sidebar band cards use class "bandCover _link" with hrefs like /band/{id}/post. + const bands: { band_no: number; name: string; members: number }[] = await page.evaluate(` + (async () => { + const sleep = ms => new Promise(r => setTimeout(r, ms)); + + // Wait up to 9 s for sidebar band cards to render. + for (let i = 0; i < 30; i++) { + if (document.querySelector('a.bandCover._link')) break; + await sleep(300); + } + + const norm = s => (s || '').replace(/\\s+/g, ' ').trim(); + const seen = new Set(); + const results = []; + + for (const a of Array.from(document.querySelectorAll('a.bandCover._link'))) { + // Extract band_no from href: /band/{id} or /band/{id}/post only. + const m = (a.getAttribute('href') || '').match(/^\\/band\\/(\\d+)(?:\\/post)?\\/?$/); + if (!m) continue; + const bandNo = Number(m[1]); + if (seen.has(bandNo)) continue; + seen.add(bandNo); + + // Band name lives in p.uriText inside div.bandName. + const nameEl = a.querySelector('p.uriText'); + const name = nameEl ? norm(nameEl.textContent) : ''; + if (!name) continue; + + // Member count is the inside span.member. + const memberEl = a.querySelector('span.member em'); + const members = memberEl ? parseInt((memberEl.textContent || '').replace(/[^0-9]/g, ''), 10) || 0 : 0; + + results.push({ band_no: bandNo, name, members }); + } + + return results; + })() + `); + + if (!bands || bands.length === 0) { + throw new EmptyResultError('band bands', 'No bands found in sidebar β€” are you logged in?'); + } + + return bands; + }, +}); diff --git a/src/clis/band/mentions.ts b/src/clis/band/mentions.ts new file mode 100644 index 00000000..be9b92b2 --- /dev/null +++ b/src/clis/band/mentions.ts @@ -0,0 +1,134 @@ +import { AuthRequiredError, EmptyResultError, SelectorError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; + +/** + * 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', + 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 as string; + const limit = kwargs.limit as number; + const unreadOnly = kwargs.unread as boolean; + + // 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 new 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): Promise => { + const captures: any[] = []; + 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: any) => 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 new 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: any) => Array.isArray(r?.result_data?.news)) as any; + if (!newsReq) { + throw new EmptyResultError('band mentions', 'Failed to capture get_news response from Band.us. Try running the command again.'); + } + let items: any[] = 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: any) => n.is_new === true); + } + if (filter === 'mentioned') { + // 'filters' is Band's server-side tag array; 'referred' means you were @mentioned. + items = items.filter((n: any) => n.filters?.includes('referred')); + } else if (filter === 'post') { + items = items.filter((n: any) => n.category === 'post'); + } else if (filter === 'comment') { + items = items.filter((n: any) => n.category === 'comment'); + } + + // Band markup tags (, , etc.) appear in + // notification text; strip them to get plain readable content. + const stripBandTags = (s: string) => s.replace(/<\/?band:[^>]+>/g, ''); + + return items.slice(0, limit).map((n: any) => { + 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 ?? '', + }; + }); + }, +}); diff --git a/src/clis/band/post.ts b/src/clis/band/post.ts new file mode 100644 index 00000000..8cd47e59 --- /dev/null +++ b/src/clis/band/post.ts @@ -0,0 +1,187 @@ +import { AuthRequiredError, EmptyResultError } from '../../errors.js'; +import { formatCookieHeader } from '../../download/index.js'; +import { downloadMedia } from '../../download/media-download.js'; +import { cli, Strategy } from '../../registry.js'; + +/** + * band post β€” Export full content of a Band post: body, comments, and optional photo download. + * + * Navigates directly to the post URL and extracts everything from the DOM. + * No XHR interception needed β€” Band renders the full post for logged-in users. + * + * Output rows: + * type=post β†’ the post itself (author, date, body text) + * type=comment β†’ top-level comment + * type=reply β†’ reply to a comment (nested under its parent) + * + * Photo thumbnail URLs carry a ?type=sNNN suffix; stripping it yields full-res. + */ +cli({ + site: 'band', + name: 'post', + description: 'Export full content of a post including comments', + domain: 'www.band.us', + strategy: Strategy.COOKIE, + navigateBefore: false, + browser: true, + args: [ + { name: 'band_no', positional: true, required: true, type: 'int', help: 'Band number' }, + { name: 'post_no', positional: true, required: true, type: 'int', help: 'Post number' }, + { name: 'output', type: 'str', default: '', help: 'Directory to save attached photos' }, + { name: 'comments', type: 'bool', default: true, help: 'Include comments (default: true)' }, + ], + columns: ['type', 'author', 'date', 'text'], + + func: async (page, kwargs) => { + const bandNo = Number(kwargs.band_no); + const postNo = Number(kwargs.post_no); + const outputDir = kwargs.output as string; + const withComments = kwargs.comments as boolean; + + await page.goto(`https://www.band.us/band/${bandNo}/post/${postNo}`); + + 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'); + + const data: { + author: string; + date: string; + text: string; + photos: string[]; + comments: { depth: number; author: string; date: string; text: string }[]; + } = await page.evaluate(` + (async () => { + const withComments = ${withComments}; + const sleep = ms => new Promise(r => setTimeout(r, ms)); + const norm = s => (s || '').replace(/\\s+/g, ' ').trim(); + // Band embeds , , etc. in content β€” strip to plain text. + const stripTags = s => s.replace(/<\\/?band:[^>]+>/g, ''); + + // Wait up to 9 s for the post content to render (poll for the author link, + // which appears after React hydration fills the post header). + for (let i = 0; i < 30; i++) { + if (document.querySelector('._postWrapper a.text')) break; + await sleep(300); + } + + const postCard = document.querySelector('._postWrapper'); + const commentSection = postCard?.querySelector('.dPostCommentMainView'); + + // Author and date live in the post header, above the comment section. + // Exclude any matches inside the comment section to avoid picking up comment authors. + let author = '', date = ''; + for (const el of (postCard?.querySelectorAll('a.text') || [])) { + if (!commentSection?.contains(el)) { author = norm(el.textContent); break; } + } + for (const el of (postCard?.querySelectorAll('time.time') || [])) { + if (!commentSection?.contains(el)) { date = norm(el.textContent); break; } + } + + const bodyEl = postCard?.querySelector('.postText._postText'); + const text = bodyEl ? stripTags(norm(bodyEl.innerText || bodyEl.textContent)) : ''; + + // Photo thumbnails have a ?type=sNNN query param; strip it for full-res URL. + // Use location.href as base so protocol-relative or relative URLs resolve correctly. + const photos = Array.from(postCard?.querySelectorAll('img._imgRecentPhoto, img._imgPhoto') || []) + .map(img => { + const src = img.getAttribute('src') || ''; + if (!src) return ''; + try { const u = new URL(src, location.href); return u.origin + u.pathname; } + catch { return ''; } + }) + .filter(Boolean); + + if (!withComments) return { author, date, text, photos, comments: [] }; + + // Wait up to 6 s for the comment list container to render. + // Wait for the container itself (not .cComment) so posts with zero comments + // don't incur a fixed 6s delay waiting for an element that never appears. + for (let i = 0; i < 20; i++) { + if (postCard?.querySelector('.sCommentList._heightDetectAreaForComment')) break; + await sleep(300); + } + + // Recursively collect comments and their replies. + // Replies live in .sReplyList > .sCommentList, not in ._replyRegion. + function extractComments(container, depth) { + const results = []; + for (const el of container.querySelectorAll(':scope > .cComment')) { + results.push({ + depth, + author: norm(el.querySelector('strong.name')?.textContent), + date: norm(el.querySelector('time.time')?.textContent), + text: stripTags(norm(el.querySelector('p.txt._commentContent')?.innerText || '')), + }); + const replyList = el.querySelector('.sReplyList .sCommentList._heightDetectAreaForComment'); + if (replyList) results.push(...extractComments(replyList, depth + 1)); + } + return results; + } + + const commentList = postCard?.querySelector('.sCommentList._heightDetectAreaForComment'); + const comments = commentList ? extractComments(commentList, 0) : []; + + return { author, date, text, photos, comments }; + })() + `); + + if (!data?.text && !data?.comments?.length && !data?.photos?.length) { + throw new EmptyResultError('band post', 'Post not found or not accessible'); + } + + const photos: string[] = data.photos ?? []; + + // Download photos when --output is specified, using the shared downloadMedia utility + // which handles redirects, timeouts, and stream errors correctly. + // Pass browser cookies so Band's login-protected photo URLs don't fail with 401/403. + if (outputDir && photos.length > 0) { + // Only send Band cookies to Band-hosted URLs; avoid leaking auth cookies to third-party CDNs. + // Use a global index across both batches so filenames don't collide (photo_1, photo_2, ...). + const cookieHeader = formatCookieHeader(await page.getCookies({ url: 'https://www.band.us' })); + const isBandUrl = (u: string) => { try { const h = new URL(u).hostname; return h === 'band.us' || h.endsWith('.band.us'); } catch { return false; } }; + // Derive extension from URL path so downloaded files have correct extensions (e.g. photo_1.jpg). + const urlExt = (u: string) => { try { return new URL(u).pathname.match(/\.(\w+)$/)?.[1] ?? 'jpg'; } catch { return 'jpg'; } }; + let globalIndex = 1; + const bandPhotos = photos.filter(isBandUrl); + const otherPhotos = photos.filter(u => !isBandUrl(u)); + if (bandPhotos.length > 0) { + await downloadMedia( + bandPhotos.map(url => ({ type: 'image' as const, url, filename: `photo_${globalIndex++}.${urlExt(url)}` })), + { output: outputDir, verbose: false, cookies: cookieHeader }, + ); + } + if (otherPhotos.length > 0) { + await downloadMedia( + otherPhotos.map(url => ({ type: 'image' as const, url, filename: `photo_${globalIndex++}.${urlExt(url)}` })), + { output: outputDir, verbose: false }, + ); + } + } + + const rows: Record[] = []; + + // Post row β€” append photo URLs inline when not downloading to disk. + rows.push({ + type: 'post', + author: data.author ?? '', + date: data.date ?? '', + text: [ + data.text ?? '', + ...(outputDir ? [] : photos.map((u, i) => `[photo${i + 1}] ${u}`)), + ].filter(Boolean).join('\n'), + }); + + // Comment rows β€” depth=0 β†’ type 'comment', depthβ‰₯1 β†’ type 'reply'. + for (const c of data.comments ?? []) { + rows.push({ + type: c.depth === 0 ? 'comment' : 'reply', + author: c.author ?? '', + date: c.date ?? '', + text: c.depth > 0 ? ' '.repeat(c.depth) + 'β”” ' + (c.text ?? '') : (c.text ?? ''), + }); + } + + return rows; + }, +}); diff --git a/src/clis/band/posts.ts b/src/clis/band/posts.ts new file mode 100644 index 00000000..db828ca3 --- /dev/null +++ b/src/clis/band/posts.ts @@ -0,0 +1,106 @@ +import { AuthRequiredError, EmptyResultError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; + +/** + * band posts β€” List posts from a specific Band. + * + * Band.us renders the post list in the DOM for logged-in users, so we navigate + * directly to the band's post page and extract everything from the DOM β€” no XHR + * interception or home-page detour required. + */ +cli({ + site: 'band', + name: 'posts', + description: 'List posts from a Band', + domain: 'www.band.us', + strategy: Strategy.COOKIE, + navigateBefore: false, + browser: true, + args: [ + { + name: 'band_no', + positional: true, + required: true, + type: 'int', + help: 'Band number (get it from: band bands)', + }, + { name: 'limit', type: 'int', default: 20, help: 'Max results' }, + ], + columns: ['date', 'author', 'content', 'comments', 'url'], + + func: async (page, kwargs) => { + const bandNo = Number(kwargs.band_no); + const limit = Number(kwargs.limit); + + // Navigate directly to the band's post page β€” no home-page detour needed. + await page.goto(`https://www.band.us/band/${bandNo}/post`); + + 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'); + + // Extract post list from the DOM. Poll until post items appear (React hydration). + const posts: { + date: string; + author: string; + content: string; + comments: number; + url: string; + }[] = await page.evaluate(` + (async () => { + const sleep = ms => new Promise(r => setTimeout(r, ms)); + const norm = s => (s || '').replace(/\\s+/g, ' ').trim(); + const limit = ${limit}; + + // Wait up to 9 s for post items to render. + for (let i = 0; i < 30; i++) { + if (document.querySelector('article.cContentsCard._postMainWrap')) break; + await sleep(300); + } + + // Band embeds custom , , etc. tags in content. + const stripTags = s => s.replace(/<\\/?band:[^>]+>/g, ''); + + const results = []; + const postEls = Array.from( + document.querySelectorAll('article.cContentsCard._postMainWrap') + ); + + for (const el of postEls) { + // URL: first post permalink link (absolute or relative). + const linkEl = el.querySelector('a[href*="/post/"]'); + const href = linkEl?.getAttribute('href') || ''; + if (!href) continue; + const url = href.startsWith('http') ? href : 'https://www.band.us' + href; + + // Author name β€” a.text in the post header area. + const author = norm(el.querySelector('a.text')?.textContent); + + // Date / timestamp. + const date = norm(el.querySelector('time')?.textContent); + + // Post body text (strip Band markup tags, truncate for listing). + const bodyEl = el.querySelector('.postText._postText'); + const content = bodyEl + ? stripTags(norm(bodyEl.innerText || bodyEl.textContent)).slice(0, 120) + : ''; + + // Comment count is in span.count inside the count area. + const commentEl = el.querySelector('span.count'); + const comments = commentEl ? parseInt((commentEl.textContent || '').replace(/[^0-9]/g, ''), 10) || 0 : 0; + + if (results.length >= limit) break; + results.push({ date, author, content, comments, url }); + } + + return results; + })() + `); + + if (!posts || posts.length === 0) { + throw new EmptyResultError('band posts', 'No posts found in this Band'); + } + + return posts; + }, +}); diff --git a/tests/e2e/browser-auth.test.ts b/tests/e2e/browser-auth.test.ts index bcf9bd70..e1d2e898 100644 --- a/tests/e2e/browser-auth.test.ts +++ b/tests/e2e/browser-auth.test.ts @@ -150,4 +150,21 @@ describe('login-required commands β€” graceful failure', () => { it('yollomi video fails gracefully without login', async () => { await expectGracefulAuthFailure(['yollomi', 'video', 'a sunset over the ocean', '--no-download', '-f', 'json'], 'yollomi video'); }, 60_000); + + // ── band (requires band.us login session) ── + it('band bands fails gracefully without login', async () => { + await expectGracefulAuthFailure(['band', 'bands', '-f', 'json'], 'band bands'); + }, 60_000); + + it('band mentions fails gracefully without login', async () => { + await expectGracefulAuthFailure(['band', 'mentions', '--limit', '3', '-f', 'json'], 'band mentions'); + }, 60_000); + + it('band posts fails gracefully without login', async () => { + await expectGracefulAuthFailure(['band', 'posts', '58400480', '--limit', '3', '-f', 'json'], 'band posts'); + }, 60_000); + + it('band post fails gracefully without login', async () => { + await expectGracefulAuthFailure(['band', 'post', '58400480', '1', '-f', 'json'], 'band post'); + }, 60_000); });