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
194 lines
8.5 KiB
JavaScript
194 lines
8.5 KiB
JavaScript
import { ArgumentError, AuthRequiredError, selectorError, EmptyResultError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
import { normalizeTwitterScreenName, unwrapBrowserResult } from './shared.js';
|
|
|
|
/**
|
|
* Extract follower rows from Twitter/X follower-list SPA cells.
|
|
*
|
|
* Verified DOM shape on x.com followers list (confirmed live 2026-05-05):
|
|
* - `[data-testid="UserCell"]` per follower
|
|
* - `[data-testid="UserAvatar-Container-<handle>"]` — stable handle source
|
|
* - `[data-testid="<userId>-follow"]` — follow button (i18n-independent)
|
|
* - `[data-testid="userFollowIndicator"]` — "Follows you" badge when present
|
|
* - the bio (when present) is rendered into `cell.innerText` but has NO
|
|
* dedicated testid in the list view (`UserDescription` only appears on
|
|
* the standalone profile page, NOT inside follower-list cells).
|
|
*
|
|
* Strategy: subtract the i18n-variable button / badge texts from the lines of
|
|
* `cell.innerText`, treat the first remaining `@…` line as the handle, the
|
|
* first non-handle line as display name, and the rest as bio. We avoid
|
|
* locale-coupled string matching (`"Follow"` / `"关注"` / `"Folgen"`).
|
|
*
|
|
* Note: Twitter does NOT render follower COUNTS in the list view, so the
|
|
* `followers` column is omitted from the output schema. Use
|
|
* `opencli twitter profile <user>` to read a per-user follower count.
|
|
*/
|
|
async function extractFollowersFromDOM(page) {
|
|
const script = `() => {
|
|
const cells = document.querySelectorAll('[data-testid="UserCell"]');
|
|
const out = [];
|
|
for (const cell of cells) {
|
|
// Collect i18n-variable UI strings to strip from the cell text.
|
|
const stripTexts = new Set();
|
|
const buttons = cell.querySelectorAll(
|
|
'[data-testid$="-follow"],[data-testid$="-unfollow"],[data-testid="userFollowIndicator"]'
|
|
);
|
|
for (const el of buttons) {
|
|
const t = (el.innerText || '').trim();
|
|
if (t) stripTexts.add(t);
|
|
}
|
|
const lines = (cell.innerText || '')
|
|
.split('\\n')
|
|
.map(s => s.trim())
|
|
.filter(Boolean)
|
|
.filter(l => !stripTexts.has(l));
|
|
// Pull the @handle line; fall back to UserAvatar-Container-<handle>.
|
|
let screen_name = '';
|
|
const remaining = [];
|
|
for (const l of lines) {
|
|
if (!screen_name && l.startsWith('@')) {
|
|
screen_name = l.slice(1).split(/\\s/)[0];
|
|
} else {
|
|
remaining.push(l);
|
|
}
|
|
}
|
|
if (!screen_name) {
|
|
const av = cell.querySelector('[data-testid^="UserAvatar-Container-"]');
|
|
const tid = av ? av.getAttribute('data-testid') || '' : '';
|
|
if (tid.startsWith('UserAvatar-Container-')) {
|
|
screen_name = tid.slice('UserAvatar-Container-'.length);
|
|
}
|
|
}
|
|
// First non-handle line is display name (may equal handle when the user hasn't set one).
|
|
const name = remaining[0] || screen_name;
|
|
// Lines past the display name form the bio.
|
|
const bio = remaining.slice(1).join(' ').replace(/\\s+/g, ' ').trim();
|
|
if (screen_name) {
|
|
out.push({ screen_name, name, bio });
|
|
}
|
|
}
|
|
return out;
|
|
}`;
|
|
return page.evaluate(script);
|
|
}
|
|
|
|
function normalizeScreenName(value) {
|
|
return normalizeTwitterScreenName(value);
|
|
}
|
|
|
|
cli({
|
|
site: 'twitter',
|
|
name: 'followers',
|
|
access: 'read',
|
|
description: 'Get accounts following a Twitter/X user (defaults to the logged-in user when no user is given)',
|
|
domain: 'x.com',
|
|
strategy: Strategy.UI,
|
|
browser: true,
|
|
args: [
|
|
{
|
|
name: 'user',
|
|
positional: true,
|
|
type: 'string',
|
|
required: false,
|
|
help: 'Twitter/X handle (with or without @). Omit to fetch followers of the currently logged-in account.',
|
|
},
|
|
{ name: 'limit', type: 'int', default: 50, help: 'Maximum number of follower rows to return (default 50). Must be a positive integer.' },
|
|
],
|
|
// `followers` (count) is NOT exposed: the SPA followers-list view does not
|
|
// render it. Use `twitter profile <user>` for per-user follower counts.
|
|
columns: ['screen_name', 'name', 'bio'],
|
|
func: async (page, kwargs) => {
|
|
const limit = kwargs.limit;
|
|
if (!Number.isInteger(limit) || limit <= 0) {
|
|
throw new ArgumentError('limit must be a positive integer');
|
|
}
|
|
|
|
const rawUser = String(kwargs.user ?? '').trim();
|
|
let targetUser = normalizeScreenName(rawUser);
|
|
if (rawUser && !targetUser) {
|
|
throw new ArgumentError('twitter followers user must be a valid Twitter/X handle', 'Example: opencli twitter followers @elonmusk --limit 100');
|
|
}
|
|
if (!targetUser) {
|
|
await page.goto('https://x.com/home');
|
|
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
// Bridge wraps primitive page.evaluate returns as { session, data:<value> };
|
|
// unwrap so the href string is usable downstream.
|
|
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 find logged-in user profile link. Are you logged in?');
|
|
}
|
|
targetUser = normalizeScreenName(href);
|
|
if (!targetUser) {
|
|
throw new AuthRequiredError('x.com', 'Could not find logged-in user profile link. Are you logged in?');
|
|
}
|
|
}
|
|
if (!targetUser) {
|
|
throw new ArgumentError('twitter followers user cannot be empty', 'Example: opencli twitter followers @elonmusk --limit 100');
|
|
}
|
|
|
|
// 1. Navigate to profile page
|
|
await page.goto(`https://x.com/${targetUser}`);
|
|
await page.wait(3);
|
|
|
|
// 2. Click the followers tab via SPA navigation (preserves session/state).
|
|
// Twitter sometimes only renders /verified_followers on profiles with
|
|
// badge filtering enabled; try the canonical link first, fall back.
|
|
const safeUser = JSON.stringify(targetUser);
|
|
const clicked = await page.evaluate(`() => {
|
|
const target = ${safeUser};
|
|
const selectors = [
|
|
'a[href="/' + target + '/followers"]',
|
|
'a[href="/' + target + '/verified_followers"]',
|
|
];
|
|
for (const sel of selectors) {
|
|
const link = document.querySelector(sel);
|
|
if (link) { link.click(); return true; }
|
|
}
|
|
return false;
|
|
}`);
|
|
if (!clicked) {
|
|
throw selectorError('Twitter followers link', 'Twitter may have changed the layout.');
|
|
}
|
|
|
|
// 3. Wait for follower cells to appear
|
|
await page.wait({ selector: '[data-testid="UserCell"]', timeout: 10000 });
|
|
|
|
// 4. Extract from DOM, scroll to load more, dedupe by screen_name
|
|
const allFollowers = [];
|
|
const seen = new Set();
|
|
let sameCount = 0;
|
|
while (allFollowers.length < limit && sameCount < 3) {
|
|
const rawFollowers = await extractFollowersFromDOM(page);
|
|
if (!Array.isArray(rawFollowers)) {
|
|
throw new CommandExecutionError('Twitter followers extraction returned malformed rows');
|
|
}
|
|
const followers = rawFollowers;
|
|
const newFollowers = followers.filter(f => !seen.has(f.screen_name));
|
|
for (const f of newFollowers) {
|
|
seen.add(f.screen_name);
|
|
allFollowers.push(f);
|
|
}
|
|
if (newFollowers.length === 0) {
|
|
sameCount++;
|
|
} else {
|
|
sameCount = 0;
|
|
}
|
|
if (allFollowers.length >= limit) break;
|
|
await page.autoScroll({ times: 1, delayMs: 500 });
|
|
await page.wait(2);
|
|
}
|
|
if (allFollowers.length === 0) {
|
|
throw new EmptyResultError('twitter followers', `No followers found for @${targetUser}`);
|
|
}
|
|
return allFollowers.slice(0, limit);
|
|
}
|
|
});
|
|
|
|
export const __test__ = {
|
|
extractFollowersFromDOM,
|
|
normalizeScreenName,
|
|
};
|