From b190fe572adacf6b7b29282678380fb45f09f0b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=AB=E3=83=B3?= Date: Fri, 27 Mar 2026 12:36:28 +0900 Subject: [PATCH 01/22] feat(band): add bands, posts, and mentions commands for band.us MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bands: lists all Bands via get_band_list_with_filter intercept - posts: lists posts from a Band via get_posts_and_announcements intercept - mentions: shows @mention notifications via get_news intercept All use Strategy.INTERCEPT since band.us API requires an HMAC md header generated by its own JS. SPA navigation to /band/{no}/post triggers the band list and posts APIs; bell + @メンション tab click triggers mentions. Co-Authored-By: Claude Sonnet 4.6 --- src/clis/band/bands.ts | 71 +++++++++++++++++++ src/clis/band/mentions.ts | 120 +++++++++++++++++++++++++++++++++ src/clis/band/posts.ts | 89 ++++++++++++++++++++++++ tests/e2e/browser-auth.test.ts | 13 ++++ 4 files changed, 293 insertions(+) create mode 100644 src/clis/band/bands.ts create mode 100644 src/clis/band/mentions.ts create mode 100644 src/clis/band/posts.ts diff --git a/src/clis/band/bands.ts b/src/clis/band/bands.ts new file mode 100644 index 00000000..2d0c5151 --- /dev/null +++ b/src/clis/band/bands.ts @@ -0,0 +1,71 @@ +import { AuthRequiredError, EmptyResultError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; + +/** + * band bands — List all Bands you belong to. + * + * Uses the INTERCEPT strategy to capture the get_band_list_with_filter + * API response that Band.us automatically makes on the home page. + */ +cli({ + site: 'band', + name: 'bands', + description: 'List all Bands you belong to', + domain: 'www.band.us', + strategy: Strategy.INTERCEPT, + browser: true, + args: [], + columns: ['band_no', 'name', 'members'], + + func: async (page, _kwargs) => { + // 1. Navigate to home, verify login, extract a band_no from sidebar links + await page.goto('https://www.band.us/'); + await page.wait(1); + + const isLoggedIn = await page.evaluate(`() => document.cookie.includes('band_session')`); + if (!isLoggedIn) throw new AuthRequiredError('band.us', 'Not logged in to Band'); + + // Extract any band_no visible in the sidebar — needed for SPA navigation trigger + const bandNo = await page.evaluate(`() => { + const link = Array.from(document.querySelectorAll('a[href*="/band/"]')) + .map(a => a.getAttribute('href').match(/\\/band\\/(\\d+)/)) + .find(m => m); + return link ? link[1] : null; + }`); + if (!bandNo) throw new EmptyResultError('band bands', 'No band links found on page. Are you logged in?'); + const bandNoStr = String(bandNo); + + // 2. Install interceptor BEFORE SPA navigation. + // Navigating to a band page triggers get_band_list_with_filter automatically. + await page.installInterceptor('get_band_list_with_filter'); + + await page.evaluate(`() => { + window.history.pushState({}, '', '/band/${bandNoStr}/post'); + window.dispatchEvent(new PopStateEvent('popstate', { state: {} })); + }`); + await page.wait(4); + + const requests = await page.getInterceptedRequests(); + if (!requests || requests.length === 0) { + throw new EmptyResultError('band bands', 'No band list data captured. Try again.'); + } + + let bands: any[] = []; + for (const req of requests) { + const data = req?.result_data; + if (Array.isArray(data)) { + bands.push(...data.map((d: any) => d.band).filter(Boolean)); + } + } + + if (bands.length === 0) { + throw new EmptyResultError('band bands', 'No bands found'); + } + + return bands.map((b: any) => ({ + band_no: b.band_no, + name: b.name ?? '', + members: b.member_count ?? 0, + })); + }, +}); diff --git a/src/clis/band/mentions.ts b/src/clis/band/mentions.ts new file mode 100644 index 00000000..a3ec113a --- /dev/null +++ b/src/clis/band/mentions.ts @@ -0,0 +1,120 @@ +import { AuthRequiredError, EmptyResultError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; + +/** + * band mentions — Show Band notifications where you were @mentioned or assigned. + * + * Uses the INTERCEPT strategy: navigates to band.us home, installs a fetch + * interceptor, then clicks the notification bell to trigger the get_news API + * call (which Band makes with its own auth headers including the md signature). + */ +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 = String(kwargs.filter ?? 'mentioned'); + const limit = Number(kwargs.limit ?? 20); + const unreadOnly = Boolean(kwargs.unread); + + // 1. Navigate to Band home (ensures cookies are active) + await page.goto('https://www.band.us/'); + await page.wait(2); + + // Verify we're logged in + const isLoggedIn = await page.evaluate(`() => { + return document.cookie.includes('band_session'); + }`); + if (!isLoggedIn) throw new AuthRequiredError('band.us', 'Not logged in to Band'); + + // 2. Install interceptor BEFORE triggering any API calls + await page.installInterceptor('get_news'); + + // 3. Click the notification bell (opens panel, triggers get_news for all) + // The bell button has class '_btnWidgetIcon' and text content containing 'お知らせ'. + await page.evaluate(`() => { + const bell = Array.from(document.querySelectorAll('button._btnWidgetIcon')).find(b => + b.textContent && b.textContent.includes('お知らせ') + ); + if (bell) bell.click(); + }`); + await page.wait(2); + + // 4. For @mention filter: click the @メンション tab (triggers server-side filtered get_news). + // For unread: also click 未確認のみ表示 button. + if (filter === 'mentioned') { + await page.evaluate(`() => { + const tab = Array.from(document.querySelectorAll('button._btnFilter')).find(b => + b.textContent && b.textContent.includes('メンション') + ); + if (tab) tab.click(); + }`); + await page.wait(2); + + if (unreadOnly) { + await page.evaluate(`() => { + const btn = Array.from(document.querySelectorAll('button')).find(b => + b.textContent && b.textContent.includes('未確認のみ') + ); + if (btn) btn.click(); + }`); + await page.wait(2); + } + } + + // 5. Retrieve intercepted data — use the LAST captured response (most specific filter) + const requests = await page.getInterceptedRequests(); + if (!requests || requests.length === 0) { + throw new EmptyResultError('band mentions', 'No notification data captured. Try running the command again.'); + } + + const lastReq = requests[requests.length - 1] as any; + let items: any[] = Array.isArray(lastReq?.result_data?.news) ? lastReq.result_data.news : []; + + if (items.length === 0) { + throw new EmptyResultError('band mentions', 'No notifications found'); + } + + // 6. Client-side filters for non-mention modes + if (filter === 'all' && unreadOnly) { + items = items.filter((n: any) => n.is_new === true); + } else if (filter === 'post') { + items = items.filter((n: any) => n.category === 'post'); + } else if (filter === 'comment') { + items = items.filter((n: any) => n.category === 'comment'); + } + + return items.slice(0, limit).map((n: any) => { + const ts = n.created_at ? new Date(n.created_at) : null; + const timeStr = ts + ? ts.toLocaleString('ja-JP', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) + : ''; + const type = n.filters?.includes('referred') ? '@mention' : n.category ?? ''; + // Strip Band markup tags from text + const rawText = (n.subtext ?? '').replace(/]+>/g, '').replace(/<\/band:[^>]+>/g, ''); + return { + time: timeStr, + band: n.band?.name ?? '', + type, + from: n.actor?.name ?? '', + text: rawText.slice(0, 100), + url: n.action?.pc ?? '', + }; + }); + }, +}); diff --git a/src/clis/band/posts.ts b/src/clis/band/posts.ts new file mode 100644 index 00000000..d54b0bfd --- /dev/null +++ b/src/clis/band/posts.ts @@ -0,0 +1,89 @@ +import { AuthRequiredError, EmptyResultError, ArgumentError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; + +/** + * band posts — List posts from a specific Band. + * + * Uses the INTERCEPT strategy: navigates to band.us home, installs a fetch + * interceptor, then SPA-navigates to the target band's post page to capture + * the get_posts_and_announcements API response. + */ +cli({ + site: 'band', + name: 'posts', + description: 'List posts from a Band', + domain: 'www.band.us', + strategy: Strategy.INTERCEPT, + 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 ?? 20); + + if (!bandNo) throw new ArgumentError('band_no', 'Band number is required. Get it from: band bands'); + + // 1. Navigate to Band home first + await page.goto('https://www.band.us/'); + await page.wait(2); + + const isLoggedIn = await page.evaluate(`() => document.cookie.includes('band_session')`); + if (!isLoggedIn) throw new AuthRequiredError('band.us', 'Not logged in to Band'); + + // 2. Install interceptor BEFORE SPA navigation + await page.installInterceptor('get_posts_and_announcements'); + + // 3. SPA navigate to the band's post page + await page.evaluate(`() => { + window.history.pushState({}, '', '/band/${bandNo}/post'); + window.dispatchEvent(new PopStateEvent('popstate', { state: {} })); + }`); + await page.wait(4); + + const finalRequests = await page.getInterceptedRequests(); + + if (finalRequests.length === 0) { + throw new EmptyResultError('band posts', 'No post data captured'); + } + + // 4. Parse get_posts_and_announcements response + let items: any[] = []; + for (const req of finalRequests) { + const postItems = req?.result_data?.items; + if (Array.isArray(postItems)) { + items.push(...postItems); + } + } + + if (items.length === 0) { + throw new EmptyResultError('band posts', 'No posts found in this Band'); + } + + return items.slice(0, limit).map((item: any) => { + const post = item.post ?? item; + const ts = post.created_at ? new Date(post.created_at) : null; + const dateStr = ts + ? ts.toLocaleString('ja-JP', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) + : ''; + // Strip Band markup tags + const rawContent = (post.content ?? '').replace(/]+>[^<]*<\/band:[^>]+>/g, '').trim(); + return { + date: dateStr, + author: post.author?.name ?? '', + content: rawContent.slice(0, 120), + comments: post.comment_count ?? 0, + url: post.web_url ?? `https://band.us/band/${bandNo}/post/${post.post_no}`, + }; + }); + }, +}); diff --git a/tests/e2e/browser-auth.test.ts b/tests/e2e/browser-auth.test.ts index bcf9bd70..37e3deab 100644 --- a/tests/e2e/browser-auth.test.ts +++ b/tests/e2e/browser-auth.test.ts @@ -150,4 +150,17 @@ 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); }); From 8e58f38b399fee0c46f22f8ed67a7f6b5bda92ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=AB=E3=83=B3?= Date: Fri, 27 Mar 2026 12:41:35 +0900 Subject: [PATCH 02/22] refactor(band): clean up all three band adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix doc comments: Band uses XHR not fetch; clarify INTERCEPT rationale - bands: replace for-loop with flatMap; explain why band page nav is needed - posts: remove item.post ?? item fallback (API always wraps in post); rename finalRequests → requests for consistency; extract stripBandTags helper - mentions: remove redundant ?? defaults (args have defaults defined); fix unreadOnly bug (was not applied to post/comment modes); consolidate Band tag stripping to single regex; cast kwargs types directly instead of converting; add comments explaining last-response strategy and 'referred' filter flag Co-Authored-By: Claude Sonnet 4.6 --- src/clis/band/bands.ts | 47 +++++++++++---------- src/clis/band/mentions.ts | 88 ++++++++++++++++++++------------------- src/clis/band/posts.ts | 54 ++++++++++++------------ 3 files changed, 96 insertions(+), 93 deletions(-) diff --git a/src/clis/band/bands.ts b/src/clis/band/bands.ts index 2d0c5151..ad2cfa78 100644 --- a/src/clis/band/bands.ts +++ b/src/clis/band/bands.ts @@ -4,8 +4,11 @@ import { cli, Strategy } from '../../registry.js'; /** * band bands — List all Bands you belong to. * - * Uses the INTERCEPT strategy to capture the get_band_list_with_filter - * API response that Band.us automatically makes on the home page. + * 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 in the page, then trigger Band's + * own React app to fire the request by SPA-navigating to a band page — which always + * causes Band to call get_band_list_with_filter to re-populate the sidebar. */ cli({ site: 'band', @@ -18,45 +21,43 @@ cli({ columns: ['band_no', 'name', 'members'], func: async (page, _kwargs) => { - // 1. Navigate to home, verify login, extract a band_no from sidebar links await page.goto('https://www.band.us/'); - await page.wait(1); + await page.wait(2); // wait for React hydration and cookie settlement const isLoggedIn = await page.evaluate(`() => document.cookie.includes('band_session')`); if (!isLoggedIn) throw new AuthRequiredError('band.us', 'Not logged in to Band'); - // Extract any band_no visible in the sidebar — needed for SPA navigation trigger + // Extract any band_no from sidebar links. We need it to SPA-navigate to a band + // page, which is the only route that reliably re-triggers get_band_list_with_filter + // (direct / and /discover routes serve from React cache after first load). const bandNo = await page.evaluate(`() => { - const link = Array.from(document.querySelectorAll('a[href*="/band/"]')) + const m = Array.from(document.querySelectorAll('a[href*="/band/"]')) .map(a => a.getAttribute('href').match(/\\/band\\/(\\d+)/)) - .find(m => m); - return link ? link[1] : null; + .find(Boolean); + return m ? m[1] : null; }`); - if (!bandNo) throw new EmptyResultError('band bands', 'No band links found on page. Are you logged in?'); - const bandNoStr = String(bandNo); + if (!bandNo) throw new EmptyResultError('band bands', 'No band links found — are you logged in?'); - // 2. Install interceptor BEFORE SPA navigation. - // Navigating to a band page triggers get_band_list_with_filter automatically. + // Install XHR interceptor before triggering navigation so we don't miss the request. await page.installInterceptor('get_band_list_with_filter'); + // SPA navigation: history.pushState keeps the React app alive (no full reload), + // so the interceptor stays active. PopStateEvent signals React Router to re-render. await page.evaluate(`() => { - window.history.pushState({}, '', '/band/${bandNoStr}/post'); + window.history.pushState({}, '', '/band/${String(bandNo)}/post'); window.dispatchEvent(new PopStateEvent('popstate', { state: {} })); }`); - await page.wait(4); + await page.wait(4); // allow time for the XHR round-trip to complete const requests = await page.getInterceptedRequests(); - if (!requests || requests.length === 0) { - throw new EmptyResultError('band bands', 'No band list data captured. Try again.'); + if (requests.length === 0) { + throw new EmptyResultError('band bands', 'No band list data captured — try again.'); } - let bands: any[] = []; - for (const req of requests) { - const data = req?.result_data; - if (Array.isArray(data)) { - bands.push(...data.map((d: any) => d.band).filter(Boolean)); - } - } + // result_data is an array of { band: { band_no, name, member_count, ... } } + const bands = (requests as any[]).flatMap(req => + Array.isArray(req?.result_data) ? req.result_data.map((d: any) => d.band).filter(Boolean) : [] + ); if (bands.length === 0) { throw new EmptyResultError('band bands', 'No bands found'); diff --git a/src/clis/band/mentions.ts b/src/clis/band/mentions.ts index a3ec113a..231224ac 100644 --- a/src/clis/band/mentions.ts +++ b/src/clis/band/mentions.ts @@ -2,11 +2,16 @@ import { AuthRequiredError, EmptyResultError } from '../../errors.js'; import { cli, Strategy } from '../../registry.js'; /** - * band mentions — Show Band notifications where you were @mentioned or assigned. + * band mentions — Show Band notifications where you were @mentioned. * - * Uses the INTERCEPT strategy: navigates to band.us home, installs a fetch - * interceptor, then clicks the notification bell to trigger the get_news API - * call (which Band makes with its own auth headers including the md signature). + * 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, then click the @メンション tab — which triggers a server-side + * filtered get_news call containing only notifications where you were mentioned. + * + * The tab-click approach is preferred over client-side filtering on the full + * notification list, because Band's server already paginates/filters correctly. */ cli({ site: 'band', @@ -28,61 +33,58 @@ cli({ columns: ['time', 'band', 'type', 'from', 'text', 'url'], func: async (page, kwargs) => { - const filter = String(kwargs.filter ?? 'mentioned'); - const limit = Number(kwargs.limit ?? 20); - const unreadOnly = Boolean(kwargs.unread); + const filter = kwargs.filter as string; + const limit = kwargs.limit as number; + const unreadOnly = kwargs.unread as boolean; - // 1. Navigate to Band home (ensures cookies are active) await page.goto('https://www.band.us/'); - await page.wait(2); + await page.wait(2); // wait for React hydration and cookie settlement - // Verify we're logged in - const isLoggedIn = await page.evaluate(`() => { - return document.cookie.includes('band_session'); - }`); + const isLoggedIn = await page.evaluate(`() => document.cookie.includes('band_session')`); if (!isLoggedIn) throw new AuthRequiredError('band.us', 'Not logged in to Band'); - // 2. Install interceptor BEFORE triggering any API calls + // Install XHR interceptor before any clicks so all get_news responses are captured. await page.installInterceptor('get_news'); - // 3. Click the notification bell (opens panel, triggers get_news for all) - // The bell button has class '_btnWidgetIcon' and text content containing 'お知らせ'. + // Click the notification bell to open the panel. This triggers an initial get_news + // call for all notification types. The bell button is identified by class and text + // since Band does not use aria-label on this element. await page.evaluate(`() => { - const bell = Array.from(document.querySelectorAll('button._btnWidgetIcon')).find(b => - b.textContent && b.textContent.includes('お知らせ') - ); + const bell = Array.from(document.querySelectorAll('button._btnWidgetIcon')) + .find(b => b.textContent && b.textContent.includes('お知らせ')); if (bell) bell.click(); }`); - await page.wait(2); + await page.wait(2); // wait for panel to render and initial get_news to complete - // 4. For @mention filter: click the @メンション tab (triggers server-side filtered get_news). - // For unread: also click 未確認のみ表示 button. if (filter === 'mentioned') { + // Click the @メンション tab to trigger a server-side filtered get_news call. + // This response contains only notifications with the 'referred' filter flag, + // which is more accurate than client-side filtering the full list. await page.evaluate(`() => { - const tab = Array.from(document.querySelectorAll('button._btnFilter')).find(b => - b.textContent && b.textContent.includes('メンション') - ); + const tab = Array.from(document.querySelectorAll('button._btnFilter')) + .find(b => b.textContent && b.textContent.includes('メンション')); if (tab) tab.click(); }`); await page.wait(2); if (unreadOnly) { + // 未確認のみ表示: triggers another server-side filtered get_news for unread mentions. await page.evaluate(`() => { - const btn = Array.from(document.querySelectorAll('button')).find(b => - b.textContent && b.textContent.includes('未確認のみ') - ); + const btn = Array.from(document.querySelectorAll('button')) + .find(b => b.textContent && b.textContent.includes('未確認のみ')); if (btn) btn.click(); }`); await page.wait(2); } } - // 5. Retrieve intercepted data — use the LAST captured response (most specific filter) const requests = await page.getInterceptedRequests(); - if (!requests || requests.length === 0) { + if (requests.length === 0) { throw new EmptyResultError('band mentions', 'No notification data captured. Try running the command again.'); } + // Use the last captured response: each UI action (bell → tab → unread toggle) + // triggers a progressively more specific get_news call, so the last one is correct. const lastReq = requests[requests.length - 1] as any; let items: any[] = Array.isArray(lastReq?.result_data?.news) ? lastReq.result_data.news : []; @@ -90,29 +92,31 @@ cli({ throw new EmptyResultError('band mentions', 'No notifications found'); } - // 6. Client-side filters for non-mention modes - if (filter === 'all' && unreadOnly) { + // Client-side filters for non-mention modes (server already filters 'mentioned'). + if (unreadOnly) { items = items.filter((n: any) => n.is_new === true); - } else if (filter === 'post') { + } + 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; - const timeStr = ts - ? ts.toLocaleString('ja-JP', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) - : ''; - const type = n.filters?.includes('referred') ? '@mention' : n.category ?? ''; - // Strip Band markup tags from text - const rawText = (n.subtext ?? '').replace(/]+>/g, '').replace(/<\/band:[^>]+>/g, ''); return { - time: timeStr, + time: ts + ? ts.toLocaleString('ja-JP', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) + : '', band: n.band?.name ?? '', - type, + // '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: rawText.slice(0, 100), + text: stripBandTags(n.subtext ?? '').slice(0, 100), url: n.action?.pc ?? '', }; }); diff --git a/src/clis/band/posts.ts b/src/clis/band/posts.ts index d54b0bfd..d532a7ca 100644 --- a/src/clis/band/posts.ts +++ b/src/clis/band/posts.ts @@ -4,9 +4,11 @@ import { cli, Strategy } from '../../registry.js'; /** * band posts — List posts from a specific Band. * - * Uses the INTERCEPT strategy: navigates to band.us home, installs a fetch - * interceptor, then SPA-navigates to the target band's post page to capture - * the get_posts_and_announcements API response. + * 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, then SPA-navigate to the target + * band's post page — which causes Band's React app to call get_posts_and_announcements + * with its own auth headers automatically. */ cli({ site: 'band', @@ -29,58 +31,54 @@ cli({ func: async (page, kwargs) => { const bandNo = Number(kwargs.band_no); - const limit = Number(kwargs.limit ?? 20); + const limit = Number(kwargs.limit); if (!bandNo) throw new ArgumentError('band_no', 'Band number is required. Get it from: band bands'); - // 1. Navigate to Band home first await page.goto('https://www.band.us/'); - await page.wait(2); + await page.wait(2); // wait for React hydration and cookie settlement const isLoggedIn = await page.evaluate(`() => document.cookie.includes('band_session')`); if (!isLoggedIn) throw new AuthRequiredError('band.us', 'Not logged in to Band'); - // 2. Install interceptor BEFORE SPA navigation + // Install XHR interceptor before triggering navigation so we don't miss the request. await page.installInterceptor('get_posts_and_announcements'); - // 3. SPA navigate to the band's post page + // SPA navigation: history.pushState keeps the React app alive (no full reload), + // so the interceptor stays active. PopStateEvent signals React Router to re-render. await page.evaluate(`() => { window.history.pushState({}, '', '/band/${bandNo}/post'); window.dispatchEvent(new PopStateEvent('popstate', { state: {} })); }`); - await page.wait(4); + await page.wait(4); // allow time for the XHR round-trip to complete - const finalRequests = await page.getInterceptedRequests(); - - if (finalRequests.length === 0) { + const requests = await page.getInterceptedRequests(); + if (requests.length === 0) { throw new EmptyResultError('band posts', 'No post data captured'); } - // 4. Parse get_posts_and_announcements response - let items: any[] = []; - for (const req of finalRequests) { - const postItems = req?.result_data?.items; - if (Array.isArray(postItems)) { - items.push(...postItems); - } - } + // result_data.items is an array of { post: { ... } } + const items = (requests as any[]).flatMap(req => + Array.isArray(req?.result_data?.items) ? req.result_data.items : [] + ); if (items.length === 0) { throw new EmptyResultError('band posts', 'No posts found in this Band'); } + // Band markup tags (, , etc.) appear in content; + // strip them to get plain text. + const stripBandTags = (s: string) => s.replace(/<\/?band:[^>]+>/g, '').trim(); + return items.slice(0, limit).map((item: any) => { - const post = item.post ?? item; + const post = item.post; const ts = post.created_at ? new Date(post.created_at) : null; - const dateStr = ts - ? ts.toLocaleString('ja-JP', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) - : ''; - // Strip Band markup tags - const rawContent = (post.content ?? '').replace(/]+>[^<]*<\/band:[^>]+>/g, '').trim(); return { - date: dateStr, + date: ts + ? ts.toLocaleString('ja-JP', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) + : '', author: post.author?.name ?? '', - content: rawContent.slice(0, 120), + content: stripBandTags(post.content ?? '').slice(0, 120), comments: post.comment_count ?? 0, url: post.web_url ?? `https://band.us/band/${bandNo}/post/${post.post_no}`, }; From 4a6a26002ab636c49f3cc0feceaf9e10c8ef4a43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=AB=E3=83=B3?= Date: Fri, 27 Mar 2026 12:48:36 +0900 Subject: [PATCH 03/22] fix(band/posts): handle mixed post/announcement items from API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_posts_and_announcements returns both regular posts and announcements that have different shapes — some lack post_no and wrap differently. Restore item.post ?? item fallback and filter out items with no resolvable identifier to prevent undefined in URLs and empty rows in output. Co-Authored-By: Claude Sonnet 4.6 --- src/clis/band/posts.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/clis/band/posts.ts b/src/clis/band/posts.ts index d532a7ca..5232f35f 100644 --- a/src/clis/band/posts.ts +++ b/src/clis/band/posts.ts @@ -57,10 +57,14 @@ cli({ throw new EmptyResultError('band posts', 'No post data captured'); } - // result_data.items is an array of { post: { ... } } + // result_data.items contains both posts and announcements. Filter to items that + // have a resolvable post object with at least a post_no or web_url to link to. const items = (requests as any[]).flatMap(req => Array.isArray(req?.result_data?.items) ? req.result_data.items : [] - ); + ).filter((item: any) => { + const post = item.post ?? item; + return post.post_no || post.web_url; + }); if (items.length === 0) { throw new EmptyResultError('band posts', 'No posts found in this Band'); @@ -71,7 +75,8 @@ cli({ const stripBandTags = (s: string) => s.replace(/<\/?band:[^>]+>/g, '').trim(); return items.slice(0, limit).map((item: any) => { - const post = item.post; + // Some bands wrap the post under item.post; others return the post object directly. + const post = item.post ?? item; const ts = post.created_at ? new Date(post.created_at) : null; return { date: ts From dd9b9896e9d428faa16d5f5309e1ca3ccab74918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=AB=E3=83=B3?= Date: Fri, 27 Mar 2026 13:42:38 +0900 Subject: [PATCH 04/22] =?UTF-8?q?feat(band):=20add=20post=20command=20?= =?UTF-8?q?=E2=80=94=20full=20post=20export=20with=20comments=20and=20phot?= =?UTF-8?q?o=20download?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exports the complete content of a single Band post: - Post body (with Band markup tags stripped) - All comments in chronological order - Photo URLs shown inline, or downloaded with --output Uses Strategy.INTERCEPT with a broad 'band.us' pattern to capture both the batch request (embedding get_post) and get_comments in one SPA navigation. Responses are identified client-side by shape: batch_result array vs items array with comment_id fields. Co-Authored-By: Claude Sonnet 4.6 --- src/clis/band/post.ts | 155 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 src/clis/band/post.ts diff --git a/src/clis/band/post.ts b/src/clis/band/post.ts new file mode 100644 index 00000000..ca9a07f8 --- /dev/null +++ b/src/clis/band/post.ts @@ -0,0 +1,155 @@ +import fs from 'fs'; +import http from 'http'; +import https from 'https'; +import path from 'path'; +import { AuthRequiredError, EmptyResultError, ArgumentError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; + +/** + * band post — Export the full content of a single Band post, including all comments. + * + * Uses Strategy.INTERCEPT with a broad `band.us` pattern to capture both the + * batch request (which embeds get_post) and the get_comments request in a single + * page navigation. Client-side we identify each by their response shape: + * - batch response: result_data.batch_result[] + * - comments response: result_data.items[] where items have a comment_id field + * + * Optionally downloads attached photos with --output . + */ +cli({ + site: 'band', + name: 'post', + description: 'Export full content of a post including comments', + domain: 'www.band.us', + strategy: Strategy.INTERCEPT, + 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 download attached photos into', + }, + ], + 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; + + if (!bandNo) throw new ArgumentError('band_no', 'Band number is required'); + if (!postNo) throw new ArgumentError('post_no', 'Post number is required'); + + await page.goto('https://www.band.us/'); + await page.wait(2); // wait for React hydration and cookie settlement + + const isLoggedIn = await page.evaluate(`() => document.cookie.includes('band_session')`); + if (!isLoggedIn) throw new AuthRequiredError('band.us', 'Not logged in to Band'); + + // Use a broad pattern to capture both the batch (post body) and get_comments + // in a single navigation, since they fire concurrently on the same page load. + await page.installInterceptor('band.us'); + + // SPA navigation: PopStateEvent signals React Router to render the post page, + // which triggers both batch (containing get_post) and get_comments XHR calls. + await page.evaluate(`() => { + window.history.pushState({}, '', '/band/${bandNo}/post/${postNo}'); + window.dispatchEvent(new PopStateEvent('popstate', { state: {} })); + }`); + await page.wait(5); // allow time for both XHR round-trips to complete + + const requests = await page.getInterceptedRequests(); + if (requests.length === 0) { + throw new EmptyResultError('band post', 'No data captured — try again.'); + } + + // Identify the batch response by the presence of batch_result. + // The batch embeds get_post, get_emotions, etc. as sub-requests. + const batchReq = (requests as any[]).find(r => + Array.isArray(r?.result_data?.batch_result) + ); + // Identify the comments response by items that have a comment_id field. + const commentsReq = (requests as any[]).find(r => + Array.isArray(r?.result_data?.items) && + r.result_data.items.length > 0 && + r.result_data.items[0]?.comment_id !== undefined + ); + + const postData = batchReq?.result_data?.batch_result?.[0]?.result_data?.post; + if (!postData) { + throw new EmptyResultError('band post', 'Post not found or not accessible'); + } + + const stripBandTags = (s: string) => s.replace(/<\/?band:[^>]+>/g, ''); + const fmtDate = (ms: number) => + new Date(ms).toLocaleString('ja-JP', { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', + }); + + // Extract photo URLs from attachment.photo (object keyed by photo_no). + const photos: string[] = Object.values(postData.attachment?.photo ?? {}) + .map((p: any) => p.photo_url) + .filter(Boolean); + + // Download photos if --output was specified. + if (outputDir && photos.length > 0) { + fs.mkdirSync(outputDir, { recursive: true }); + await Promise.all( + photos.map((url, i) => + new Promise((resolve, reject) => { + const ext = path.extname(new URL(url).pathname) || '.jpg'; + const dest = path.join(outputDir, `photo_${i + 1}${ext}`); + const file = fs.createWriteStream(dest); + const get = url.startsWith('https') ? https.get : http.get; + get(url, res => { + res.pipe(file); + file.on('finish', () => { file.close(); resolve(); }); + }).on('error', reject); + }) + ) + ); + } + + // Build output rows: post header first, then one row per comment. + const rows: Record[] = []; + + rows.push({ + type: 'post', + author: postData.author?.name ?? '', + date: postData.created_at ? fmtDate(postData.created_at) : '', + // Include photo URLs in the text if not downloading, so they are visible. + text: [ + stripBandTags(postData.content ?? ''), + ...(outputDir ? [] : photos.map((u, i) => `[photo${i + 1}] ${u}`)), + ].filter(Boolean).join('\n'), + }); + + const commentItems: any[] = commentsReq?.result_data?.items ?? []; + for (const c of commentItems) { + rows.push({ + type: 'comment', + author: c.author?.name ?? '', + date: c.created_at ? fmtDate(c.created_at) : '', + text: stripBandTags(c.body ?? ''), + }); + } + + return rows; + }, +}); From 60a496e598e0a4b9fa28231360854d68b06e0447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=AB=E3=83=B3?= Date: Sat, 28 Mar 2026 11:47:01 +0900 Subject: [PATCH 05/22] refactor(band): replace XHR interception with direct DOM extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bands, posts, post: navigate directly to target URL instead of home→SPA detour - All three switch from Strategy.INTERCEPT to Strategy.COOKIE with navigateBefore: false (bands uses framework pre-nav to home; posts/post disable it and goto target directly) - DOM extraction polls for specific content elements rather than fixed waits - post: confirm selectors via browser inspection (a.text, time.time, .sCommentList, .sReplyList for nested replies); add --comments flag to skip comment fetch - posts: extract from rendered post list DOM; correct comment item selector (div.cComment) - Fix: post empty-result guard changed from && to handle null data safely - Fix: photo download now checks HTTP status code before piping to avoid writing redirect HTML into image files - Fix: mentions unread client-side filter skipped for 'mentioned' mode since server already filtered via 未確認のみ button click Co-Authored-By: Claude Sonnet 4.6 --- src/clis/band/bands.ts | 89 ++++++------- src/clis/band/mentions.ts | 6 +- src/clis/band/post.ts | 223 ++++++++++++++++++--------------- src/clis/band/posts.ts | 112 ++++++++++------- tests/e2e/browser-auth.test.ts | 4 + 5 files changed, 242 insertions(+), 192 deletions(-) diff --git a/src/clis/band/bands.ts b/src/clis/band/bands.ts index ad2cfa78..03521cf0 100644 --- a/src/clis/band/bands.ts +++ b/src/clis/band/bands.ts @@ -4,69 +4,72 @@ import { cli, Strategy } from '../../registry.js'; /** * band bands — List all Bands you belong to. * - * 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 in the page, then trigger Band's - * own React app to fire the request by SPA-navigating to a band page — which always - * causes Band to call get_band_list_with_filter to re-populate the sidebar. + * 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.INTERCEPT, + strategy: Strategy.COOKIE, browser: true, args: [], columns: ['band_no', 'name', 'members'], func: async (page, _kwargs) => { - await page.goto('https://www.band.us/'); - await page.wait(2); // wait for React hydration and cookie settlement - const isLoggedIn = await page.evaluate(`() => document.cookie.includes('band_session')`); if (!isLoggedIn) throw new AuthRequiredError('band.us', 'Not logged in to Band'); - // Extract any band_no from sidebar links. We need it to SPA-navigate to a band - // page, which is the only route that reliably re-triggers get_band_list_with_filter - // (direct / and /discover routes serve from React cache after first load). - const bandNo = await page.evaluate(`() => { - const m = Array.from(document.querySelectorAll('a[href*="/band/"]')) - .map(a => a.getAttribute('href').match(/\\/band\\/(\\d+)/)) - .find(Boolean); - return m ? m[1] : null; - }`); - if (!bandNo) throw new EmptyResultError('band bands', 'No band links found — are you logged in?'); + // Extract the band list from the sidebar. Poll until at least one band link + // appears (React hydration may take a moment after navigation). + const bands: { band_no: number; name: string; members: number }[] = await page.evaluate(` + (async () => { + const sleep = ms => new Promise(r => setTimeout(r, ms)); - // Install XHR interceptor before triggering navigation so we don't miss the request. - await page.installInterceptor('get_band_list_with_filter'); + // Wait up to 9 s for sidebar band links to render. + for (let i = 0; i < 30; i++) { + if (document.querySelector('a[href*="/band/"]')) break; + await sleep(300); + } - // SPA navigation: history.pushState keeps the React app alive (no full reload), - // so the interceptor stays active. PopStateEvent signals React Router to re-render. - await page.evaluate(`() => { - window.history.pushState({}, '', '/band/${String(bandNo)}/post'); - window.dispatchEvent(new PopStateEvent('popstate', { state: {} })); - }`); - await page.wait(4); // allow time for the XHR round-trip to complete + const seen = new Set(); + const results = []; - const requests = await page.getInterceptedRequests(); - if (requests.length === 0) { - throw new EmptyResultError('band bands', 'No band list data captured — try again.'); - } + for (const a of Array.from(document.querySelectorAll('a[href*="/band/"]'))) { + const m = a.getAttribute('href').match(/\\/band\\/(\\d+)/); + if (!m) continue; + const bandNo = Number(m[1]); + if (seen.has(bandNo)) continue; + seen.add(bandNo); + + // Band name: prefer a dedicated name element; fall back to the link's + // own text content (stripping any nested numeric badge text). + const nameEl = a.querySelector('._bandName, .bandName, .name, strong'); + const name = (nameEl?.textContent || a.textContent || '').trim() + .replace(/\\s*\\d+\\s*$/, '') // strip trailing member-count badge + .trim(); + if (!name) continue; + + // Member count may appear as a small badge element inside the link. + const memberEl = a.querySelector('._memberCount, .memberCount, .count'); + const members = memberEl ? parseInt(memberEl.textContent, 10) || 0 : 0; + + results.push({ band_no: bandNo, name, members }); + } - // result_data is an array of { band: { band_no, name, member_count, ... } } - const bands = (requests as any[]).flatMap(req => - Array.isArray(req?.result_data) ? req.result_data.map((d: any) => d.band).filter(Boolean) : [] - ); + return results; + })() + `); - if (bands.length === 0) { - throw new EmptyResultError('band bands', 'No bands found'); + if (!bands || bands.length === 0) { + throw new EmptyResultError('band bands', 'No bands found in sidebar — are you logged in?'); } - return bands.map((b: any) => ({ - band_no: b.band_no, - name: b.name ?? '', - members: b.member_count ?? 0, - })); + return bands; }, }); diff --git a/src/clis/band/mentions.ts b/src/clis/band/mentions.ts index 231224ac..9490d1a1 100644 --- a/src/clis/band/mentions.ts +++ b/src/clis/band/mentions.ts @@ -92,8 +92,10 @@ cli({ throw new EmptyResultError('band mentions', 'No notifications found'); } - // Client-side filters for non-mention modes (server already filters 'mentioned'). - if (unreadOnly) { + // For non-mention modes the server returns the full list; apply unread filter client-side. + // For 'mentioned' mode the server already filtered by unread (via the 未確認のみ button click), + // so skip the redundant client-side pass to avoid dropping items when is_new is absent. + if (unreadOnly && filter !== 'mentioned') { items = items.filter((n: any) => n.is_new === true); } if (filter === 'post') { diff --git a/src/clis/band/post.ts b/src/clis/band/post.ts index ca9a07f8..eac809a9 100644 --- a/src/clis/band/post.ts +++ b/src/clis/band/post.ts @@ -6,44 +6,31 @@ import { AuthRequiredError, EmptyResultError, ArgumentError } from '../../errors import { cli, Strategy } from '../../registry.js'; /** - * band post — Export the full content of a single Band post, including all comments. + * band post — Export full content of a Band post: body, comments, and optional photo download. * - * Uses Strategy.INTERCEPT with a broad `band.us` pattern to capture both the - * batch request (which embeds get_post) and the get_comments request in a single - * page navigation. Client-side we identify each by their response shape: - * - batch response: result_data.batch_result[] - * - comments response: result_data.items[] where items have a comment_id field + * 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. * - * Optionally downloads attached photos with --output . + * 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.INTERCEPT, + 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 download attached photos into', - }, + { 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'], @@ -51,102 +38,140 @@ cli({ const bandNo = Number(kwargs.band_no); const postNo = Number(kwargs.post_no); const outputDir = kwargs.output as string; + const withComments = kwargs.comments as boolean; if (!bandNo) throw new ArgumentError('band_no', 'Band number is required'); if (!postNo) throw new ArgumentError('post_no', 'Post number is required'); - await page.goto('https://www.band.us/'); - await page.wait(2); // wait for React hydration and cookie settlement + await page.goto(`https://www.band.us/band/${bandNo}/post/${postNo}`); const isLoggedIn = await page.evaluate(`() => document.cookie.includes('band_session')`); if (!isLoggedIn) throw new AuthRequiredError('band.us', 'Not logged in to Band'); - // Use a broad pattern to capture both the batch (post body) and get_comments - // in a single navigation, since they fire concurrently on the same page load. - await page.installInterceptor('band.us'); - - // SPA navigation: PopStateEvent signals React Router to render the post page, - // which triggers both batch (containing get_post) and get_comments XHR calls. - await page.evaluate(`() => { - window.history.pushState({}, '', '/band/${bandNo}/post/${postNo}'); - window.dispatchEvent(new PopStateEvent('popstate', { state: {} })); - }`); - await page.wait(5); // allow time for both XHR round-trips to complete - - const requests = await page.getInterceptedRequests(); - if (requests.length === 0) { - throw new EmptyResultError('band post', 'No data captured — try again.'); - } - - // Identify the batch response by the presence of batch_result. - // The batch embeds get_post, get_emotions, etc. as sub-requests. - const batchReq = (requests as any[]).find(r => - Array.isArray(r?.result_data?.batch_result) - ); - // Identify the comments response by items that have a comment_id field. - const commentsReq = (requests as any[]).find(r => - Array.isArray(r?.result_data?.items) && - r.result_data.items.length > 0 && - r.result_data.items[0]?.comment_id !== undefined - ); - - const postData = batchReq?.result_data?.batch_result?.[0]?.result_data?.post; - if (!postData) { + 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. + const photos = Array.from(postCard?.querySelectorAll('img._imgRecentPhoto, img._imgPhoto') || []) + .map(img => { + try { const u = new URL(img.getAttribute('src') || ''); return u.origin + u.pathname; } + catch { return ''; } + }) + .filter(Boolean); + + if (!withComments) return { author, date, text, photos, comments: [] }; + + // Wait up to 6 s for comments to appear. + for (let i = 0; i < 20; i++) { + if (postCard?.querySelector('.sCommentList._heightDetectAreaForComment .cComment')) 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) { throw new EmptyResultError('band post', 'Post not found or not accessible'); } - const stripBandTags = (s: string) => s.replace(/<\/?band:[^>]+>/g, ''); - const fmtDate = (ms: number) => - new Date(ms).toLocaleString('ja-JP', { - year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit', - }); - - // Extract photo URLs from attachment.photo (object keyed by photo_no). - const photos: string[] = Object.values(postData.attachment?.photo ?? {}) - .map((p: any) => p.photo_url) - .filter(Boolean); - - // Download photos if --output was specified. + // Download photos when --output is specified. + const photos: string[] = data.photos ?? []; if (outputDir && photos.length > 0) { fs.mkdirSync(outputDir, { recursive: true }); - await Promise.all( - photos.map((url, i) => - new Promise((resolve, reject) => { - const ext = path.extname(new URL(url).pathname) || '.jpg'; - const dest = path.join(outputDir, `photo_${i + 1}${ext}`); - const file = fs.createWriteStream(dest); - const get = url.startsWith('https') ? https.get : http.get; - get(url, res => { - res.pipe(file); - file.on('finish', () => { file.close(); resolve(); }); - }).on('error', reject); - }) - ) - ); + await Promise.all(photos.map((url, i) => + new Promise((resolve, reject) => { + const ext = path.extname(new URL(url).pathname) || '.jpg'; + const dest = path.join(outputDir, `photo_${i + 1}${ext}`); + const file = fs.createWriteStream(dest); + (url.startsWith('https') ? https : http).get(url, res => { + if (res.statusCode && res.statusCode >= 300) { + file.close(); + fs.unlink(dest, () => {}); + reject(new Error(`HTTP ${res.statusCode} downloading ${url}`)); + return; + } + res.pipe(file); + file.on('finish', () => { file.close(); resolve(); }); + }).on('error', reject); + }) + )); } - // Build output rows: post header first, then one row per comment. const rows: Record[] = []; + // Post row — append photo URLs inline when not downloading to disk. rows.push({ type: 'post', - author: postData.author?.name ?? '', - date: postData.created_at ? fmtDate(postData.created_at) : '', - // Include photo URLs in the text if not downloading, so they are visible. + author: data.author ?? '', + date: data.date ?? '', text: [ - stripBandTags(postData.content ?? ''), + data.text ?? '', ...(outputDir ? [] : photos.map((u, i) => `[photo${i + 1}] ${u}`)), ].filter(Boolean).join('\n'), }); - const commentItems: any[] = commentsReq?.result_data?.items ?? []; - for (const c of commentItems) { + // Comment rows — depth=0 → type 'comment', depth≥1 → type 'reply'. + for (const c of data.comments ?? []) { rows.push({ - type: 'comment', - author: c.author?.name ?? '', - date: c.created_at ? fmtDate(c.created_at) : '', - text: stripBandTags(c.body ?? ''), + type: c.depth === 0 ? 'comment' : 'reply', + author: c.author ?? '', + date: c.date ?? '', + text: c.depth > 0 ? ' └ ' + (c.text ?? '') : (c.text ?? ''), }); } diff --git a/src/clis/band/posts.ts b/src/clis/band/posts.ts index 5232f35f..e75b023c 100644 --- a/src/clis/band/posts.ts +++ b/src/clis/band/posts.ts @@ -4,18 +4,17 @@ import { cli, Strategy } from '../../registry.js'; /** * band posts — List posts from a specific Band. * - * 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, then SPA-navigate to the target - * band's post page — which causes Band's React app to call get_posts_and_announcements - * with its own auth headers automatically. + * 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.INTERCEPT, + strategy: Strategy.COOKIE, + navigateBefore: false, browser: true, args: [ { @@ -35,58 +34,75 @@ cli({ if (!bandNo) throw new ArgumentError('band_no', 'Band number is required. Get it from: band bands'); - await page.goto('https://www.band.us/'); - await page.wait(2); // wait for React hydration and cookie settlement + // Navigate directly to the band's post page — no home-page detour needed. + await page.goto(`https://www.band.us/band/${bandNo}/post`); const isLoggedIn = await page.evaluate(`() => document.cookie.includes('band_session')`); if (!isLoggedIn) throw new AuthRequiredError('band.us', 'Not logged in to Band'); - // Install XHR interceptor before triggering navigation so we don't miss the request. - await page.installInterceptor('get_posts_and_announcements'); + // 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}; - // SPA navigation: history.pushState keeps the React app alive (no full reload), - // so the interceptor stays active. PopStateEvent signals React Router to re-render. - await page.evaluate(`() => { - window.history.pushState({}, '', '/band/${bandNo}/post'); - window.dispatchEvent(new PopStateEvent('popstate', { state: {} })); - }`); - await page.wait(4); // allow time for the XHR round-trip to complete + // Wait up to 9 s for post items to render. + for (let i = 0; i < 30; i++) { + if (document.querySelector('li._postListItem, li[data-post-no], article._post')) break; + await sleep(300); + } - const requests = await page.getInterceptedRequests(); - if (requests.length === 0) { - throw new EmptyResultError('band posts', 'No post data captured'); - } + // Band embeds custom , , etc. tags in content. + const stripTags = s => s.replace(/<\\/?band:[^>]+>/g, ''); + + const results = []; + const postEls = Array.from( + document.querySelectorAll('li._postListItem, li[data-post-no], article._post') + ); + + for (const el of postEls) { + // URL: find the post permalink link. + const linkEl = el.querySelector('a[href*="/post/"]'); + const href = linkEl?.getAttribute('href') || ''; + const url = href.startsWith('http') ? href : 'https://www.band.us' + href; + + // Author name. + const author = norm(el.querySelector('.authorName, .uAuthorName, a.text.ellipsis')?.textContent); + + // Date / timestamp. + const dateEl = el.querySelector('time, .timeText._postDate, .uPostDate'); + const date = norm(dateEl?.textContent || dateEl?.getAttribute('datetime') || ''); + + // Post body text (strip Band markup tags, truncate for listing). + const bodyEl = el.querySelector('.postText._postText, .uPostText'); + const content = bodyEl + ? stripTags(norm(bodyEl.innerText || bodyEl.textContent)).slice(0, 120) + : ''; + + // Comment count badge. + const commentEl = el.querySelector('._commentCount, .commentCount, .uCommentCount'); + const comments = commentEl ? parseInt(commentEl.textContent, 10) || 0 : 0; + + if (!url && !content) continue; + results.push({ date, author, content, comments, url }); + if (results.length >= limit) break; + } - // result_data.items contains both posts and announcements. Filter to items that - // have a resolvable post object with at least a post_no or web_url to link to. - const items = (requests as any[]).flatMap(req => - Array.isArray(req?.result_data?.items) ? req.result_data.items : [] - ).filter((item: any) => { - const post = item.post ?? item; - return post.post_no || post.web_url; - }); + return results; + })() + `); - if (items.length === 0) { + if (!posts || posts.length === 0) { throw new EmptyResultError('band posts', 'No posts found in this Band'); } - // Band markup tags (, , etc.) appear in content; - // strip them to get plain text. - const stripBandTags = (s: string) => s.replace(/<\/?band:[^>]+>/g, '').trim(); - - return items.slice(0, limit).map((item: any) => { - // Some bands wrap the post under item.post; others return the post object directly. - const post = item.post ?? item; - const ts = post.created_at ? new Date(post.created_at) : null; - return { - date: ts - ? ts.toLocaleString('ja-JP', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) - : '', - author: post.author?.name ?? '', - content: stripBandTags(post.content ?? '').slice(0, 120), - comments: post.comment_count ?? 0, - url: post.web_url ?? `https://band.us/band/${bandNo}/post/${post.post_no}`, - }; - }); + return posts; }, }); diff --git a/tests/e2e/browser-auth.test.ts b/tests/e2e/browser-auth.test.ts index 37e3deab..e1d2e898 100644 --- a/tests/e2e/browser-auth.test.ts +++ b/tests/e2e/browser-auth.test.ts @@ -163,4 +163,8 @@ describe('login-required commands — graceful failure', () => { 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); }); From ce5e14ea5e5803710bcbcc04101ac3c831a58112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=AB=E3=83=B3?= Date: Sat, 28 Mar 2026 11:59:18 +0900 Subject: [PATCH 06/22] fix(band): address code review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - post: replace manual http/https download with shared downloadMedia utility (handles redirects, timeouts, stream errors correctly) - post: fix photo URL resolution to use location.href as base, handling protocol-relative and relative URLs without throwing - post: switch to node:-prefixed imports per repo convention - post/posts: remove redundant ArgumentError guards — framework already validates required args before func() is called - mentions: INTERCEPT strategy is intentional (Band HMAC prevents DOM-only approach for notifications; update PR description to clarify) Co-Authored-By: Claude Sonnet 4.6 --- src/clis/band/post.ts | 41 +++++++++++++---------------------------- src/clis/band/posts.ts | 4 +--- 2 files changed, 14 insertions(+), 31 deletions(-) diff --git a/src/clis/band/post.ts b/src/clis/band/post.ts index eac809a9..855fc84c 100644 --- a/src/clis/band/post.ts +++ b/src/clis/band/post.ts @@ -1,8 +1,5 @@ -import fs from 'fs'; -import http from 'http'; -import https from 'https'; -import path from 'path'; -import { AuthRequiredError, EmptyResultError, ArgumentError } from '../../errors.js'; +import { AuthRequiredError, EmptyResultError } from '../../errors.js'; +import { downloadMedia } from '../../download/media-download.js'; import { cli, Strategy } from '../../registry.js'; /** @@ -40,9 +37,6 @@ cli({ const outputDir = kwargs.output as string; const withComments = kwargs.comments as boolean; - if (!bandNo) throw new ArgumentError('band_no', 'Band number is required'); - if (!postNo) throw new ArgumentError('post_no', 'Post number is required'); - await page.goto(`https://www.band.us/band/${bandNo}/post/${postNo}`); const isLoggedIn = await page.evaluate(`() => document.cookie.includes('band_session')`); @@ -86,9 +80,12 @@ cli({ 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 => { - try { const u = new URL(img.getAttribute('src') || ''); return u.origin + u.pathname; } + 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); @@ -129,27 +126,15 @@ cli({ throw new EmptyResultError('band post', 'Post not found or not accessible'); } - // Download photos when --output is specified. const photos: string[] = data.photos ?? []; + + // Download photos when --output is specified, using the shared httpDownload utility + // which handles redirects, timeouts, and stream errors correctly. if (outputDir && photos.length > 0) { - fs.mkdirSync(outputDir, { recursive: true }); - await Promise.all(photos.map((url, i) => - new Promise((resolve, reject) => { - const ext = path.extname(new URL(url).pathname) || '.jpg'; - const dest = path.join(outputDir, `photo_${i + 1}${ext}`); - const file = fs.createWriteStream(dest); - (url.startsWith('https') ? https : http).get(url, res => { - if (res.statusCode && res.statusCode >= 300) { - file.close(); - fs.unlink(dest, () => {}); - reject(new Error(`HTTP ${res.statusCode} downloading ${url}`)); - return; - } - res.pipe(file); - file.on('finish', () => { file.close(); resolve(); }); - }).on('error', reject); - }) - )); + await downloadMedia( + photos.map(url => ({ type: 'image' as const, url })), + { output: outputDir, filenamePrefix: 'photo', verbose: false }, + ); } const rows: Record[] = []; diff --git a/src/clis/band/posts.ts b/src/clis/band/posts.ts index e75b023c..0a736b07 100644 --- a/src/clis/band/posts.ts +++ b/src/clis/band/posts.ts @@ -1,4 +1,4 @@ -import { AuthRequiredError, EmptyResultError, ArgumentError } from '../../errors.js'; +import { AuthRequiredError, EmptyResultError } from '../../errors.js'; import { cli, Strategy } from '../../registry.js'; /** @@ -32,8 +32,6 @@ cli({ const bandNo = Number(kwargs.band_no); const limit = Number(kwargs.limit); - if (!bandNo) throw new ArgumentError('band_no', 'Band number is required. Get it from: band bands'); - // Navigate directly to the band's post page — no home-page detour needed. await page.goto(`https://www.band.us/band/${bandNo}/post`); From efcff9ed851257142138d9fac2c5a86e3c75bdbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=AB=E3=83=B3?= Date: Sat, 28 Mar 2026 12:17:02 +0900 Subject: [PATCH 07/22] fix(band): address second round of code review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bands: tighten href selector to /band/{id}(?:/post)?$ so feed/post-detail links are excluded; only sidebar navigation links match - mentions: replace fixed page.wait(2) sleeps with polling on getInterceptedRequests() — waits up to 8 s per action, exits as soon as the expected number of captures arrives (avoids flakiness on slow XHR) Co-Authored-By: Claude Sonnet 4.6 --- src/clis/band/bands.ts | 8 ++++++-- src/clis/band/mentions.ts | 19 +++++++++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/clis/band/bands.ts b/src/clis/band/bands.ts index 03521cf0..277c1b19 100644 --- a/src/clis/band/bands.ts +++ b/src/clis/band/bands.ts @@ -31,9 +31,13 @@ cli({ (async () => { const sleep = ms => new Promise(r => setTimeout(r, ms)); + // Sidebar band links have the form /band/{id} or /band/{id}/post (no post_no suffix). + // This pattern excludes feed/post-detail links like /band/{id}/post/{postNo}. + const sidebarHref = /^\\/band\\/(\\d+)(?:\\/post)?$/; + // Wait up to 9 s for sidebar band links to render. for (let i = 0; i < 30; i++) { - if (document.querySelector('a[href*="/band/"]')) break; + if (Array.from(document.querySelectorAll('a[href*="/band/"]')).some(a => sidebarHref.test(a.getAttribute('href') || ''))) break; await sleep(300); } @@ -41,7 +45,7 @@ cli({ const results = []; for (const a of Array.from(document.querySelectorAll('a[href*="/band/"]'))) { - const m = a.getAttribute('href').match(/\\/band\\/(\\d+)/); + const m = (a.getAttribute('href') || '').match(sidebarHref); if (!m) continue; const bandNo = Number(m[1]); if (seen.has(bandNo)) continue; diff --git a/src/clis/band/mentions.ts b/src/clis/band/mentions.ts index 9490d1a1..4a97286e 100644 --- a/src/clis/band/mentions.ts +++ b/src/clis/band/mentions.ts @@ -46,6 +46,17 @@ cli({ // Install XHR interceptor before any clicks so all get_news responses are captured. await page.installInterceptor('get_news'); + // Poll until at least `count` requests are captured, up to maxSecs seconds. + // Avoids relying on fixed sleeps when the XHR round-trip is slow. + const waitForCaptures = async (count: number, maxSecs = 8): Promise => { + for (let i = 0; i < maxSecs * 2; i++) { + await page.wait(500); + const reqs = await page.getInterceptedRequests(); + if (reqs.length >= count) return reqs; + } + return page.getInterceptedRequests(); + }; + // Click the notification bell to open the panel. This triggers an initial get_news // call for all notification types. The bell button is identified by class and text // since Band does not use aria-label on this element. @@ -54,7 +65,8 @@ cli({ .find(b => b.textContent && b.textContent.includes('お知らせ')); if (bell) bell.click(); }`); - await page.wait(2); // wait for panel to render and initial get_news to complete + + let requests = await waitForCaptures(1); if (filter === 'mentioned') { // Click the @メンション tab to trigger a server-side filtered get_news call. @@ -65,7 +77,7 @@ cli({ .find(b => b.textContent && b.textContent.includes('メンション')); if (tab) tab.click(); }`); - await page.wait(2); + requests = await waitForCaptures(2); if (unreadOnly) { // 未確認のみ表示: triggers another server-side filtered get_news for unread mentions. @@ -74,11 +86,10 @@ cli({ .find(b => b.textContent && b.textContent.includes('未確認のみ')); if (btn) btn.click(); }`); - await page.wait(2); + requests = await waitForCaptures(3); } } - const requests = await page.getInterceptedRequests(); if (requests.length === 0) { throw new EmptyResultError('band mentions', 'No notification data captured. Try running the command again.'); } From 45987a1eba7f0a6181aef80d6aeaae8439a0f685 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=AB=E3=83=B3?= Date: Sat, 28 Mar 2026 13:43:38 +0900 Subject: [PATCH 08/22] fix(band): fix selector bugs found during testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bands: use a.bandCover._link + p.uriText + span.member em selectors (previous a[href*="/band/"] + .bandName combo leaked "メンバー" text) - posts: use article.cContentsCard._postMainWrap + span.count selectors (previous li._postListItem selector matched nothing; DOM changed) - mentions: fix page.wait(500) → page.wait(0.5) (was waiting 500s not ms); use timestamp-suffixed URL to force fresh page load each run so the notification panel is closed; fix get_news vs get_news_count capture ambiguity with result_data.news check; replace cumulative waitForCaptures with waitForOneCapture (getInterceptedRequests clears array on each call) Co-Authored-By: Claude Sonnet 4.6 --- src/clis/band/bands.ts | 30 ++++++++----------- src/clis/band/mentions.ts | 62 ++++++++++++++------------------------- src/clis/band/posts.ts | 19 ++++++------ 3 files changed, 44 insertions(+), 67 deletions(-) diff --git a/src/clis/band/bands.ts b/src/clis/band/bands.ts index 277c1b19..8803a03d 100644 --- a/src/clis/band/bands.ts +++ b/src/clis/band/bands.ts @@ -25,42 +25,38 @@ cli({ const isLoggedIn = await page.evaluate(`() => document.cookie.includes('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 link + // 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)); - // Sidebar band links have the form /band/{id} or /band/{id}/post (no post_no suffix). - // This pattern excludes feed/post-detail links like /band/{id}/post/{postNo}. - const sidebarHref = /^\\/band\\/(\\d+)(?:\\/post)?$/; - - // Wait up to 9 s for sidebar band links to render. + // Wait up to 9 s for sidebar band cards to render. for (let i = 0; i < 30; i++) { - if (Array.from(document.querySelectorAll('a[href*="/band/"]')).some(a => sidebarHref.test(a.getAttribute('href') || ''))) break; + 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[href*="/band/"]'))) { - const m = (a.getAttribute('href') || '').match(sidebarHref); + for (const a of Array.from(document.querySelectorAll('a.bandCover._link'))) { + // Extract band_no from href: /band/{id} or /band/{id}/post + const m = (a.getAttribute('href') || '').match(/\\/band\\/(\\d+)/); if (!m) continue; const bandNo = Number(m[1]); if (seen.has(bandNo)) continue; seen.add(bandNo); - // Band name: prefer a dedicated name element; fall back to the link's - // own text content (stripping any nested numeric badge text). - const nameEl = a.querySelector('._bandName, .bandName, .name, strong'); - const name = (nameEl?.textContent || a.textContent || '').trim() - .replace(/\\s*\\d+\\s*$/, '') // strip trailing member-count badge - .trim(); + // 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 may appear as a small badge element inside the link. - const memberEl = a.querySelector('._memberCount, .memberCount, .count'); + // Member count is the inside span.member. + const memberEl = a.querySelector('span.member em'); const members = memberEl ? parseInt(memberEl.textContent, 10) || 0 : 0; results.push({ band_no: bandNo, name, members }); diff --git a/src/clis/band/mentions.ts b/src/clis/band/mentions.ts index 4a97286e..8190c1fc 100644 --- a/src/clis/band/mentions.ts +++ b/src/clis/band/mentions.ts @@ -37,8 +37,11 @@ cli({ const limit = kwargs.limit as number; const unreadOnly = kwargs.unread as boolean; - await page.goto('https://www.band.us/'); - await page.wait(2); // wait for React hydration and cookie settlement + // 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()}`); + await page.wait(2); const isLoggedIn = await page.evaluate(`() => document.cookie.includes('band_session')`); if (!isLoggedIn) throw new AuthRequiredError('band.us', 'Not logged in to Band'); @@ -46,15 +49,16 @@ cli({ // Install XHR interceptor before any clicks so all get_news responses are captured. await page.installInterceptor('get_news'); - // Poll until at least `count` requests are captured, up to maxSecs seconds. - // Avoids relying on fixed sleeps when the XHR round-trip is slow. - const waitForCaptures = async (count: number, maxSecs = 8): Promise => { + // Poll until at least 1 new capture arrives, up to maxSecs seconds. + // getInterceptedRequests() clears the array on each call, so we wait for + // exactly 1 new capture after each UI action rather than a cumulative total. + const waitForOneCapture = async (maxSecs = 8): Promise => { for (let i = 0; i < maxSecs * 2; i++) { - await page.wait(500); + await page.wait(0.5); // 0.5 seconds per iteration (page.wait takes seconds) const reqs = await page.getInterceptedRequests(); - if (reqs.length >= count) return reqs; + if (reqs.length >= 1) return reqs; } - return page.getInterceptedRequests(); + return []; }; // Click the notification bell to open the panel. This triggers an initial get_news @@ -66,50 +70,28 @@ cli({ if (bell) bell.click(); }`); - let requests = await waitForCaptures(1); - - if (filter === 'mentioned') { - // Click the @メンション tab to trigger a server-side filtered get_news call. - // This response contains only notifications with the 'referred' filter flag, - // which is more accurate than client-side filtering the full list. - await page.evaluate(`() => { - const tab = Array.from(document.querySelectorAll('button._btnFilter')) - .find(b => b.textContent && b.textContent.includes('メンション')); - if (tab) tab.click(); - }`); - requests = await waitForCaptures(2); - - if (unreadOnly) { - // 未確認のみ表示: triggers another server-side filtered get_news for unread mentions. - await page.evaluate(`() => { - const btn = Array.from(document.querySelectorAll('button')) - .find(b => b.textContent && b.textContent.includes('未確認のみ')); - if (btn) btn.click(); - }`); - requests = await waitForCaptures(3); - } - } + const requests = await waitForOneCapture(); if (requests.length === 0) { throw new EmptyResultError('band mentions', 'No notification data captured. Try running the command again.'); } - // Use the last captured response: each UI action (bell → tab → unread toggle) - // triggers a progressively more specific get_news call, so the last one is correct. - const lastReq = requests[requests.length - 1] as any; - let items: any[] = Array.isArray(lastReq?.result_data?.news) ? lastReq.result_data.news : []; + // 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; + let items: any[] = newsReq?.result_data?.news ?? []; if (items.length === 0) { throw new EmptyResultError('band mentions', 'No notifications found'); } - // For non-mention modes the server returns the full list; apply unread filter client-side. - // For 'mentioned' mode the server already filtered by unread (via the 未確認のみ button click), - // so skip the redundant client-side pass to avoid dropping items when is_new is absent. - if (unreadOnly && filter !== 'mentioned') { + // Apply filters client-side from the full notification list. + if (unreadOnly) { items = items.filter((n: any) => n.is_new === true); } - if (filter === 'post') { + 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'); diff --git a/src/clis/band/posts.ts b/src/clis/band/posts.ts index 0a736b07..f103c7b0 100644 --- a/src/clis/band/posts.ts +++ b/src/clis/band/posts.ts @@ -53,7 +53,7 @@ cli({ // Wait up to 9 s for post items to render. for (let i = 0; i < 30; i++) { - if (document.querySelector('li._postListItem, li[data-post-no], article._post')) break; + if (document.querySelector('article.cContentsCard._postMainWrap')) break; await sleep(300); } @@ -62,30 +62,29 @@ cli({ const results = []; const postEls = Array.from( - document.querySelectorAll('li._postListItem, li[data-post-no], article._post') + document.querySelectorAll('article.cContentsCard._postMainWrap') ); for (const el of postEls) { - // URL: find the post permalink link. + // URL: first post permalink link (absolute or relative). const linkEl = el.querySelector('a[href*="/post/"]'); const href = linkEl?.getAttribute('href') || ''; const url = href.startsWith('http') ? href : 'https://www.band.us' + href; - // Author name. - const author = norm(el.querySelector('.authorName, .uAuthorName, a.text.ellipsis')?.textContent); + // Author name — a.text in the post header area. + const author = norm(el.querySelector('a.text')?.textContent); // Date / timestamp. - const dateEl = el.querySelector('time, .timeText._postDate, .uPostDate'); - const date = norm(dateEl?.textContent || dateEl?.getAttribute('datetime') || ''); + const date = norm(el.querySelector('time')?.textContent); // Post body text (strip Band markup tags, truncate for listing). - const bodyEl = el.querySelector('.postText._postText, .uPostText'); + const bodyEl = el.querySelector('.postText._postText'); const content = bodyEl ? stripTags(norm(bodyEl.innerText || bodyEl.textContent)).slice(0, 120) : ''; - // Comment count badge. - const commentEl = el.querySelector('._commentCount, .commentCount, .uCommentCount'); + // Comment count is in span.count inside the count area. + const commentEl = el.querySelector('span.count'); const comments = commentEl ? parseInt(commentEl.textContent, 10) || 0 : 0; if (!url && !content) continue; From 3f5391ed722b77594873794a83f1fb7cbd8a312a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=AB=E3=83=B3?= Date: Sat, 28 Mar 2026 14:06:38 +0900 Subject: [PATCH 09/22] fix(band/mentions): use CSS class selector for bell button instead of locale-dependent text match Co-Authored-By: Claude Sonnet 4.6 --- src/clis/band/mentions.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/clis/band/mentions.ts b/src/clis/band/mentions.ts index 8190c1fc..63326e3e 100644 --- a/src/clis/band/mentions.ts +++ b/src/clis/band/mentions.ts @@ -62,11 +62,10 @@ cli({ }; // Click the notification bell to open the panel. This triggers an initial get_news - // call for all notification types. The bell button is identified by class and text - // since Band does not use aria-label on this element. + // call for all notification types. Use the CSS class directly — text-based matching + // is locale-dependent and breaks when Band.us is set to a non-Japanese language. await page.evaluate(`() => { - const bell = Array.from(document.querySelectorAll('button._btnWidgetIcon')) - .find(b => b.textContent && b.textContent.includes('お知らせ')); + const bell = document.querySelector('button._btnWidgetIcon'); if (bell) bell.click(); }`); From f7cb2a65b57d8c87b573a7d1ce8a95575ca8b6f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=AB=E3=83=B3?= Date: Sat, 28 Mar 2026 14:08:27 +0900 Subject: [PATCH 10/22] fix(band): address third round of code review feedback - post: pass browser cookies to downloadMedia so Band's login-protected photo URLs don't fail with 401/403 - post: include photos.length in empty-result guard so photo-only posts are not falsely reported as not found - mentions: accumulate captures across poll iterations so get_news_count responses don't cause early exit before the real get_news arrives - mentions: update docstring to match actual implementation (client-side filtering, no tab-click) Co-Authored-By: Claude Sonnet 4.6 --- src/clis/band/mentions.ts | 22 ++++++++++++---------- src/clis/band/post.ts | 9 ++++++--- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/clis/band/mentions.ts b/src/clis/band/mentions.ts index 63326e3e..8c145b71 100644 --- a/src/clis/band/mentions.ts +++ b/src/clis/band/mentions.ts @@ -7,11 +7,8 @@ import { cli, Strategy } from '../../registry.js'; * 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, then click the @メンション tab — which triggers a server-side - * filtered get_news call containing only notifications where you were mentioned. - * - * The tab-click approach is preferred over client-side filtering on the full - * notification list, because Band's server already paginates/filters correctly. + * 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', @@ -49,16 +46,21 @@ cli({ // Install XHR interceptor before any clicks so all get_news responses are captured. await page.installInterceptor('get_news'); - // Poll until at least 1 new capture arrives, up to maxSecs seconds. - // getInterceptedRequests() clears the array on each call, so we wait for - // exactly 1 new capture after each UI action rather than a cumulative total. + // 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 >= 1) return reqs; + if (reqs.length > 0) { + captures.push(...reqs); + if (captures.some((r: any) => Array.isArray(r?.result_data?.news))) return captures; + } } - return []; + return captures; }; // Click the notification bell to open the panel. This triggers an initial get_news diff --git a/src/clis/band/post.ts b/src/clis/band/post.ts index 855fc84c..2da64674 100644 --- a/src/clis/band/post.ts +++ b/src/clis/band/post.ts @@ -122,18 +122,21 @@ cli({ })() `); - if (!data?.text && !data?.comments?.length) { + 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 httpDownload utility + // 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) { + const browserCookies = await page.getCookies({ domain: 'band.us' }); + const cookieHeader = browserCookies.map(c => `${c.name}=${c.value}`).join('; '); await downloadMedia( photos.map(url => ({ type: 'image' as const, url })), - { output: outputDir, filenamePrefix: 'photo', verbose: false }, + { output: outputDir, filenamePrefix: 'photo', verbose: false, cookies: cookieHeader }, ); } From 4ab364234a99bf1994eaf9cd88443fdfb2e81754 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=AB=E3=83=B3?= Date: Sat, 28 Mar 2026 18:47:04 +0900 Subject: [PATCH 11/22] fix(band): address fourth round of code review feedback - mentions: fail fast with a clear error when bell button is not found, instead of silently no-op and waiting 8s before EmptyResultError - post: use shared formatCookieHeader() instead of manual cookie string construction Co-Authored-By: Claude Sonnet 4.6 --- src/clis/band/mentions.ts | 8 ++++++-- src/clis/band/post.ts | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/clis/band/mentions.ts b/src/clis/band/mentions.ts index 8c145b71..e2ec1cc7 100644 --- a/src/clis/band/mentions.ts +++ b/src/clis/band/mentions.ts @@ -66,10 +66,14 @@ cli({ // Click the notification bell to open the panel. This triggers an initial get_news // call for all notification types. Use the CSS class directly — text-based matching // is locale-dependent and breaks when Band.us is set to a non-Japanese language. - await page.evaluate(`() => { + const bellFound = await page.evaluate(`() => { const bell = document.querySelector('button._btnWidgetIcon'); - if (bell) bell.click(); + if (bell) { bell.click(); return true; } + return false; }`); + if (!bellFound) { + throw new EmptyResultError('band mentions', 'Notification bell not found (selector: button._btnWidgetIcon). The Band.us UI may have changed.'); + } const requests = await waitForOneCapture(); diff --git a/src/clis/band/post.ts b/src/clis/band/post.ts index 2da64674..9c7e3265 100644 --- a/src/clis/band/post.ts +++ b/src/clis/band/post.ts @@ -1,4 +1,5 @@ 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'; @@ -132,8 +133,7 @@ cli({ // 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) { - const browserCookies = await page.getCookies({ domain: 'band.us' }); - const cookieHeader = browserCookies.map(c => `${c.name}=${c.value}`).join('; '); + const cookieHeader = formatCookieHeader(await page.getCookies({ domain: 'band.us' })); await downloadMedia( photos.map(url => ({ type: 'image' as const, url })), { output: outputDir, filenamePrefix: 'photo', verbose: false, cookies: cookieHeader }, From 4732db3409c0c470d4214f54828c394410de7921 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=AB=E3=83=B3?= Date: Sat, 28 Mar 2026 18:58:43 +0900 Subject: [PATCH 12/22] fix(band): address fifth round of code review feedback - mentions: replace fixed page.wait(2) with polling for bell button readiness (up to 10s), eliminating the fixed sleep and fail-fast when the selector is missing - mentions: add explicit !newsReq guard with a clear error message when get_news capture times out, instead of falling through to a misleading "No notifications found" - posts: skip posts with no permalink href instead of emitting a bogus 'https://www.band.us' URL Co-Authored-By: Claude Sonnet 4.6 --- src/clis/band/mentions.ts | 34 +++++++++++++++++----------------- src/clis/band/posts.ts | 1 + 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/clis/band/mentions.ts b/src/clis/band/mentions.ts index e2ec1cc7..abdc1b9a 100644 --- a/src/clis/band/mentions.ts +++ b/src/clis/band/mentions.ts @@ -38,7 +38,6 @@ cli({ // 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()}`); - await page.wait(2); const isLoggedIn = await page.evaluate(`() => document.cookie.includes('band_session')`); if (!isLoggedIn) throw new AuthRequiredError('band.us', 'Not logged in to Band'); @@ -46,6 +45,17 @@ cli({ // 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 EmptyResultError('band mentions', 'Notification bell not found (selector: button._btnWidgetIcon). 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 @@ -63,27 +73,17 @@ cli({ return captures; }; - // Click the notification bell to open the panel. This triggers an initial get_news - // call for all notification types. Use the CSS class directly — text-based matching - // is locale-dependent and breaks when Band.us is set to a non-Japanese language. - const bellFound = await page.evaluate(`() => { - const bell = document.querySelector('button._btnWidgetIcon'); - if (bell) { bell.click(); return true; } - return false; - }`); - if (!bellFound) { - throw new EmptyResultError('band mentions', 'Notification bell not found (selector: button._btnWidgetIcon). The Band.us UI may have changed.'); - } + // Click the bell — bellReady guarantees the element exists at this point. + await page.evaluate(`() => document.querySelector('button._btnWidgetIcon').click()`); const requests = await waitForOneCapture(); - if (requests.length === 0) { - throw new EmptyResultError('band mentions', 'No notification data captured. Try running the command again.'); - } - // 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; - let items: any[] = newsReq?.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: any[] = newsReq.result_data.news ?? []; if (items.length === 0) { throw new EmptyResultError('band mentions', 'No notifications found'); diff --git a/src/clis/band/posts.ts b/src/clis/band/posts.ts index f103c7b0..5074ae91 100644 --- a/src/clis/band/posts.ts +++ b/src/clis/band/posts.ts @@ -69,6 +69,7 @@ cli({ // 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. From 60e3da67a1a3e493d1d887b671ef7e7725687f76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=AB=E3=83=B3?= Date: Sat, 28 Mar 2026 19:40:56 +0900 Subject: [PATCH 13/22] fix(band): address sixth round of code review feedback - post: only send Band cookies to *.band.us photo URLs; third-party CDN URLs are downloaded without cookies to avoid cross-domain cookie leakage - bands: strip non-digit chars before parseInt so member counts like "1,234" parse correctly - posts: same fix for comment counts Co-Authored-By: Claude Sonnet 4.6 --- src/clis/band/bands.ts | 2 +- src/clis/band/post.ts | 21 ++++++++++++++++----- src/clis/band/posts.ts | 2 +- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/clis/band/bands.ts b/src/clis/band/bands.ts index 8803a03d..2284ac72 100644 --- a/src/clis/band/bands.ts +++ b/src/clis/band/bands.ts @@ -57,7 +57,7 @@ cli({ // Member count is the inside span.member. const memberEl = a.querySelector('span.member em'); - const members = memberEl ? parseInt(memberEl.textContent, 10) || 0 : 0; + const members = memberEl ? parseInt((memberEl.textContent || '').replace(/[^0-9]/g, ''), 10) || 0 : 0; results.push({ band_no: bandNo, name, members }); } diff --git a/src/clis/band/post.ts b/src/clis/band/post.ts index 9c7e3265..155eb207 100644 --- a/src/clis/band/post.ts +++ b/src/clis/band/post.ts @@ -133,11 +133,22 @@ cli({ // 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) { - const cookieHeader = formatCookieHeader(await page.getCookies({ domain: 'band.us' })); - await downloadMedia( - photos.map(url => ({ type: 'image' as const, url })), - { output: outputDir, filenamePrefix: 'photo', verbose: false, cookies: cookieHeader }, - ); + // Only send Band cookies to Band-hosted URLs; avoid leaking auth cookies to third-party CDNs. + const bandPhotos = photos.filter(u => { try { const h = new URL(u).hostname; return h === 'band.us' || h.endsWith('.band.us'); } catch { return false; } }); + const otherPhotos = photos.filter(u => !bandPhotos.includes(u)); + if (bandPhotos.length > 0) { + const cookieHeader = formatCookieHeader(await page.getCookies({ domain: 'band.us' })); + await downloadMedia( + bandPhotos.map(url => ({ type: 'image' as const, url })), + { output: outputDir, filenamePrefix: 'photo', verbose: false, cookies: cookieHeader }, + ); + } + if (otherPhotos.length > 0) { + await downloadMedia( + otherPhotos.map(url => ({ type: 'image' as const, url })), + { output: outputDir, filenamePrefix: 'photo', verbose: false }, + ); + } } const rows: Record[] = []; diff --git a/src/clis/band/posts.ts b/src/clis/band/posts.ts index 5074ae91..33a93299 100644 --- a/src/clis/band/posts.ts +++ b/src/clis/band/posts.ts @@ -86,7 +86,7 @@ cli({ // Comment count is in span.count inside the count area. const commentEl = el.querySelector('span.count'); - const comments = commentEl ? parseInt(commentEl.textContent, 10) || 0 : 0; + const comments = commentEl ? parseInt((commentEl.textContent || '').replace(/[^0-9]/g, ''), 10) || 0 : 0; if (!url && !content) continue; results.push({ date, author, content, comments, url }); From 4660cb9dc461e83cc724f02a5858f65c4b3d7593 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=AB=E3=83=B3?= Date: Sat, 28 Mar 2026 19:52:44 +0900 Subject: [PATCH 14/22] fix(band): address seventh round of code review feedback - posts: check limit before push so --limit 0 returns empty result - post: indent replies proportionally by depth (' '.repeat(depth)) so multi-level threads remain readable in table output Co-Authored-By: Claude Sonnet 4.6 --- src/clis/band/post.ts | 2 +- src/clis/band/posts.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/clis/band/post.ts b/src/clis/band/post.ts index 155eb207..13dc9257 100644 --- a/src/clis/band/post.ts +++ b/src/clis/band/post.ts @@ -170,7 +170,7 @@ cli({ type: c.depth === 0 ? 'comment' : 'reply', author: c.author ?? '', date: c.date ?? '', - text: c.depth > 0 ? ' └ ' + (c.text ?? '') : (c.text ?? ''), + text: c.depth > 0 ? ' '.repeat(c.depth) + '└ ' + (c.text ?? '') : (c.text ?? ''), }); } diff --git a/src/clis/band/posts.ts b/src/clis/band/posts.ts index 33a93299..a437fb9d 100644 --- a/src/clis/band/posts.ts +++ b/src/clis/band/posts.ts @@ -89,8 +89,8 @@ cli({ const comments = commentEl ? parseInt((commentEl.textContent || '').replace(/[^0-9]/g, ''), 10) || 0 : 0; if (!url && !content) continue; - results.push({ date, author, content, comments, url }); if (results.length >= limit) break; + results.push({ date, author, content, comments, url }); } return results; From e40d29782bac4667b08db7bdf36545a1c0dd3a9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=AB=E3=83=B3?= Date: Sat, 28 Mar 2026 20:03:25 +0900 Subject: [PATCH 15/22] fix(band/bands): anchor href regex to prevent matching post-detail URLs Pattern now requires /band/{id} or /band/{id}/post (with optional trailing slash) so deeper paths like /band/{id}/post/{postNo} are excluded. Co-Authored-By: Claude Sonnet 4.6 --- src/clis/band/bands.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/clis/band/bands.ts b/src/clis/band/bands.ts index 2284ac72..c41c3061 100644 --- a/src/clis/band/bands.ts +++ b/src/clis/band/bands.ts @@ -43,8 +43,8 @@ cli({ const results = []; for (const a of Array.from(document.querySelectorAll('a.bandCover._link'))) { - // Extract band_no from href: /band/{id} or /band/{id}/post - const m = (a.getAttribute('href') || '').match(/\\/band\\/(\\d+)/); + // 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; From 46e5efba68e32947b07d3e92358b6537f87a13eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=AB=E3=83=B3?= Date: Sat, 28 Mar 2026 20:23:27 +0900 Subject: [PATCH 16/22] fix(band): address ninth round of code review feedback - mentions: guard bell click with a boolean return so a disappearing element throws a clear EmptyResultError instead of a raw TypeError - post: wait for comment list container instead of first .cComment so posts with zero comments don't incur a fixed 6s delay Co-Authored-By: Claude Sonnet 4.6 --- src/clis/band/mentions.ts | 13 +++++++++++-- src/clis/band/post.ts | 6 ++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/clis/band/mentions.ts b/src/clis/band/mentions.ts index abdc1b9a..4c87cc0e 100644 --- a/src/clis/band/mentions.ts +++ b/src/clis/band/mentions.ts @@ -73,8 +73,17 @@ cli({ return captures; }; - // Click the bell — bellReady guarantees the element exists at this point. - await page.evaluate(`() => document.querySelector('button._btnWidgetIcon').click()`); + // 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 EmptyResultError('band mentions', 'Notification bell disappeared before click (selector: button._btnWidgetIcon). The Band.us UI may have changed.'); + } const requests = await waitForOneCapture(); diff --git a/src/clis/band/post.ts b/src/clis/band/post.ts index 13dc9257..1f867a28 100644 --- a/src/clis/band/post.ts +++ b/src/clis/band/post.ts @@ -93,9 +93,11 @@ cli({ if (!withComments) return { author, date, text, photos, comments: [] }; - // Wait up to 6 s for comments to appear. + // 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 .cComment')) break; + if (postCard?.querySelector('.sCommentList._heightDetectAreaForComment')) break; await sleep(300); } From ce4c11d9d787cca7d565a78cf52b18dfb5b1d134 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=AB=E3=83=B3?= Date: Sat, 28 Mar 2026 20:33:36 +0900 Subject: [PATCH 17/22] fix(band): use page.getCookies() for login detection across all commands Replaces document.cookie.includes('band_session') with page.getCookies({ domain: 'band.us' }) so login detection works even if Band.us marks the session cookie as HttpOnly in the future. Co-Authored-By: Claude Sonnet 4.6 --- src/clis/band/bands.ts | 3 ++- src/clis/band/mentions.ts | 3 ++- src/clis/band/post.ts | 3 ++- src/clis/band/posts.ts | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/clis/band/bands.ts b/src/clis/band/bands.ts index c41c3061..37d3a5d5 100644 --- a/src/clis/band/bands.ts +++ b/src/clis/band/bands.ts @@ -22,7 +22,8 @@ cli({ columns: ['band_no', 'name', 'members'], func: async (page, _kwargs) => { - const isLoggedIn = await page.evaluate(`() => document.cookie.includes('band_session')`); + 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 diff --git a/src/clis/band/mentions.ts b/src/clis/band/mentions.ts index 4c87cc0e..eb332dea 100644 --- a/src/clis/band/mentions.ts +++ b/src/clis/band/mentions.ts @@ -39,7 +39,8 @@ cli({ // and leaving the notification panel open from a previous run). await page.goto(`https://www.band.us/?_=${Date.now()}`); - const isLoggedIn = await page.evaluate(`() => document.cookie.includes('band_session')`); + 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. diff --git a/src/clis/band/post.ts b/src/clis/band/post.ts index 1f867a28..710d8bf7 100644 --- a/src/clis/band/post.ts +++ b/src/clis/band/post.ts @@ -40,7 +40,8 @@ cli({ await page.goto(`https://www.band.us/band/${bandNo}/post/${postNo}`); - const isLoggedIn = await page.evaluate(`() => document.cookie.includes('band_session')`); + 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: { diff --git a/src/clis/band/posts.ts b/src/clis/band/posts.ts index a437fb9d..d35a11a3 100644 --- a/src/clis/band/posts.ts +++ b/src/clis/band/posts.ts @@ -35,7 +35,8 @@ cli({ // Navigate directly to the band's post page — no home-page detour needed. await page.goto(`https://www.band.us/band/${bandNo}/post`); - const isLoggedIn = await page.evaluate(`() => document.cookie.includes('band_session')`); + 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). From 7c9bb3afb6032090f3ff377c552f0fff95bad898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=AB=E3=83=B3?= Date: Sat, 28 Mar 2026 20:42:54 +0900 Subject: [PATCH 18/22] fix(band): address eleventh round of code review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mentions: replace EmptyResultError with SelectorError for missing/ disappeared bell button — produces a clearer SELECTOR error code - post: assign per-photo filenames using a global index across both download batches so band-hosted and CDN photos don't overwrite each other Co-Authored-By: Claude Sonnet 4.6 --- src/clis/band/mentions.ts | 6 +++--- src/clis/band/post.ts | 17 ++++++++++------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/clis/band/mentions.ts b/src/clis/band/mentions.ts index eb332dea..be9b92b2 100644 --- a/src/clis/band/mentions.ts +++ b/src/clis/band/mentions.ts @@ -1,4 +1,4 @@ -import { AuthRequiredError, EmptyResultError } from '../../errors.js'; +import { AuthRequiredError, EmptyResultError, SelectorError } from '../../errors.js'; import { cli, Strategy } from '../../registry.js'; /** @@ -54,7 +54,7 @@ cli({ await page.wait(0.5); } if (!bellReady) { - throw new EmptyResultError('band mentions', 'Notification bell not found (selector: button._btnWidgetIcon). The Band.us UI may have changed.'); + 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. @@ -83,7 +83,7 @@ cli({ return true; }`); if (!bellClicked) { - throw new EmptyResultError('band mentions', 'Notification bell disappeared before click (selector: button._btnWidgetIcon). The Band.us UI may have changed.'); + throw new SelectorError('button._btnWidgetIcon', 'Notification bell disappeared before click. The Band.us UI may have changed.'); } const requests = await waitForOneCapture(); diff --git a/src/clis/band/post.ts b/src/clis/band/post.ts index 710d8bf7..6d9bb04b 100644 --- a/src/clis/band/post.ts +++ b/src/clis/band/post.ts @@ -137,19 +137,22 @@ cli({ // 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. - const bandPhotos = photos.filter(u => { try { const h = new URL(u).hostname; return h === 'band.us' || h.endsWith('.band.us'); } catch { return false; } }); - const otherPhotos = photos.filter(u => !bandPhotos.includes(u)); + // Use a global index across both batches so filenames don't collide (photo_1, photo_2, ...). + const cookieHeader = formatCookieHeader(await page.getCookies({ domain: 'band.us' })); + const isBandUrl = (u: string) => { try { const h = new URL(u).hostname; return h === 'band.us' || h.endsWith('.band.us'); } catch { return false; } }; + let globalIndex = 1; + const bandPhotos = photos.filter(isBandUrl); + const otherPhotos = photos.filter(u => !isBandUrl(u)); if (bandPhotos.length > 0) { - const cookieHeader = formatCookieHeader(await page.getCookies({ domain: 'band.us' })); await downloadMedia( - bandPhotos.map(url => ({ type: 'image' as const, url })), - { output: outputDir, filenamePrefix: 'photo', verbose: false, cookies: cookieHeader }, + bandPhotos.map(url => ({ type: 'image' as const, url, filename: `photo_${globalIndex++}` })), + { output: outputDir, verbose: false, cookies: cookieHeader }, ); } if (otherPhotos.length > 0) { await downloadMedia( - otherPhotos.map(url => ({ type: 'image' as const, url })), - { output: outputDir, filenamePrefix: 'photo', verbose: false }, + otherPhotos.map(url => ({ type: 'image' as const, url, filename: `photo_${globalIndex++}` })), + { output: outputDir, verbose: false }, ); } } From 20f18cc16c3df0a769b6850968f8e870e9954a0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=AB=E3=83=B3?= Date: Sat, 28 Mar 2026 20:51:38 +0900 Subject: [PATCH 19/22] fix(band): address twelfth round of code review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - post: derive file extension from URL path and include in filename (e.g. photo_1.jpg) so downloaded photos have correct extensions - posts: remove dead code guard (!url && !content) — url is always non-empty here since href-empty posts are already skipped above Co-Authored-By: Claude Sonnet 4.6 --- src/clis/band/post.ts | 6 ++++-- src/clis/band/posts.ts | 1 - 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/clis/band/post.ts b/src/clis/band/post.ts index 6d9bb04b..6cdc6396 100644 --- a/src/clis/band/post.ts +++ b/src/clis/band/post.ts @@ -140,18 +140,20 @@ cli({ // Use a global index across both batches so filenames don't collide (photo_1, photo_2, ...). const cookieHeader = formatCookieHeader(await page.getCookies({ domain: '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++}` })), + 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++}` })), + otherPhotos.map(url => ({ type: 'image' as const, url, filename: `photo_${globalIndex++}.${urlExt(url)}` })), { output: outputDir, verbose: false }, ); } diff --git a/src/clis/band/posts.ts b/src/clis/band/posts.ts index d35a11a3..db828ca3 100644 --- a/src/clis/band/posts.ts +++ b/src/clis/band/posts.ts @@ -89,7 +89,6 @@ cli({ const commentEl = el.querySelector('span.count'); const comments = commentEl ? parseInt((commentEl.textContent || '').replace(/[^0-9]/g, ''), 10) || 0 : 0; - if (!url && !content) continue; if (results.length >= limit) break; results.push({ date, author, content, comments, url }); } From 86dd9deba33f96da6ee07987c655d708aace15fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=AB=E3=83=B3?= Date: Sat, 28 Mar 2026 21:02:35 +0900 Subject: [PATCH 20/22] fix(band/post): use url-scoped getCookies for photo download auth Domain-scoped getCookies may omit host-only cookies scoped to www.band.us; using url: 'https://www.band.us' ensures all relevant cookies are included in the auth header for Band-hosted photo downloads. Co-Authored-By: Claude Sonnet 4.6 --- src/clis/band/post.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/clis/band/post.ts b/src/clis/band/post.ts index 6cdc6396..8cd47e59 100644 --- a/src/clis/band/post.ts +++ b/src/clis/band/post.ts @@ -138,7 +138,7 @@ cli({ 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({ domain: 'band.us' })); + 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'; } }; From dd63b5719d941d39e516c0efd12e3ef1cb20aab3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=AB=E3=83=B3?= Date: Sat, 28 Mar 2026 21:07:45 +0900 Subject: [PATCH 21/22] docs(band): add adapter documentation and sidebar entry Required by CI doc-check --strict: every adapter in src/clis/ must have a corresponding docs/adapters/browser/*.md file. Co-Authored-By: Claude Sonnet 4.6 --- docs/.vitepress/config.mts | 1 + docs/adapters/browser/band.md | 63 +++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 docs/adapters/browser/band.md 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) From b73e833732ef83eac614a18834bdaca2b817f3e2 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 29 Mar 2026 17:18:08 +0800 Subject: [PATCH 22/22] test(e2e): wire band auth coverage into default matrix --- tests/e2e/band-auth.test.ts | 20 +++++++ tests/e2e/browser-auth-helpers.ts | 18 ++++++ tests/e2e/browser-auth.test.ts | 95 ++++++++++--------------------- vitest.config.ts | 1 + 4 files changed, 70 insertions(+), 64 deletions(-) create mode 100644 tests/e2e/band-auth.test.ts create mode 100644 tests/e2e/browser-auth-helpers.ts diff --git a/tests/e2e/band-auth.test.ts b/tests/e2e/band-auth.test.ts new file mode 100644 index 00000000..0bd4a208 --- /dev/null +++ b/tests/e2e/band-auth.test.ts @@ -0,0 +1,20 @@ +import { describe, it } from 'vitest'; +import { expectGracefulAuthFailure } from './browser-auth-helpers.js'; + +describe('band auth-required commands — graceful failure', () => { + it('band bands fails gracefully without login', async () => { + await expectGracefulAuthFailure(['band', 'bands', '-f', 'json']); + }, 60_000); + + it('band mentions fails gracefully without login', async () => { + await expectGracefulAuthFailure(['band', 'mentions', '--limit', '3', '-f', 'json']); + }, 60_000); + + it('band posts fails gracefully without login', async () => { + await expectGracefulAuthFailure(['band', 'posts', '58400480', '--limit', '3', '-f', 'json']); + }, 60_000); + + it('band post fails gracefully without login', async () => { + await expectGracefulAuthFailure(['band', 'post', '58400480', '1', '-f', 'json']); + }, 60_000); +}); diff --git a/tests/e2e/browser-auth-helpers.ts b/tests/e2e/browser-auth-helpers.ts new file mode 100644 index 00000000..50776491 --- /dev/null +++ b/tests/e2e/browser-auth-helpers.ts @@ -0,0 +1,18 @@ +import { expect } from 'vitest'; +import { runCli } from './helpers.js'; + +/** + * Verify a login-required command fails gracefully (no crash, no hang). + * Acceptable outcomes: exit code 1 with error message, OR timeout handled. + */ +export async function expectGracefulAuthFailure(args: string[]) { + const { stdout, stderr, code } = await runCli(args, { timeout: 60_000 }); + // Should either fail with exit code 1 (error message) or succeed with empty data. + // The key assertion: it should NOT hang forever or crash with unhandled exception. + if (code !== 0) { + // Verify stderr has a meaningful error, not an unhandled crash. + const output = stderr + stdout; + expect(output.length).toBeGreaterThan(0); + } + // If it somehow succeeds (e.g., partial public data), that's fine too. +} diff --git a/tests/e2e/browser-auth.test.ts b/tests/e2e/browser-auth.test.ts index e1d2e898..32cd8eaf 100644 --- a/tests/e2e/browser-auth.test.ts +++ b/tests/e2e/browser-auth.test.ts @@ -6,165 +6,132 @@ * These tests verify the error handling path, not the data extraction. */ -import { describe, it, expect } from 'vitest'; -import { runCli } from './helpers.js'; - -/** - * Verify a login-required command fails gracefully (no crash, no hang). - * Acceptable outcomes: exit code 1 with error message, OR timeout handled. - */ -async function expectGracefulAuthFailure(args: string[], label: string) { - const { stdout, stderr, code } = await runCli(args, { timeout: 60_000 }); - // Should either fail with exit code 1 (error message) or succeed with empty data - // The key assertion: it should NOT hang forever or crash with unhandled exception - if (code !== 0) { - // Verify stderr has a meaningful error, not an unhandled crash - const output = stderr + stdout; - expect(output.length).toBeGreaterThan(0); - } - // If it somehow succeeds (e.g., partial public data), that's fine too -} +import { describe, it } from 'vitest'; +import { expectGracefulAuthFailure } from './browser-auth-helpers.js'; describe('login-required commands — graceful failure', () => { // ── bilibili (requires cookie session) ── it('bilibili me fails gracefully without login', async () => { - await expectGracefulAuthFailure(['bilibili', 'me', '-f', 'json'], 'bilibili me'); + await expectGracefulAuthFailure(['bilibili', 'me', '-f', 'json']); }, 60_000); it('bilibili dynamic fails gracefully without login', async () => { - await expectGracefulAuthFailure(['bilibili', 'dynamic', '--limit', '3', '-f', 'json'], 'bilibili dynamic'); + await expectGracefulAuthFailure(['bilibili', 'dynamic', '--limit', '3', '-f', 'json']); }, 60_000); it('bilibili favorite fails gracefully without login', async () => { - await expectGracefulAuthFailure(['bilibili', 'favorite', '--limit', '3', '-f', 'json'], 'bilibili favorite'); + await expectGracefulAuthFailure(['bilibili', 'favorite', '--limit', '3', '-f', 'json']); }, 60_000); it('bilibili history fails gracefully without login', async () => { - await expectGracefulAuthFailure(['bilibili', 'history', '--limit', '3', '-f', 'json'], 'bilibili history'); + await expectGracefulAuthFailure(['bilibili', 'history', '--limit', '3', '-f', 'json']); }, 60_000); it('bilibili following fails gracefully without login', async () => { - await expectGracefulAuthFailure(['bilibili', 'following', '--limit', '3', '-f', 'json'], 'bilibili following'); + await expectGracefulAuthFailure(['bilibili', 'following', '--limit', '3', '-f', 'json']); }, 60_000); // ── twitter (requires login) ── it('twitter bookmarks fails gracefully without login', async () => { - await expectGracefulAuthFailure(['twitter', 'bookmarks', '--limit', '3', '-f', 'json'], 'twitter bookmarks'); + await expectGracefulAuthFailure(['twitter', 'bookmarks', '--limit', '3', '-f', 'json']); }, 60_000); it('twitter timeline fails gracefully without login', async () => { - await expectGracefulAuthFailure(['twitter', 'timeline', '--limit', '3', '-f', 'json'], 'twitter timeline'); + await expectGracefulAuthFailure(['twitter', 'timeline', '--limit', '3', '-f', 'json']); }, 60_000); it('twitter notifications fails gracefully without login', async () => { - await expectGracefulAuthFailure(['twitter', 'notifications', '--limit', '3', '-f', 'json'], 'twitter notifications'); + await expectGracefulAuthFailure(['twitter', 'notifications', '--limit', '3', '-f', 'json']); }, 60_000); // ── v2ex (requires login) ── it('v2ex me fails gracefully without login', async () => { - await expectGracefulAuthFailure(['v2ex', 'me', '-f', 'json'], 'v2ex me'); + await expectGracefulAuthFailure(['v2ex', 'me', '-f', 'json']); }, 60_000); it('v2ex notifications fails gracefully without login', async () => { - await expectGracefulAuthFailure(['v2ex', 'notifications', '--limit', '3', '-f', 'json'], 'v2ex notifications'); + await expectGracefulAuthFailure(['v2ex', 'notifications', '--limit', '3', '-f', 'json']); }, 60_000); // ── xueqiu (requires login) ── it('xueqiu feed fails gracefully without login', async () => { - await expectGracefulAuthFailure(['xueqiu', 'feed', '--limit', '3', '-f', 'json'], 'xueqiu feed'); + await expectGracefulAuthFailure(['xueqiu', 'feed', '--limit', '3', '-f', 'json']); }, 60_000); it('xueqiu watchlist fails gracefully without login', async () => { - await expectGracefulAuthFailure(['xueqiu', 'watchlist', '-f', 'json'], 'xueqiu watchlist'); + await expectGracefulAuthFailure(['xueqiu', 'watchlist', '-f', 'json']); }, 60_000); // ── linux-do (requires login — all endpoints need authentication) ── it('linux-do feed fails gracefully without login', async () => { - await expectGracefulAuthFailure(['linux-do', 'feed', '--limit', '3', '-f', 'json'], 'linux-do feed'); + await expectGracefulAuthFailure(['linux-do', 'feed', '--limit', '3', '-f', 'json']); }, 60_000); it('linux-do categories fails gracefully without login', async () => { - await expectGracefulAuthFailure(['linux-do', 'categories', '--limit', '3', '-f', 'json'], 'linux-do categories'); + await expectGracefulAuthFailure(['linux-do', 'categories', '--limit', '3', '-f', 'json']); }, 60_000); it('linux-do tags fails gracefully without login', async () => { - await expectGracefulAuthFailure(['linux-do', 'tags', '--limit', '3', '-f', 'json'], 'linux-do tags'); + await expectGracefulAuthFailure(['linux-do', 'tags', '--limit', '3', '-f', 'json']); }, 60_000); it('linux-do topic fails gracefully without login', async () => { - await expectGracefulAuthFailure(['linux-do', 'topic', '1', '-f', 'json'], 'linux-do topic'); + await expectGracefulAuthFailure(['linux-do', 'topic', '1', '-f', 'json']); }, 60_000); it('linux-do search fails gracefully without login', async () => { - await expectGracefulAuthFailure(['linux-do', 'search', 'test', '--limit', '3', '-f', 'json'], 'linux-do search'); + await expectGracefulAuthFailure(['linux-do', 'search', 'test', '--limit', '3', '-f', 'json']); }, 60_000); it('linux-do user-topics fails gracefully without login', async () => { - await expectGracefulAuthFailure(['linux-do', 'user-topics', 'test', '--limit', '3', '-f', 'json'], 'linux-do user-topics'); + await expectGracefulAuthFailure(['linux-do', 'user-topics', 'test', '--limit', '3', '-f', 'json']); }, 60_000); it('linux-do user-posts fails gracefully without login', async () => { - await expectGracefulAuthFailure(['linux-do', 'user-posts', 'test', '--limit', '3', '-f', 'json'], 'linux-do user-posts'); + await expectGracefulAuthFailure(['linux-do', 'user-posts', 'test', '--limit', '3', '-f', 'json']); }, 60_000); // ── xiaohongshu (requires login) ── it('xiaohongshu feed fails gracefully without login', async () => { - await expectGracefulAuthFailure(['xiaohongshu', 'feed', '--limit', '3', '-f', 'json'], 'xiaohongshu feed'); + await expectGracefulAuthFailure(['xiaohongshu', 'feed', '--limit', '3', '-f', 'json']); }, 60_000); it('xiaohongshu notifications fails gracefully without login', async () => { - await expectGracefulAuthFailure(['xiaohongshu', 'notifications', '--limit', '3', '-f', 'json'], 'xiaohongshu notifications'); + await expectGracefulAuthFailure(['xiaohongshu', 'notifications', '--limit', '3', '-f', 'json']); }, 60_000); // ── pixiv (requires login) ── it('pixiv ranking fails gracefully without login', async () => { - await expectGracefulAuthFailure(['pixiv', 'ranking', '--limit', '3', '-f', 'json'], 'pixiv ranking'); + await expectGracefulAuthFailure(['pixiv', 'ranking', '--limit', '3', '-f', 'json']); }, 60_000); it('pixiv search fails gracefully without login', async () => { - await expectGracefulAuthFailure(['pixiv', 'search', '初音ミク', '--limit', '3', '-f', 'json'], 'pixiv search'); + await expectGracefulAuthFailure(['pixiv', 'search', '初音ミク', '--limit', '3', '-f', 'json']); }, 60_000); it('pixiv user fails gracefully without login', async () => { - await expectGracefulAuthFailure(['pixiv', 'user', '11', '-f', 'json'], 'pixiv user'); + await expectGracefulAuthFailure(['pixiv', 'user', '11', '-f', 'json']); }, 60_000); it('pixiv illusts fails gracefully without login', async () => { - await expectGracefulAuthFailure(['pixiv', 'illusts', '11', '--limit', '3', '-f', 'json'], 'pixiv illusts'); + await expectGracefulAuthFailure(['pixiv', 'illusts', '11', '--limit', '3', '-f', 'json']); }, 60_000); it('pixiv detail fails gracefully without login', async () => { - await expectGracefulAuthFailure(['pixiv', 'detail', '123456', '-f', 'json'], 'pixiv detail'); + await expectGracefulAuthFailure(['pixiv', 'detail', '123456', '-f', 'json']); }, 60_000); it('pixiv download fails gracefully without login', async () => { - await expectGracefulAuthFailure(['pixiv', 'download', '123456', '--output', '/tmp/pixiv-e2e-test', '-f', 'json'], 'pixiv download'); + await expectGracefulAuthFailure(['pixiv', 'download', '123456', '--output', '/tmp/pixiv-e2e-test', '-f', 'json']); }, 60_000); // ── yollomi (requires login session) ── it('yollomi generate fails gracefully without login', async () => { - await expectGracefulAuthFailure(['yollomi', 'generate', 'a cute cat', '--no-download', '-f', 'json'], 'yollomi generate'); + await expectGracefulAuthFailure(['yollomi', 'generate', 'a cute cat', '--no-download', '-f', 'json']); }, 60_000); 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'); + await expectGracefulAuthFailure(['yollomi', 'video', 'a sunset over the ocean', '--no-download', '-f', 'json']); }, 60_000); }); diff --git a/vitest.config.ts b/vitest.config.ts index 48154835..8b22bdc8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -25,6 +25,7 @@ export default defineConfig({ name: 'e2e', include: [ 'tests/e2e/browser-public.test.ts', + 'tests/e2e/band-auth.test.ts', 'tests/e2e/public-commands.test.ts', 'tests/e2e/management.test.ts', 'tests/e2e/output-formats.test.ts',