Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

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,
};