refactor: normalize about output + retry helper

This commit is contained in:
Peter Steinberger
2026-06-24 18:42:22 +09:00
committed by yuta
parent 77c7d676a7
commit 1b2afd5acb
4 changed files with 54 additions and 60 deletions
+28 -20
View File
@@ -2,9 +2,27 @@ 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 { 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 { AboutAccountProfile, TwitterUser } from '../lib/twitter-client-types.js';
const LEADING_AT_REGEX = /^@+/; function formatAboutProfile(profile: AboutAccountProfile, ctx: CliContext, handle: string): string[] {
const lines: string[] = [`${ctx.p('info')}Account information for @${handle}:`];
if (profile.accountBasedIn) {
lines.push(` Account based in: ${profile.accountBasedIn}`);
}
if (profile.createdCountryAccurate !== undefined) {
lines.push(` Creation country accurate: ${profile.createdCountryAccurate ? 'Yes' : 'No'}`);
}
if (profile.locationAccurate !== undefined) {
lines.push(` Location accurate: ${profile.locationAccurate ? 'Yes' : 'No'}`);
}
if (profile.source) {
lines.push(`${ctx.l('source')}${profile.source}`);
}
if (profile.learnMoreUrl) {
lines.push(` Learn more: ${profile.learnMoreUrl}`);
}
return lines;
}
type PagedUsersResult = { type PagedUsersResult = {
success: boolean; success: boolean;
@@ -294,6 +312,11 @@ export function registerUserCommands(program: Command, ctx: CliContext): void {
const timeoutMs = ctx.resolveTimeoutFromOptions(opts); const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const normalizedHandle = normalizeHandle(username); const normalizedHandle = normalizeHandle(username);
if (!normalizedHandle) {
console.error(`${ctx.p('err')}Invalid username: ${username}`);
process.exit(1);
}
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) { for (const warning of warnings) {
@@ -306,29 +329,14 @@ export function registerUserCommands(program: Command, ctx: CliContext): void {
} }
const client = new TwitterClient({ cookies, timeoutMs }); const client = new TwitterClient({ cookies, timeoutMs });
const result = await client.getUserAboutAccount(username); const result = await client.getUserAboutAccount(normalizedHandle);
if (result.success && result.aboutProfile) { if (result.success && result.aboutProfile) {
if (cmdOpts.json) { if (cmdOpts.json) {
console.log(JSON.stringify(result.aboutProfile, null, 2)); console.log(JSON.stringify(result.aboutProfile, null, 2));
} else { } else {
const profile = result.aboutProfile; for (const line of formatAboutProfile(result.aboutProfile, ctx, normalizedHandle)) {
const displayHandle = normalizedHandle ?? username.replace(LEADING_AT_REGEX, ''); console.log(line);
console.log(`${ctx.p('info')}Account information for @${displayHandle}:`);
if (profile.accountBasedIn) {
console.log(` Account based in: ${profile.accountBasedIn}`);
}
if (profile.createdCountryAccurate !== undefined) {
console.log(` Creation country accurate: ${profile.createdCountryAccurate ? 'Yes' : 'No'}`);
}
if (profile.locationAccurate !== undefined) {
console.log(` Location accurate: ${profile.locationAccurate ? 'Yes' : 'No'}`);
}
if (profile.source) {
console.log(`${ctx.l('source')}${profile.source}`);
}
if (profile.learnMoreUrl) {
console.log(` Learn more: ${profile.learnMoreUrl}`);
} }
} }
} else { } else {
+12
View File
@@ -49,6 +49,18 @@ export abstract class TwitterClientBase {
} }
} }
protected async withRefreshedQueryIdsOn404<T extends { success: boolean; had404?: boolean }>(
attempt: () => Promise<T>,
): Promise<{ result: T; refreshed: boolean }> {
const firstAttempt = await attempt();
if (firstAttempt.success || !firstAttempt.had404) {
return { result: firstAttempt, refreshed: false };
}
await this.refreshQueryIds();
const secondAttempt = await attempt();
return { result: secondAttempt, refreshed: true };
}
protected async getTweetDetailQueryIds(): Promise<string[]> { protected async getTweetDetailQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('TweetDetail'); const primary = await this.getQueryId('TweetDetail');
return Array.from(new Set([primary, '97JF30KziU00483E_8elBA', 'aFvUsJm2c-oDkJV75blV6g'])); return Array.from(new Set([primary, '97JF30KziU00483E_8elBA', 'aFvUsJm2c-oDkJV75blV6g']));
+4 -14
View File
@@ -307,21 +307,11 @@ export function withUserLookup<TBase extends AbstractConstructor<TwitterClientBa
}; };
}; };
const firstAttempt = await tryOnce(); const { result } = await this.withRefreshedQueryIdsOn404(tryOnce);
if (firstAttempt.success) { if (result.success) {
return { success: true, aboutProfile: firstAttempt.aboutProfile }; return { success: true, aboutProfile: result.aboutProfile };
} }
return { success: false, error: result.error };
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 };
} }
} }
+10 -26
View File
@@ -331,29 +331,21 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
return { success: false as const, error: lastError ?? 'Unknown error fetching following', had404 }; return { success: false as const, error: lastError ?? 'Unknown error fetching following', had404 };
}; };
const firstAttempt = await tryOnce(); const { result, refreshed } = await this.withRefreshedQueryIdsOn404(tryOnce);
if (firstAttempt.success) { if (result.success) {
return { success: true, users: firstAttempt.users, nextCursor: firstAttempt.nextCursor }; return { success: true, users: result.users, nextCursor: result.nextCursor };
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await tryOnce();
if (secondAttempt.success) {
return { success: true, users: secondAttempt.users, nextCursor: secondAttempt.nextCursor };
} }
if (refreshed) {
// GraphQL Following can also return 404 (queryId churn / endpoint flakiness). // GraphQL Following can also return 404 (queryId churn / endpoint flakiness).
// Fallback to the internal v1.1 REST endpoint used by the web client through the relay. // Fallback to the internal v1.1 REST endpoint used by the web client through the relay.
const restAttempt = await this.getFollowingViaRest(userId, count, cursor); const restAttempt = await this.getFollowingViaRest(userId, count, cursor);
if (restAttempt.success) { if (restAttempt.success) {
return restAttempt; return restAttempt;
} }
return { success: false, error: secondAttempt.error };
} }
return { success: false, error: firstAttempt.error }; return { success: false, error: result.error };
} }
/** /**
@@ -436,29 +428,21 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
return { success: false as const, error: lastError ?? 'Unknown error fetching followers', had404 }; return { success: false as const, error: lastError ?? 'Unknown error fetching followers', had404 };
}; };
const firstAttempt = await tryOnce(); const { result, refreshed } = await this.withRefreshedQueryIdsOn404(tryOnce);
if (firstAttempt.success) { if (result.success) {
return { success: true, users: firstAttempt.users, nextCursor: firstAttempt.nextCursor }; return { success: true, users: result.users, nextCursor: result.nextCursor };
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await tryOnce();
if (secondAttempt.success) {
return { success: true, users: secondAttempt.users, nextCursor: secondAttempt.nextCursor };
} }
if (refreshed) {
// GraphQL Followers regularly returns 404 (queryId churn / endpoint flakiness). // GraphQL Followers regularly returns 404 (queryId churn / endpoint flakiness).
// Fallback to the internal v1.1 REST endpoint used by the web client through the relay. // Fallback to the internal v1.1 REST endpoint used by the web client through the relay.
const restAttempt = await this.getFollowersViaRest(userId, count, cursor); const restAttempt = await this.getFollowersViaRest(userId, count, cursor);
if (restAttempt.success) { if (restAttempt.success) {
return restAttempt; return restAttempt;
} }
return { success: false, error: secondAttempt.error };
} }
return { success: false, error: firstAttempt.error }; return { success: false, error: result.error };
} }
} }