Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions clis/twitter/article.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { resolveTwitterQueryId } from './shared.js';
import { resolveTwitterQueryId, describeTwitterApiError } from './shared.js';
import { TWITTER_BEARER_TOKEN } from './utils.js';
const TWEET_RESULT_BY_REST_ID_QUERY_ID = '7xflPyRiUxGVbJd4uWmbfg';
cli({
Expand Down Expand Up @@ -96,7 +96,7 @@ cli({
+ '&fieldToggles=' + encodeURIComponent(fieldToggles);

const resp = await fetch(url, {headers, credentials: 'include'});
if (!resp.ok) return {error: 'HTTP ' + resp.status, hint: 'Tweet may not exist or queryId expired'};
if (!resp.ok) return {httpStatus: resp.status};
const d = await resp.json();

const result = d.data?.tweetResult?.result;
Expand Down Expand Up @@ -159,6 +159,9 @@ cli({
}];
}
`);
if (result?.httpStatus) {
throw new CommandExecutionError(describeTwitterApiError('TweetResultByRestId', result.httpStatus));
}
if (result?.error) {
throw new CommandExecutionError(result.error + (result.hint ? ` (${result.hint})` : ''));
}
Expand Down
4 changes: 2 additions & 2 deletions clis/twitter/bookmark-folder.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
import { extractMedia, resolveTwitterQueryId } from './shared.js';
import { extractMedia, resolveTwitterQueryId, describeTwitterApiError } from './shared.js';

// Companion to bookmark-folders.js: reads tweets inside a single folder.
// X exposes folder contents through a separate timeline operation
Expand Down Expand Up @@ -169,7 +169,7 @@ cli({
}`);
if (data?.error) {
if (allTweets.length === 0)
throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch folder ${folderId}. queryId may have expired, or the folder may not exist.`);
throw new CommandExecutionError(describeTwitterApiError('BookmarkFolderTimeline', data.error, `folder=${folderId}`));
break;
}
const { tweets, nextCursor } = parseBookmarkFolderTimeline(data, seen);
Expand Down
4 changes: 2 additions & 2 deletions clis/twitter/bookmark-folders.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
import { TWITTER_BEARER_TOKEN } from './utils.js';
import { resolveTwitterQueryId } from './shared.js';
import { resolveTwitterQueryId, describeTwitterApiError } from './shared.js';

// X surfaces user-created bookmark folders through a GraphQL slice query.
// We mirror the patterns used in bookmarks.js / lists.js: a literal
Expand Down Expand Up @@ -101,7 +101,7 @@ cli({
return r.ok ? await r.json() : { error: r.status };
}`);
if (data?.error) {
throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch bookmark folders. queryId may have expired, or your account may not have folder access.`);
throw new CommandExecutionError(describeTwitterApiError('bookmarkFoldersSlice', data.error, 'account may not have folder access'));
}
const seen = new Set();
return parseBookmarkFolders(data, seen);
Expand Down
4 changes: 2 additions & 2 deletions clis/twitter/bookmarks.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
import { extractMedia } from './shared.js';
import { extractMedia, describeTwitterApiError } from './shared.js';
import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
const BOOKMARKS_QUERY_ID = 'Fy0QMy4q_aZCpkO0PnyLYw';
const MAX_PAGINATION_PAGES = 100;
Expand Down Expand Up @@ -162,7 +162,7 @@ cli({
}`);
if (data?.error) {
if (allTweets.length === 0)
throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch bookmarks. queryId may have expired.`);
throw new CommandExecutionError(describeTwitterApiError('Bookmarks', data.error));
break;
}
const { tweets, nextCursor } = parseBookmarks(data, seen);
Expand Down
3 changes: 2 additions & 1 deletion clis/twitter/device-follow.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
import { describeTwitterApiError } from './shared.js';

const DEVICE_FOLLOW_PATH = '/i/api/2/notifications/device_follow.json';
const MAX_LIMIT = 200;
Expand Down Expand Up @@ -165,7 +166,7 @@ cli({
if (data.error === 401 || data.error === 403) {
throw new AuthRequiredError('x.com', `Twitter device-follow returned HTTP ${data.error}`);
}
throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch device-follow notification stream.`);
throw new CommandExecutionError(describeTwitterApiError('device_follow', data.error));
}
const parsed = parseDeviceFollow(data, new Set());
if (!parsed) {
Expand Down
4 changes: 2 additions & 2 deletions clis/twitter/following.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { looksLikePrivateTwitterTimeline, normalizeTwitterScreenName, resolveTwitterQueryId, sanitizeQueryId, unwrapBrowserResult } from './shared.js';
import { looksLikePrivateTwitterTimeline, normalizeTwitterScreenName, resolveTwitterQueryId, sanitizeQueryId, unwrapBrowserResult, describeTwitterApiError } from './shared.js';
import { TWITTER_BEARER_TOKEN } from './utils.js';

const FOLLOWING_QUERY_ID = 'F42cDX8PDFxkbjjq6JrM2w';
Expand Down Expand Up @@ -238,7 +238,7 @@ cli({
if (data?.error) {
if (data.error === 401 || data.error === 403)
throw new AuthRequiredError('x.com', `Twitter following request failed (HTTP ${data.error})`);
throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch following list. queryId may have expired.`);
throw new CommandExecutionError(describeTwitterApiError('Following', data.error));
}
lastRawResponse = data;
const { users, nextCursor } = parseFollowing(data);
Expand Down
4 changes: 2 additions & 2 deletions clis/twitter/likes.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { looksLikePrivateTwitterTimeline, normalizeTwitterScreenName, resolveTwitterQueryId, sanitizeQueryId, extractMedia, unwrapBrowserResult } from './shared.js';
import { looksLikePrivateTwitterTimeline, normalizeTwitterScreenName, resolveTwitterQueryId, sanitizeQueryId, extractMedia, unwrapBrowserResult, describeTwitterApiError } from './shared.js';
import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
const LIKES_QUERY_ID = 'CDWHmpZeSdIJ3HGeRbNm0w';
const USER_BY_SCREEN_NAME_QUERY_ID = 'IGgvgiOx4QZndDHuD3x9TQ';
Expand Down Expand Up @@ -213,7 +213,7 @@ cli({
}`));
if (data?.error) {
if (allTweets.length === 0)
throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch likes. queryId may have expired.`);
throw new CommandExecutionError(describeTwitterApiError('Likes', data.error));
break;
}
lastRawResponse = data;
Expand Down
13 changes: 7 additions & 6 deletions clis/twitter/list-tweets.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
import { extractMedia, extractCard, extractQuotedTweet } from './shared.js';
import { BROWSER_JSON_SNIFF_FN, throwIfLoginWall } from '@jackwener/opencli/utils';
import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
import { extractCard, extractQuotedTweet, extractMedia, describeTwitterApiError } from './shared.js';

const LIST_TWEETS_QUERY_ID = 'RlZzktZY_9wJynoepm8ZsA';
const OPERATION_NAME = 'ListLatestTweetsTimeline';
Expand Down Expand Up @@ -177,13 +178,13 @@ cli({
for (let i = 0; i < MAX_PAGINATION_PAGES && allTweets.length < limit; i++) {
const fetchCount = Math.min(100, limit - allTweets.length + 10);
const apiUrl = buildUrl(queryId, listId, fetchCount, cursor);
const data = await page.evaluate(`async () => {
const r = await fetch(${JSON.stringify(apiUrl)}, { headers: ${headers}, credentials: 'include' });
return r.ok ? await r.json() : { error: r.status };
}`);
const data = throwIfLoginWall(await page.evaluate(`async () => {
${BROWSER_JSON_SNIFF_FN}
return await fetchJsonOrLoginWall(${JSON.stringify(apiUrl)}, { headers: ${headers}, credentials: 'include' });
}`), { url: apiUrl });
if (data?.error) {
if (allTweets.length === 0)
throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch list timeline. queryId may have expired or list may be private.`);
throw new CommandExecutionError(describeTwitterApiError('ListLatestTweetsTimeline', data.error, 'list may be private'));
break;
}
const { tweets, nextCursor } = parseListTimeline(data, seen);
Expand Down
3 changes: 2 additions & 1 deletion clis/twitter/lists.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { TWITTER_BEARER_TOKEN } from './utils.js';
import { describeTwitterApiError } from './shared.js';

const LISTS_QUERY_ID = '78UbkyXwXBD98IgUWXOy9g';
const OPERATION_NAME = 'ListsManagementPageTimeline';
Expand Down Expand Up @@ -162,7 +163,7 @@ export const command = cli({
return r.ok ? await r.json() : { error: r.status };
}`);
if (data?.error) {
throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch lists. queryId may have expired.`);
throw new CommandExecutionError(describeTwitterApiError('ListsManagementPageTimeline', data.error));
}
const seen = new Set();
if (!getListsManagementInstructions(data)) {
Expand Down
10 changes: 8 additions & 2 deletions clis/twitter/profile.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { normalizeTwitterScreenName, resolveTwitterQueryId, unwrapBrowserResult } from './shared.js';
import { describeTwitterApiError, normalizeTwitterScreenName, resolveTwitterQueryId, unwrapBrowserResult } from './shared.js';
import { TWITTER_BEARER_TOKEN } from './utils.js';
const USER_BY_SCREEN_NAME_QUERY_ID = 'IGgvgiOx4QZndDHuD3x9TQ';

Expand Down Expand Up @@ -132,6 +132,7 @@ cli({
return {
ok: false,
auth: resp.status === 401 || resp.status === 403,
httpStatus: resp.status,
error: 'HTTP ' + resp.status,
hint: 'User may not exist, auth may be required, or queryId expired'
};
Expand All @@ -152,7 +153,12 @@ cli({
throw new CommandExecutionError('Twitter profile response payload is malformed');
}
if (!rawResult.ok) {
const message = rawResult.error + (rawResult.hint ? ` (${rawResult.hint})` : '');
// For HTTP errors, use fork's rich code mapping (429/401/403/404/5xx differentiation
// from describeTwitterApiError); fall back to the plain message for non-HTTP failures
// (fetch threw, JSON parse failed, payload malformed).
const message = typeof rawResult.httpStatus === 'number'
? describeTwitterApiError('UserByScreenName', rawResult.httpStatus, rawResult.hint)
: rawResult.error + (rawResult.hint ? ` (${rawResult.hint})` : '');
if (rawResult.auth) {
throw new AuthRequiredError('x.com', message);
}
Expand Down
6 changes: 3 additions & 3 deletions clis/twitter/search.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { extractMedia, extractCard, extractQuotedTweet, normalizeTwitterGraphqlPayload, resolveTwitterOperationMetadata } from './shared.js';
import { extractMedia, extractCard, extractQuotedTweet, normalizeTwitterGraphqlPayload, resolveTwitterOperationMetadata, describeTwitterApiError } from './shared.js';
import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';

// ── Public-search operator surface ─────────────────────────────────────
Expand Down Expand Up @@ -44,7 +44,7 @@ const PRODUCT_TO_GRAPHQL_PRODUCT = Object.freeze({
const MAX_PAGINATION_PAGES = 100;

const SEARCH_TIMELINE_OPERATION = {
queryId: 'VhUd6vHVmLBcw0uX-6jMLA',
queryId: 'Yw6L66Pw54NHKuq4Dp7b4Q',
features: {
rweb_video_screen_enabled: true,
rweb_cashtags_enabled: true,
Expand Down Expand Up @@ -318,7 +318,7 @@ cli({
return r.ok ? await r.json() : { error: r.status };
}`));
if (data?.error) {
if (results.length === 0) throw new CommandExecutionError(`HTTP ${data.error}: SearchTimeline fetch failed — queryId may have expired`);
if (results.length === 0) throw new CommandExecutionError(describeTwitterApiError('SearchTimeline', data.error));
break;
}
const { rows, nextCursor } = parseSearchTimeline(data, seen);
Expand Down
17 changes: 16 additions & 1 deletion clis/twitter/search.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,22 @@ import { __test__ } from './search.js';

const { buildSearchQuery, resolveSearchFParam, resolveSearchProduct, buildSearchTimelineRequest, parseSearchTimeline, HAS_CHOICES, EXCLUDE_CHOICES, PRODUCT_CHOICES, EXCLUDE_TO_OPERATOR, PRODUCT_TO_F_PARAM, FROM_USER_PATTERN } = __test__;
describe('twitter search command', () => {
// Mocked SearchTimeline operation metadata. The dynamic resolver
// (resolveTwitterOperationMetadata) is exercised via page.evaluate's first
// call. Returning a full {queryId, features, fieldToggles} validates the
// happy path where bundle scan / GitHub fallback succeeds — not the stale
// hardcoded path that previously masked the regex bug.
const DYNAMIC_OP = {
queryId: 'DynamicSearchQid42',
features: { dynamic_test_feature: true },
fieldToggles: { dynamic_test_toggle: true },
};
function makeSearchPage(data) {
return {
getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'csrf' }]),
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn()
.mockResolvedValueOnce(null) // resolveTwitterQueryId fallback
.mockResolvedValueOnce(DYNAMIC_OP) // resolveTwitterOperationMetadata dynamic result
.mockResolvedValueOnce(data),
};
}
Expand Down Expand Up @@ -89,6 +99,11 @@ describe('twitter search command', () => {
expect(searchFetch).toContain('/SearchTimeline');
expect(searchFetch).toContain("method: 'POST'");
expect(searchFetch).toContain('\\"rawQuery\\":\\"from:alice\\"');
// Regression guard: the dynamic queryId from resolveTwitterOperationMetadata
// must propagate to the actual GraphQL URL. Previously a bug in the bundle
// parser would return a wrong queryId silently, Twitter would 4xx, and
// search.js raised "queryId may have expired".
expect(searchFetch).toContain('/DynamicSearchQid42/SearchTimeline');
});

it('uses the requested GraphQL product', async () => {
Expand Down
Loading
Loading