fix: about account live coverage (#51) (thanks @pjtf93)

This commit is contained in:
Peter Steinberger
2026-06-24 18:42:16 +09:00
committed by yuta
parent 058aa0e3c3
commit 77c7d676a7
7 changed files with 97 additions and 54 deletions
+1
View File
@@ -8,6 +8,7 @@
- `user-tweets` command to fetch a user's profile timeline (#34) — thanks @crcatala. - `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. - `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. - `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. - `likes` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#44) — thanks @jsholmes.
- `list-timeline` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#30) — thanks @zheli. - `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. - Rich text output now shows article previews, quoted tweets, and media links (#32) — thanks @odysseus0.
+1
View File
@@ -22,6 +22,7 @@ Run:
Notes: Notes:
- Live tests are skipped unless `BIRD_LIVE=1` (set by `pnpm test:live`). - Live tests are skipped unless `BIRD_LIVE=1` (set by `pnpm test:live`).
- Search query is configurable via `BIRD_LIVE_SEARCH_QUERY`. - 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). - Command timeout is configurable via `BIRD_LIVE_TIMEOUT_MS` (ms).
- Spawned CLI `NODE_ENV` defaults to `production` (override with `BIRD_LIVE_NODE_ENV`). - 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`. - If you don't tweet, set `BIRD_LIVE_TWEET_ID` to a known tweet ID to use for `read/replies/thread`.
+1 -1
View File
@@ -96,7 +96,7 @@ export function createProgram(ctx: CliContext): Command {
].join('\n\n')}\n\n${ctx.colors.section('Shortcuts')}\n${[ ].join('\n\n')}\n\n${ctx.colors.section('Shortcuts')}\n${[
formatExample('bird <tweet-id-or-url> [--json]', 'Shorthand for `bird read <tweet-id-or-url>`'), formatExample('bird <tweet-id-or-url> [--json]', 'Shorthand for `bird read <tweet-id-or-url>`'),
].join('\n\n')}\n\n${ctx.colors.section('JSON Output')}\n${ctx.colors.muted( ].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( )}\n${ctx.colors.muted(
` Add ${ctx.colors.option('--json-full')} to include raw API response in ${ctx.colors.argument('_raw')} field (tweet commands only)`, ` 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 <command> --help')} to see per-command flags.)`)}`, )}\n${ctx.colors.muted(` (Run ${ctx.colors.command('bird <command> --help')} to see per-command flags.)`)}`,
+6 -1
View File
@@ -1,8 +1,11 @@
import type { Command } from 'commander'; import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js'; import type { CliContext } from '../cli/shared.js';
import { normalizeHandle } from '../lib/normalize-handle.js';
import { TwitterClient } from '../lib/twitter-client.js'; import { TwitterClient } from '../lib/twitter-client.js';
import type { TwitterUser } from '../lib/twitter-client-types.js'; import type { TwitterUser } from '../lib/twitter-client-types.js';
const LEADING_AT_REGEX = /^@+/;
type PagedUsersResult = { type PagedUsersResult = {
success: boolean; success: boolean;
users?: TwitterUser[]; users?: TwitterUser[];
@@ -289,6 +292,7 @@ export function registerUserCommands(program: Command, ctx: CliContext): void {
.action(async (username: string, cmdOpts: { json?: boolean }) => { .action(async (username: string, cmdOpts: { json?: boolean }) => {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts); const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const normalizedHandle = normalizeHandle(username);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); 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)); console.log(JSON.stringify(result.aboutProfile, null, 2));
} else { } else {
const profile = result.aboutProfile; 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) { if (profile.accountBasedIn) {
console.log(` Account based in: ${profile.accountBasedIn}`); console.log(` Account based in: ${profile.accountBasedIn}`);
} }
+27 -2
View File
@@ -231,7 +231,9 @@ export function withUserLookup<TBase extends AbstractConstructor<TwitterClientBa
variables: JSON.stringify(variables), variables: JSON.stringify(variables),
}); });
const tryOnce = async () => {
let lastError: string | undefined; let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getAboutAccountQueryIds(); const queryIds = await this.getAboutAccountQueryIds();
for (const queryId of queryIds) { for (const queryId of queryIds) {
@@ -246,6 +248,7 @@ export function withUserLookup<TBase extends AbstractConstructor<TwitterClientBa
if (!response.ok) { if (!response.ok) {
const text = await response.text(); const text = await response.text();
if (response.status === 404) { if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`; lastError = `HTTP ${response.status}`;
continue; continue;
} }
@@ -282,7 +285,7 @@ export function withUserLookup<TBase extends AbstractConstructor<TwitterClientBa
} }
return { return {
success: true, success: true as const,
aboutProfile: { aboutProfile: {
accountBasedIn: aboutProfile.account_based_in, accountBasedIn: aboutProfile.account_based_in,
source: aboutProfile.source, source: aboutProfile.source,
@@ -290,13 +293,35 @@ export function withUserLookup<TBase extends AbstractConstructor<TwitterClientBa
locationAccurate: aboutProfile.location_accurate, locationAccurate: aboutProfile.location_accurate,
learnMoreUrl: aboutProfile.learn_more_url, learnMoreUrl: aboutProfile.learn_more_url,
}, },
had404,
}; };
} catch (error) { } catch (error) {
lastError = error instanceof Error ? error.message : String(error); lastError = error instanceof Error ? error.message : String(error);
} }
} }
return { success: false, error: lastError ?? 'Unknown error fetching account details' }; 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 };
}
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 };
} }
} }
+11
View File
@@ -149,6 +149,17 @@ d('live CLI (Twitter/X)', () => {
expect(whoamiStdout).toContain('credentials:'); 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<Record<string, unknown>>(about.stdout);
expect(Array.isArray(payload)).toBe(false);
expect(Object.keys(payload).length).toBeGreaterThan(0);
});
it('read returns tweet JSON', async () => { it('read returns tweet JSON', async () => {
const read = await runBird([...baseArgs, 'read', tweetId, '--json'], { const read = await runBird([...baseArgs, 'read', tweetId, '--json'], {
timeoutMs: 45_000, timeoutMs: 45_000,
+1 -1
View File
@@ -119,7 +119,7 @@ describe('TwitterClient getUserAboutAccount', () => {
}); });
it('handles HTTP 404 error', async () => { it('handles HTTP 404 error', async () => {
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValue({
ok: false, ok: false,
status: 404, status: 404,
text: async () => 'Not Found', text: async () => 'Not Found',