fix: about account live coverage (#51) (thanks @pjtf93)
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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.)`)}`,
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,72 +231,97 @@ export function withUserLookup<TBase extends AbstractConstructor<TwitterClientBa
|
|||||||
variables: JSON.stringify(variables),
|
variables: JSON.stringify(variables),
|
||||||
});
|
});
|
||||||
|
|
||||||
let lastError: string | undefined;
|
const tryOnce = async () => {
|
||||||
const queryIds = await this.getAboutAccountQueryIds();
|
let lastError: string | undefined;
|
||||||
|
let had404 = false;
|
||||||
|
const queryIds = await this.getAboutAccountQueryIds();
|
||||||
|
|
||||||
for (const queryId of queryIds) {
|
for (const queryId of queryIds) {
|
||||||
const url = `${TWITTER_API_BASE}/${queryId}/AboutAccountQuery?${params.toString()}`;
|
const url = `${TWITTER_API_BASE}/${queryId}/AboutAccountQuery?${params.toString()}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.fetchWithTimeout(url, {
|
const response = await this.fetchWithTimeout(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
lastError = `HTTP ${response.status}`;
|
had404 = true;
|
||||||
|
lastError = `HTTP ${response.status}`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await response.json()) as {
|
const data = (await response.json()) as {
|
||||||
data?: {
|
data?: {
|
||||||
user_result_by_screen_name?: {
|
user_result_by_screen_name?: {
|
||||||
result?: {
|
result?: {
|
||||||
about_profile?: {
|
about_profile?: {
|
||||||
account_based_in?: string;
|
account_based_in?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
created_country_accurate?: boolean;
|
created_country_accurate?: boolean;
|
||||||
location_accurate?: boolean;
|
location_accurate?: boolean;
|
||||||
learn_more_url?: string;
|
learn_more_url?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
errors?: Array<{ message: string }>;
|
||||||
};
|
};
|
||||||
errors?: Array<{ message: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (data.errors && data.errors.length > 0) {
|
if (data.errors && data.errors.length > 0) {
|
||||||
lastError = data.errors.map((e) => e.message).join(', ');
|
lastError = data.errors.map((e) => e.message).join(', ');
|
||||||
continue;
|
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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user