9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
115 lines
4.9 KiB
JavaScript
115 lines
4.9 KiB
JavaScript
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,
|
|
};
|