-
Notifications
You must be signed in to change notification settings - Fork 687
feat(band): add Band.us adapter — bands, posts, mentions, post commands #532
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
kanghouchao
wants to merge
21
commits into
jackwener:main
Choose a base branch
from
kanghouchao:feat/band
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+584
−0
Open
Changes from all commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
31a819e
feat(band): add bands, posts, and mentions commands for band.us
kanghouchao 7184225
refactor(band): clean up all three band adapters
kanghouchao 8894ad9
fix(band/posts): handle mixed post/announcement items from API
kanghouchao 8c637c9
feat(band): add post command — full post export with comments and pho…
kanghouchao 5688f89
refactor(band): replace XHR interception with direct DOM extraction
kanghouchao acb1b14
fix(band): address code review feedback
kanghouchao 946fb52
fix(band): address second round of code review feedback
kanghouchao 23e72e5
fix(band): fix selector bugs found during testing
kanghouchao 1710178
fix(band/mentions): use CSS class selector for bell button instead of…
kanghouchao 071b647
fix(band): address third round of code review feedback
kanghouchao 3268287
fix(band): address fourth round of code review feedback
kanghouchao 1a3e807
fix(band): address fifth round of code review feedback
kanghouchao 0ae2c15
fix(band): address sixth round of code review feedback
kanghouchao 861ab4e
fix(band): address seventh round of code review feedback
kanghouchao 886ecec
fix(band/bands): anchor href regex to prevent matching post-detail URLs
kanghouchao 7f4bb40
fix(band): address ninth round of code review feedback
kanghouchao aa4f355
fix(band): use page.getCookies() for login detection across all commands
kanghouchao f5b8268
fix(band): address eleventh round of code review feedback
kanghouchao 377046c
fix(band): address twelfth round of code review feedback
kanghouchao e39564b
fix(band/post): use url-scoped getCookies for photo download auth
kanghouchao 314101f
docs(band): add adapter documentation and sidebar entry
kanghouchao File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <band_no>` | List posts from a Band | | ||
| | `opencli band post <band_no> <post_no>` | 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| import { AuthRequiredError, EmptyResultError } from '../../errors.js'; | ||
| import { cli, Strategy } from '../../registry.js'; | ||
|
|
||
| /** | ||
| * band bands — List all Bands you belong to. | ||
| * | ||
| * Band.us renders the full band list in the left sidebar of the home page for | ||
| * logged-in users, so we can extract everything we need from the DOM without | ||
| * XHR interception or any secondary navigation. | ||
| * | ||
| * Each sidebar item is an <a href="/band/{band_no}/..."> link whose text and | ||
| * data attributes carry the band name and member count. | ||
| */ | ||
| cli({ | ||
| site: 'band', | ||
| name: 'bands', | ||
| description: 'List all Bands you belong to', | ||
| domain: 'www.band.us', | ||
| strategy: Strategy.COOKIE, | ||
| browser: true, | ||
| args: [], | ||
| columns: ['band_no', 'name', 'members'], | ||
|
|
||
| func: async (page, _kwargs) => { | ||
| const cookies = await page.getCookies({ domain: 'band.us' }); | ||
| const isLoggedIn = cookies.some(c => c.name === 'band_session'); | ||
| if (!isLoggedIn) throw new AuthRequiredError('band.us', 'Not logged in to Band'); | ||
|
|
||
| // Extract the band list from the sidebar. Poll until at least one band card | ||
| // appears (React hydration may take a moment after navigation). | ||
| // Sidebar band cards use class "bandCover _link" with hrefs like /band/{id}/post. | ||
| const bands: { band_no: number; name: string; members: number }[] = await page.evaluate(` | ||
| (async () => { | ||
| const sleep = ms => new Promise(r => setTimeout(r, ms)); | ||
|
|
||
| // Wait up to 9 s for sidebar band cards to render. | ||
| for (let i = 0; i < 30; i++) { | ||
| if (document.querySelector('a.bandCover._link')) break; | ||
| await sleep(300); | ||
| } | ||
|
|
||
| const norm = s => (s || '').replace(/\\s+/g, ' ').trim(); | ||
| const seen = new Set(); | ||
| const results = []; | ||
|
|
||
| for (const a of Array.from(document.querySelectorAll('a.bandCover._link'))) { | ||
| // Extract band_no from href: /band/{id} or /band/{id}/post only. | ||
| const m = (a.getAttribute('href') || '').match(/^\\/band\\/(\\d+)(?:\\/post)?\\/?$/); | ||
| if (!m) continue; | ||
| const bandNo = Number(m[1]); | ||
| if (seen.has(bandNo)) continue; | ||
| seen.add(bandNo); | ||
|
|
||
| // Band name lives in p.uriText inside div.bandName. | ||
| const nameEl = a.querySelector('p.uriText'); | ||
| const name = nameEl ? norm(nameEl.textContent) : ''; | ||
| if (!name) continue; | ||
|
|
||
| // Member count is the <em> inside span.member. | ||
| const memberEl = a.querySelector('span.member em'); | ||
| const members = memberEl ? parseInt((memberEl.textContent || '').replace(/[^0-9]/g, ''), 10) || 0 : 0; | ||
|
|
||
| results.push({ band_no: bandNo, name, members }); | ||
| } | ||
|
|
||
| return results; | ||
| })() | ||
| `); | ||
|
|
||
| if (!bands || bands.length === 0) { | ||
| throw new EmptyResultError('band bands', 'No bands found in sidebar — are you logged in?'); | ||
| } | ||
|
|
||
| return bands; | ||
| }, | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| import { AuthRequiredError, EmptyResultError, SelectorError } from '../../errors.js'; | ||
| import { cli, Strategy } from '../../registry.js'; | ||
|
|
||
| /** | ||
| * band mentions — Show Band notifications where you were @mentioned. | ||
| * | ||
| * Band.us signs every API request with a per-request HMAC (`md` header) generated | ||
| * by its own JavaScript, so we cannot replicate it externally. Instead we use | ||
| * Strategy.INTERCEPT: install an XHR interceptor, open the notification panel by | ||
| * clicking the bell to trigger the get_news XHR call, then apply client-side | ||
| * filtering to extract notifications matching the requested filter/unread options. | ||
| */ | ||
| cli({ | ||
| site: 'band', | ||
| name: 'mentions', | ||
| description: 'Show Band notifications where you are @mentioned', | ||
| domain: 'www.band.us', | ||
| strategy: Strategy.INTERCEPT, | ||
| browser: true, | ||
| args: [ | ||
kanghouchao marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| { | ||
| name: 'filter', | ||
| default: 'mentioned', | ||
| choices: ['mentioned', 'all', 'post', 'comment'], | ||
| help: 'Filter: mentioned (default) | all | post | comment', | ||
| }, | ||
| { name: 'limit', type: 'int', default: 20, help: 'Max results' }, | ||
| { name: 'unread', type: 'bool', default: false, help: 'Show only unread notifications' }, | ||
| ], | ||
| columns: ['time', 'band', 'type', 'from', 'text', 'url'], | ||
|
|
||
| func: async (page, kwargs) => { | ||
| const filter = kwargs.filter as string; | ||
| const limit = kwargs.limit as number; | ||
| const unreadOnly = kwargs.unread as boolean; | ||
|
|
||
| // Navigate with a timestamp param to force a fresh page load each run. | ||
| // Without this, same-URL navigation may skip the reload (preserving the JS context | ||
| // and leaving the notification panel open from a previous run). | ||
| await page.goto(`https://www.band.us/?_=${Date.now()}`); | ||
|
|
||
| const cookies = await page.getCookies({ domain: 'band.us' }); | ||
| const isLoggedIn = cookies.some(c => c.name === 'band_session'); | ||
| if (!isLoggedIn) throw new AuthRequiredError('band.us', 'Not logged in to Band'); | ||
|
|
||
| // Install XHR interceptor before any clicks so all get_news responses are captured. | ||
| await page.installInterceptor('get_news'); | ||
|
|
||
| // Wait for the bell button to appear (React hydration) instead of a fixed sleep. | ||
| let bellReady = false; | ||
| for (let i = 0; i < 20; i++) { | ||
| const exists = await page.evaluate(`() => !!document.querySelector('button._btnWidgetIcon')`); | ||
| if (exists) { bellReady = true; break; } | ||
| await page.wait(0.5); | ||
| } | ||
| if (!bellReady) { | ||
| throw new SelectorError('button._btnWidgetIcon', 'Notification bell not found. The Band.us UI may have changed.'); | ||
| } | ||
|
|
||
| // Poll until a capture containing result_data.news arrives, up to maxSecs seconds. | ||
| // getInterceptedRequests() clears the array on each call, so captures are accumulated | ||
| // locally. The interceptor pattern 'get_news' also matches 'get_news_count' responses | ||
| // which don't have result_data.news — keep polling until the real news response arrives. | ||
| const waitForOneCapture = async (maxSecs = 8): Promise<any[]> => { | ||
| const captures: any[] = []; | ||
| for (let i = 0; i < maxSecs * 2; i++) { | ||
| await page.wait(0.5); // 0.5 seconds per iteration (page.wait takes seconds) | ||
| const reqs = await page.getInterceptedRequests(); | ||
| if (reqs.length > 0) { | ||
| captures.push(...reqs); | ||
| if (captures.some((r: any) => Array.isArray(r?.result_data?.news))) return captures; | ||
| } | ||
| } | ||
| return captures; | ||
| }; | ||
|
|
||
| // Click the bell. Guard against the element disappearing between the readiness | ||
| // check and the click (e.g. due to a React re-render) to surface a clear error. | ||
| const bellClicked = await page.evaluate(`() => { | ||
| const el = document.querySelector('button._btnWidgetIcon'); | ||
| if (!el) return false; | ||
| el.click(); | ||
| return true; | ||
| }`); | ||
| if (!bellClicked) { | ||
| throw new SelectorError('button._btnWidgetIcon', 'Notification bell disappeared before click. The Band.us UI may have changed.'); | ||
| } | ||
|
|
||
| const requests = await waitForOneCapture(); | ||
|
|
||
| // Find the get_news response (has result_data.news); get_news_count responses do not. | ||
| const newsReq = requests.find((r: any) => Array.isArray(r?.result_data?.news)) as any; | ||
| if (!newsReq) { | ||
| throw new EmptyResultError('band mentions', 'Failed to capture get_news response from Band.us. Try running the command again.'); | ||
| } | ||
| let items: any[] = newsReq.result_data.news ?? []; | ||
|
|
||
| if (items.length === 0) { | ||
| throw new EmptyResultError('band mentions', 'No notifications found'); | ||
| } | ||
|
|
||
| // Apply filters client-side from the full notification list. | ||
| if (unreadOnly) { | ||
| items = items.filter((n: any) => n.is_new === true); | ||
| } | ||
| if (filter === 'mentioned') { | ||
| // 'filters' is Band's server-side tag array; 'referred' means you were @mentioned. | ||
| items = items.filter((n: any) => n.filters?.includes('referred')); | ||
| } else if (filter === 'post') { | ||
| items = items.filter((n: any) => n.category === 'post'); | ||
| } else if (filter === 'comment') { | ||
| items = items.filter((n: any) => n.category === 'comment'); | ||
| } | ||
|
|
||
| // Band markup tags (<band:mention uid="...">, <band:sticker>, etc.) appear in | ||
| // notification text; strip them to get plain readable content. | ||
| const stripBandTags = (s: string) => s.replace(/<\/?band:[^>]+>/g, ''); | ||
|
|
||
| return items.slice(0, limit).map((n: any) => { | ||
| const ts = n.created_at ? new Date(n.created_at) : null; | ||
| return { | ||
| time: ts | ||
| ? ts.toLocaleString('ja-JP', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) | ||
| : '', | ||
| band: n.band?.name ?? '', | ||
| // 'filters' is Band's server-side tag array; 'referred' means you were @mentioned. | ||
| type: n.filters?.includes('referred') ? '@mention' : n.category ?? '', | ||
| from: n.actor?.name ?? '', | ||
| text: stripBandTags(n.subtext ?? '').slice(0, 100), | ||
| url: n.action?.pc ?? '', | ||
| }; | ||
| }); | ||
| }, | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.