import { cli, Strategy } from '@jackwener/opencli/registry'; import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; import { TWITTER_BEARER_TOKEN } from './utils.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 // fallback queryId combined with a runtime lookup against the // twitter-openapi placeholder.json so we keep working when X rotates IDs. const OPERATION_NAME = 'bookmarkFoldersSlice'; const FALLBACK_QUERY_ID = 'i78YDd0Tza-dWKw5H2Y7WA'; const FEATURES = { rweb_tipjar_consumption_enabled: false, responsive_web_graphql_exclude_directive_enabled: true, verified_phone_label_enabled: false, creator_subscriptions_tweet_preview_api_enabled: true, responsive_web_graphql_timeline_navigation_enabled: true, responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, }; function buildUrl(queryId) { const variables = JSON.stringify({}); return `/i/api/graphql/${queryId}/${OPERATION_NAME}` + `?variables=${encodeURIComponent(variables)}` + `&features=${encodeURIComponent(JSON.stringify(FEATURES))}`; } /** * Walk the GraphQL response shape used by X's bookmark folders slice and * project each folder onto our column row. * * X has shipped at least three different envelope shapes for this query * across the last two years; the precedence order below preserves * compatibility with older accounts whose Premium-eligibility flag is * still on the legacy V2 envelope. * * Exported via __test__ so the parser is unit-testable without a browser. */ export function parseBookmarkFolders(data, seen) { const folders = []; const slice = data?.data?.viewer?.bookmark_collections_slice || data?.data?.viewer_v2?.user_results?.result?.bookmark_collections_slice || data?.data?.bookmark_collections_slice || null; const items = slice?.items || slice?.timeline?.timeline?.instructions?.flatMap?.(i => i.entries || []) || []; for (const item of items) { // Two known item shapes: direct {id, name, ...} (newer) or wrapped // {content: {bookmarkCollectionResult: {...}}} (older / nested). const folder = item?.bookmarkCollection || item?.content?.bookmarkCollection || item?.content?.itemContent?.bookmark_collection || item; const id = folder?.id_str || folder?.id || folder?.rest_id || ''; if (!id || seen.has(id)) continue; seen.add(id); const name = folder?.name || folder?.collection_name || ''; // bookmarks_count is the X UI label; older envelopes used `count`. const itemsCount = Number(folder?.bookmarks_count ?? folder?.items_count ?? folder?.count ?? 0) || 0; const createdAt = folder?.created_at || folder?.timestamp_ms || ''; folders.push({ id: String(id), name: String(name), items: itemsCount, created_at: String(createdAt), }); } return folders; } cli({ site: 'twitter', name: 'bookmark-folders', access: 'read', description: 'List your Twitter/X bookmark folders (the user-created collections under Bookmarks). Returns folder id, name, item count, and created_at.', domain: 'x.com', strategy: Strategy.COOKIE, browser: true, args: [], columns: ['id', 'name', 'items', 'created_at'], func: async (page) => { const cookies = await page.getCookies({ url: 'https://x.com' }); const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null; if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)'); // Try the fa0311/twitter-openapi placeholder first; fall back to scraping // client-web bundles for the queryId; final fallback is the pinned constant. const queryId = await resolveTwitterQueryId(page, OPERATION_NAME, FALLBACK_QUERY_ID); const headers = JSON.stringify({ 'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`, 'X-Csrf-Token': ct0, 'X-Twitter-Auth-Type': 'OAuth2Session', 'X-Twitter-Active-User': 'yes', }); const apiUrl = buildUrl(queryId); 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 }; }`); if (data?.error) { throw new CommandExecutionError(describeTwitterApiError('bookmarkFoldersSlice', data.error, 'account may not have folder access')); } const seen = new Set(); return parseBookmarkFolders(data, seen); }, }); export const __test__ = { parseBookmarkFolders, buildUrl, };