diff --git a/CHANGELOG.md b/CHANGELOG.md index 1826e55..896393f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - Following/followers pagination now guards repeat cursors and standardizes JSON output (#28) — thanks @malpern. - Lists GraphQL feature flags updated to prevent 400s (#27) — thanks @zheli. - Likes pagination now follows cursors and avoids stalling on duplicate pages (#12) — thanks @titouv. +- Tweet detail parsing now tolerates partial GraphQL errors when usable data exists (#48) — thanks @jsholmes. - `pnpm run build:dist` now succeeds after tightening JSON/pagination option typing in tweet output commands. - News output now respects `--tweets-per-item`, keeps unique IDs, and parses non-add entry instructions (#39) — thanks @aavetis. diff --git a/src/lib/twitter-client-tweet-detail.ts b/src/lib/twitter-client-tweet-detail.ts index c9cb016..a70884c 100644 --- a/src/lib/twitter-client-tweet-detail.ts +++ b/src/lib/twitter-client-tweet-detail.ts @@ -216,7 +216,15 @@ export function withTweetDetails 0) { - return { success: false as const, error: data.errors.map((e) => e.message).join(', ') }; + // Twitter API sometimes returns partial errors (e.g., is_translatable failures) + // alongside valid tweet/thread data. Only fail if nothing useful is present. + const hasUsableData = Boolean( + data.data?.tweetResult?.result || + data.data?.threaded_conversation_with_injections_v2?.instructions?.length, + ); + if (!hasUsableData) { + return { success: false as const, error: data.errors.map((e) => e.message).join(', ') }; + } } return { success: true as const, data: data.data ?? {} }; diff --git a/tests/twitter-client-coverage.core.test.ts b/tests/twitter-client-coverage.core.test.ts index ed94c81..63326cf 100644 --- a/tests/twitter-client-coverage.core.test.ts +++ b/tests/twitter-client-coverage.core.test.ts @@ -185,6 +185,89 @@ describe('TwitterClient core coverage', () => { expect(result.error).toBe('bad news'); }); + it('allows partial errors when tweetResult is present', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + makeResponse({ + json: async () => ({ + data: { + tweetResult: { + result: { + rest_id: '1', + legacy: { + full_text: 'hi', + created_at: '2024-01-01T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + }, + core: { user_results: { result: { legacy: { screen_name: 'user', name: 'User' } } } }, + }, + }, + }, + errors: [{ message: 'field error' }], + }), + }), + ); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + const result = await clientPrivate.fetchTweetDetail('1'); + + expect(result.success).toBe(true); + expect(result.data?.tweetResult).toBeDefined(); + }); + + it('allows partial errors when instructions are present', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + makeResponse({ + json: async () => ({ + data: { + threaded_conversation_with_injections_v2: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: '2', + legacy: { + full_text: 'thread tweet', + created_at: '2024-01-01T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + conversation_id_str: '2', + }, + core: { + user_results: { result: { legacy: { screen_name: 'user', name: 'User' } } }, + }, + }, + }, + }, + }, + }, + ], + }, + ], + }, + }, + errors: [{ message: 'is_translatable error' }], + }), + }), + ); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + const result = await clientPrivate.fetchTweetDetail('2'); + + expect(result.success).toBe(true); + expect(result.data?.threaded_conversation_with_injections_v2?.instructions?.length).toBe(1); + }); + it('parses POST responses when GET returns 404', async () => { const mockFetch = vi .fn()