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

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