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

175 lines
8.4 KiB
JavaScript

import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { describeTwitterApiError, normalizeTwitterScreenName, resolveTwitterQueryId, unwrapBrowserResult } from './shared.js';
import { TWITTER_BEARER_TOKEN } from './utils.js';
const USER_BY_SCREEN_NAME_QUERY_ID = 'IGgvgiOx4QZndDHuD3x9TQ';
function isPlainObject(value) {
return value != null && typeof value === 'object' && !Array.isArray(value);
}
function stringField(value) {
return typeof value === 'string' ? value : '';
}
export function mapTwitterProfileResult(result, screenName) {
if (!isPlainObject(result)) {
throw new CommandExecutionError(`Twitter profile response for @${screenName} is malformed`);
}
const hasLegacy = isPlainObject(result.legacy);
const hasCore = isPlainObject(result.core);
if (!hasLegacy && !hasCore) {
throw new CommandExecutionError(`Twitter profile response for @${screenName} is missing profile fields`);
}
const legacy = hasLegacy ? result.legacy : {};
const core = hasCore ? result.core : {};
if (!stringField(core.screen_name) && !stringField(legacy.screen_name) && !stringField(core.name) && !stringField(legacy.name) && !stringField(core.created_at) && !stringField(legacy.created_at)) {
throw new CommandExecutionError(`Twitter profile response for @${screenName} is missing profile identity fields`);
}
const location = isPlainObject(result.location) ? result.location : {};
const expandedUrl = legacy.entities?.url?.urls?.[0]?.expanded_url || '';
return [{
screen_name: stringField(core.screen_name) || stringField(legacy.screen_name) || screenName,
name: stringField(core.name) || stringField(legacy.name),
bio: stringField(legacy.description),
location: stringField(location.location) || stringField(legacy.location),
url: stringField(expandedUrl),
followers: legacy.followers_count || 0,
following: legacy.friends_count || 0,
tweets: legacy.statuses_count || 0,
likes: legacy.favourites_count || 0,
verified: Boolean(result.is_blue_verified || legacy.verified),
created_at: stringField(core.created_at) || stringField(legacy.created_at),
}];
}
cli({
site: 'twitter',
name: 'profile',
access: 'read',
description: 'Fetch a Twitter user profile — bio, stats, etc. (defaults to the logged-in user when no username is given)',
domain: 'x.com',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'username', type: 'string', positional: true, help: 'Twitter screen name (with or without @). Defaults to the logged-in user when omitted.' },
],
columns: ['screen_name', 'name', 'bio', 'location', 'url', 'followers', 'following', 'tweets', 'likes', 'verified', 'created_at'],
func: async (page, kwargs) => {
const rawUsername = String(kwargs.username ?? '').trim();
let username = normalizeTwitterScreenName(rawUsername);
if (rawUsername && !username) {
throw new ArgumentError('twitter profile username must be a valid Twitter/X handle', 'Example: opencli twitter profile @jack');
}
// If no username, detect the logged-in user.
// Bridge wraps primitive page.evaluate returns as { session, data:<value> };
// unwrap so the href string is usable downstream.
if (!username) {
await page.goto('https://x.com/home');
await page.wait({ selector: '[data-testid="primaryColumn"]' });
const href = unwrapBrowserResult(await page.evaluate(`() => {
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
return link ? link.getAttribute('href') : null;
}`));
if (!href || typeof href !== 'string')
throw new AuthRequiredError('x.com', 'Could not detect logged-in user. Are you logged in?');
username = normalizeTwitterScreenName(href);
if (!username)
throw new AuthRequiredError('x.com', 'Could not detect logged-in user. Are you logged in?');
}
// Navigate directly to the user's profile page (gives us cookie context)
await page.goto(`https://x.com/${username}`);
await page.wait(3);
// Read CSRF token directly from the cookie store via CDP — zero page.evaluate round-trip
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)');
const queryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
const rawResult = unwrapBrowserResult(await page.evaluate(`
async () => {
const screenName = ${JSON.stringify(username)};
const ct0 = ${JSON.stringify(ct0)};
const bearer = ${JSON.stringify(TWITTER_BEARER_TOKEN)};
const headers = {
'Authorization': 'Bearer ' + decodeURIComponent(bearer),
'X-Csrf-Token': ct0,
'X-Twitter-Auth-Type': 'OAuth2Session',
'X-Twitter-Active-User': 'yes'
};
const variables = JSON.stringify({
screen_name: screenName,
withSafetyModeUserFields: true,
});
const features = JSON.stringify({
hidden_profile_subscriptions_enabled: true,
rweb_tipjar_consumption_enabled: true,
responsive_web_graphql_exclude_directive_enabled: true,
verified_phone_label_enabled: false,
subscriptions_verification_info_is_identity_verified_enabled: true,
subscriptions_verification_info_verified_since_enabled: true,
highlights_tweets_tab_ui_enabled: true,
responsive_web_twitter_article_notes_tab_enabled: true,
subscriptions_feature_can_gift_premium: true,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
responsive_web_graphql_timeline_navigation_enabled: true,
});
const url = '/i/api/graphql/' + ${JSON.stringify(queryId)} + '/UserByScreenName?variables='
+ encodeURIComponent(variables)
+ '&features=' + encodeURIComponent(features);
let resp;
try {
resp = await fetch(url, {headers, credentials: 'include'});
} catch (error) {
return {ok: false, error: 'Twitter profile request failed: ' + String(error && error.message || error)};
}
if (!resp.ok) {
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'
};
}
let d;
try {
d = await resp.json();
} catch (error) {
return {ok: false, error: 'Twitter profile response was not JSON: ' + String(error && error.message || error)};
}
const result = d.data?.user?.result;
if (!result) return {ok: false, notFound: true, error: 'User @' + screenName + ' not found'};
return {ok: true, result};
}
`));
if (!isPlainObject(rawResult)) {
throw new CommandExecutionError('Twitter profile response payload is malformed');
}
if (!rawResult.ok) {
// 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);
}
if (rawResult.notFound) {
throw new EmptyResultError('twitter profile', message);
}
throw new CommandExecutionError(message);
}
return mapTwitterProfileResult(rawResult.result, username);
}
});
export const __test__ = { mapTwitterProfileResult };