diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cd2f78..0468384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - `user-tweets` command to fetch a user's profile timeline (#34) — thanks @crcatala. - `replies` and `thread` now support pagination (`--all`, `--max-pages`, `--cursor`, `--delay`) (#35) — thanks @crcatala. - `search` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#42) — thanks @pjtf93. +- `about` command for account origin/location details (#51) — thanks @pjtf93. - `likes` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#44) — thanks @jsholmes. - `list-timeline` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#30) — thanks @zheli. - Rich text output now shows article previews, quoted tweets, and media links (#32) — thanks @odysseus0. diff --git a/docs/testing.md b/docs/testing.md index 8ff0051..39e9a87 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -22,6 +22,7 @@ Run: Notes: - Live tests are skipped unless `BIRD_LIVE=1` (set by `pnpm test:live`). - Search query is configurable via `BIRD_LIVE_SEARCH_QUERY`. +- About account handle is configurable via `BIRD_LIVE_ABOUT_HANDLE`. - Command timeout is configurable via `BIRD_LIVE_TIMEOUT_MS` (ms). - Spawned CLI `NODE_ENV` defaults to `production` (override with `BIRD_LIVE_NODE_ENV`). - If you don't tweet, set `BIRD_LIVE_TWEET_ID` to a known tweet ID to use for `read/replies/thread`. diff --git a/src/cli/program.ts b/src/cli/program.ts index cac36ec..00d3f4f 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -96,7 +96,7 @@ export function createProgram(ctx: CliContext): Command { ].join('\n\n')}\n\n${ctx.colors.section('Shortcuts')}\n${[ formatExample('bird [--json]', 'Shorthand for `bird read `'), ].join('\n\n')}\n\n${ctx.colors.section('JSON Output')}\n${ctx.colors.muted( - ` Add ${ctx.colors.option('--json')} to: read, replies, thread, search, mentions, bookmarks, likes, following, followers, lists, list-timeline, user-tweets, query-ids`, + ` Add ${ctx.colors.option('--json')} to: read, replies, thread, search, mentions, bookmarks, likes, following, followers, about, lists, list-timeline, user-tweets, query-ids`, )}\n${ctx.colors.muted( ` Add ${ctx.colors.option('--json-full')} to include raw API response in ${ctx.colors.argument('_raw')} field (tweet commands only)`, )}\n${ctx.colors.muted(` (Run ${ctx.colors.command('bird --help')} to see per-command flags.)`)}`, diff --git a/src/commands/users.ts b/src/commands/users.ts index 6924920..cef4888 100644 --- a/src/commands/users.ts +++ b/src/commands/users.ts @@ -1,8 +1,11 @@ import type { Command } from 'commander'; import type { CliContext } from '../cli/shared.js'; +import { normalizeHandle } from '../lib/normalize-handle.js'; import { TwitterClient } from '../lib/twitter-client.js'; import type { TwitterUser } from '../lib/twitter-client-types.js'; +const LEADING_AT_REGEX = /^@+/; + type PagedUsersResult = { success: boolean; users?: TwitterUser[]; @@ -289,6 +292,7 @@ export function registerUserCommands(program: Command, ctx: CliContext): void { .action(async (username: string, cmdOpts: { json?: boolean }) => { const opts = program.opts(); const timeoutMs = ctx.resolveTimeoutFromOptions(opts); + const normalizedHandle = normalizeHandle(username); const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); @@ -309,7 +313,8 @@ export function registerUserCommands(program: Command, ctx: CliContext): void { console.log(JSON.stringify(result.aboutProfile, null, 2)); } else { const profile = result.aboutProfile; - console.log(`${ctx.p('info')}Account information for @${username}:`); + const displayHandle = normalizedHandle ?? username.replace(LEADING_AT_REGEX, ''); + console.log(`${ctx.p('info')}Account information for @${displayHandle}:`); if (profile.accountBasedIn) { console.log(` Account based in: ${profile.accountBasedIn}`); } diff --git a/src/lib/twitter-client-user-lookup.ts b/src/lib/twitter-client-user-lookup.ts index 231611b..ff349f1 100644 --- a/src/lib/twitter-client-user-lookup.ts +++ b/src/lib/twitter-client-user-lookup.ts @@ -231,72 +231,97 @@ export function withUserLookup { + let lastError: string | undefined; + let had404 = false; + const queryIds = await this.getAboutAccountQueryIds(); - for (const queryId of queryIds) { - const url = `${TWITTER_API_BASE}/${queryId}/AboutAccountQuery?${params.toString()}`; + for (const queryId of queryIds) { + const url = `${TWITTER_API_BASE}/${queryId}/AboutAccountQuery?${params.toString()}`; - try { - const response = await this.fetchWithTimeout(url, { - method: 'GET', - headers: this.getHeaders(), - }); + try { + const response = await this.fetchWithTimeout(url, { + method: 'GET', + headers: this.getHeaders(), + }); - if (!response.ok) { - const text = await response.text(); - if (response.status === 404) { - lastError = `HTTP ${response.status}`; + if (!response.ok) { + const text = await response.text(); + if (response.status === 404) { + had404 = true; + lastError = `HTTP ${response.status}`; + continue; + } + lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`; continue; } - lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`; - continue; - } - const data = (await response.json()) as { - data?: { - user_result_by_screen_name?: { - result?: { - about_profile?: { - account_based_in?: string; - source?: string; - created_country_accurate?: boolean; - location_accurate?: boolean; - learn_more_url?: string; + const data = (await response.json()) as { + data?: { + user_result_by_screen_name?: { + result?: { + about_profile?: { + account_based_in?: string; + source?: string; + created_country_accurate?: boolean; + location_accurate?: boolean; + learn_more_url?: string; + }; }; }; }; + errors?: Array<{ message: string }>; }; - errors?: Array<{ message: string }>; - }; - if (data.errors && data.errors.length > 0) { - lastError = data.errors.map((e) => e.message).join(', '); - continue; + if (data.errors && data.errors.length > 0) { + lastError = data.errors.map((e) => e.message).join(', '); + continue; + } + + const aboutProfile = data.data?.user_result_by_screen_name?.result?.about_profile; + if (!aboutProfile) { + lastError = 'Missing about_profile in response'; + continue; + } + + return { + success: true as const, + aboutProfile: { + accountBasedIn: aboutProfile.account_based_in, + source: aboutProfile.source, + createdCountryAccurate: aboutProfile.created_country_accurate, + locationAccurate: aboutProfile.location_accurate, + learnMoreUrl: aboutProfile.learn_more_url, + }, + had404, + }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); } - - const aboutProfile = data.data?.user_result_by_screen_name?.result?.about_profile; - if (!aboutProfile) { - lastError = 'Missing about_profile in response'; - continue; - } - - return { - success: true, - aboutProfile: { - accountBasedIn: aboutProfile.account_based_in, - source: aboutProfile.source, - createdCountryAccurate: aboutProfile.created_country_accurate, - locationAccurate: aboutProfile.location_accurate, - learnMoreUrl: aboutProfile.learn_more_url, - }, - }; - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); } + + return { + success: false as const, + error: lastError ?? 'Unknown error fetching account details', + had404, + }; + }; + + const firstAttempt = await tryOnce(); + if (firstAttempt.success) { + return { success: true, aboutProfile: firstAttempt.aboutProfile }; } - return { success: false, error: lastError ?? 'Unknown error fetching account details' }; + if (firstAttempt.had404) { + await this.refreshQueryIds(); + const secondAttempt = await tryOnce(); + if (secondAttempt.success) { + return { success: true, aboutProfile: secondAttempt.aboutProfile }; + } + return { success: false, error: secondAttempt.error }; + } + + return { success: false, error: firstAttempt.error }; } } diff --git a/tests/live/live.test.ts b/tests/live/live.test.ts index cc6ca38..24525ec 100644 --- a/tests/live/live.test.ts +++ b/tests/live/live.test.ts @@ -149,6 +149,17 @@ d('live CLI (Twitter/X)', () => { expect(whoamiStdout).toContain('credentials:'); }); + it('about returns account JSON', async () => { + const aboutHandle = (process.env.BIRD_LIVE_ABOUT_HANDLE ?? handle).trim() || handle; + const about = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'about', aboutHandle, '--json'], { + timeoutMs: 45_000, + }); + expect(about.exitCode).toBe(0); + const payload = parseJson>(about.stdout); + expect(Array.isArray(payload)).toBe(false); + expect(Object.keys(payload).length).toBeGreaterThan(0); + }); + it('read returns tweet JSON', async () => { const read = await runBird([...baseArgs, 'read', tweetId, '--json'], { timeoutMs: 45_000, diff --git a/tests/twitter-client.user-about.test.ts b/tests/twitter-client.user-about.test.ts index c5c6234..33803a3 100644 --- a/tests/twitter-client.user-about.test.ts +++ b/tests/twitter-client.user-about.test.ts @@ -119,7 +119,7 @@ describe('TwitterClient getUserAboutAccount', () => { }); it('handles HTTP 404 error', async () => { - mockFetch.mockResolvedValueOnce({ + mockFetch.mockResolvedValue({ ok: false, status: 404, text: async () => 'Not Found',