Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ When the site you need is not yet covered, use the `opencli-adapter-author` skil
| **geogebra** | `eval` `add-point` `add-line` `add-circle` `add-polygon` `triangle` `hexagon` `list` `info` |
| **linkedin** | `connect` `inbox` `job-detail` `jobs-preferences` `post-analytics` `posts` `profile-experience` `profile-projects` `profile-read` `profile-analytics` `safe-send` `search` `services-read` `sent-invitations` `thread-snapshot` `timeline` `salesnav-search` `salesnav-inbox` `salesnav-message` `salesnav-thread` |
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `upvoted` `save` `saved` `comment` `subscribe` |
| **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-add` `list-remove` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` |
| **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-create` `list-delete` `list-add` `list-add-batch` `list-remove` `list-remove-batch` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` |
| **claude** | `ask` `send` `new` `status` `read` `history` `detail` |
| **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` |
| **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` |
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自
| **geogebra** | `eval` `add-point` `add-line` `add-circle` `add-polygon` `triangle` `hexagon` `list` `info` |
| **linkedin** | `connect` `inbox` `job-detail` `jobs-preferences` `post-analytics` `posts` `profile-experience` `profile-projects` `profile-read` `profile-analytics` `safe-send` `search` `people-search` `services-read` `sent-invitations` `thread-snapshot` `timeline` `salesnav-search` `salesnav-inbox` `salesnav-message` `salesnav-thread` |
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` |
| **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-add` `list-remove` `bookmarks` `profile` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `likes` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` |
| **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-create` `list-delete` `list-add` `list-add-batch` `list-remove` `list-remove-batch` `bookmarks` `profile` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `likes` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` |
| **claude** | `ask` `send` `new` `status` `read` `history` `detail` |
| **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` |
| **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` |
Expand Down
177 changes: 177 additions & 0 deletions cli-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -25594,6 +25594,40 @@
"sourceFile": "twitter/follow.js",
"navigateBefore": true
},
{
"site": "twitter",
"name": "follow-batch",
"description": "Follow multiple Twitter/X users from a comma-separated username list",
"access": "write",
"domain": "x.com",
"strategy": "ui",
"browser": true,
"args": [
{
"name": "usernames",
"type": "string",
"required": true,
"positional": true,
"help": "Comma-separated Twitter/X screen names, with or without @"
},
{
"name": "delay-ms",
"type": "int",
"default": 3000,
"required": false,
"help": "Delay between follow attempts in milliseconds"
}
],
"columns": [
"username",
"status",
"message"
],
"type": "js",
"modulePath": "twitter/follow-batch.js",
"sourceFile": "twitter/follow-batch.js",
"navigateBefore": true
},
{
"site": "twitter",
"name": "followers",
Expand Down Expand Up @@ -25799,6 +25833,56 @@
"sourceFile": "twitter/list-add.js",
"navigateBefore": true
},
{
"site": "twitter",
"name": "list-add-batch",
"description": "Add multiple users to a Twitter/X list you own from a comma-separated username list",
"access": "write",
"domain": "x.com",
"strategy": "ui",
"browser": true,
"args": [
{
"name": "listId",
"type": "string",
"required": true,
"positional": true,
"help": "Numeric ID of the list you own (e.g. from `opencli twitter lists`)"
},
{
"name": "usernames",
"type": "string",
"required": true,
"positional": true,
"help": "Comma-separated Twitter/X handles to add (with or without @)"
},
{
"name": "interval",
"type": "int",
"default": 5,
"required": false,
"help": "Seconds to wait between account additions (default: 5)"
},
{
"name": "timeout",
"type": "int",
"default": 600,
"required": false,
"help": "Max seconds for the overall batch command (default: 600)"
}
],
"columns": [
"listId",
"username",
"userId",
"status",
"message"
],
"type": "js",
"modulePath": "twitter/list-add-batch.js",
"sourceFile": "twitter/list-add-batch.js",
"navigateBefore": true
},
{
"site": "twitter",
"name": "list-create",
Expand Down Expand Up @@ -25842,6 +25926,49 @@
"sourceFile": "twitter/list-create.js",
"navigateBefore": "https://x.com"
},
{
"site": "twitter",
"name": "list-delete",
"description": "Delete a Twitter/X list you own after explicit confirmation",
"access": "write",
"domain": "x.com",
"strategy": "ui",
"browser": true,
"args": [
{
"name": "listId",
"type": "string",
"required": true,
"positional": true,
"help": "Numeric ID of the list you own (e.g. from `opencli twitter lists`)"
},
{
"name": "confirm",
"type": "boolean",
"default": false,
"required": false,
"help": "Required. Set --confirm true to delete the list."
},
{
"name": "timeout",
"type": "int",
"default": 300,
"required": false,
"help": "Max seconds for the overall delete command (default: 300)"
}
],
"columns": [
"listId",
"name",
"members",
"status",
"message"
],
"type": "js",
"modulePath": "twitter/list-delete.js",
"sourceFile": "twitter/list-delete.js",
"navigateBefore": true
},
{
"site": "twitter",
"name": "list-remove",
Expand Down Expand Up @@ -25878,6 +26005,56 @@
"sourceFile": "twitter/list-remove.js",
"navigateBefore": true
},
{
"site": "twitter",
"name": "list-remove-batch",
"description": "Remove multiple users from a Twitter/X list you own from a comma-separated username list",
"access": "write",
"domain": "x.com",
"strategy": "ui",
"browser": true,
"args": [
{
"name": "listId",
"type": "string",
"required": true,
"positional": true,
"help": "Numeric ID of the list you own (e.g. from `opencli twitter lists`)"
},
{
"name": "usernames",
"type": "string",
"required": true,
"positional": true,
"help": "Comma-separated Twitter/X handles to remove (with or without @)"
},
{
"name": "interval",
"type": "int",
"default": 5,
"required": false,
"help": "Seconds to wait between account removals (default: 5)"
},
{
"name": "timeout",
"type": "int",
"default": 600,
"required": false,
"help": "Max seconds for the overall batch command (default: 600)"
}
],
"columns": [
"listId",
"username",
"userId",
"status",
"message"
],
"type": "js",
"modulePath": "twitter/list-remove-batch.js",
"sourceFile": "twitter/list-remove-batch.js",
"navigateBefore": true
},
{
"site": "twitter",
"name": "list-tweets",
Expand Down
155 changes: 155 additions & 0 deletions clis/twitter/follow-batch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';

const USERNAME_RE = /^[A-Za-z0-9_]{1,15}$/;
const DEFAULT_DELAY_MS = 3000;
const MAX_DELAY_MS = 60000;

export function parseBatchUsernames(input) {
const raw = String(input || '').trim();
if (!raw) {
throw new ArgumentError('At least one Twitter/X username is required');
}

const usernames = [];
const seen = new Set();
for (const part of raw.split(',')) {
const username = part.trim().replace(/^@+/, '');
if (!username) continue;
if (!USERNAME_RE.test(username)) {
throw new ArgumentError(`Invalid Twitter/X username: ${JSON.stringify(part.trim())}`);
}
const key = username.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
usernames.push(username);
}

if (!usernames.length) {
throw new ArgumentError('At least one Twitter/X username is required');
}
return usernames;
}

async function readFollowState(page, username) {
return page.evaluate(`(async () => {
try {
let attempts = 0;
while (attempts < 20) {
const unfollowBtn = document.querySelector('[data-testid$="-unfollow"]');
if (unfollowBtn) {
return { ok: true, status: 'noop', message: 'Already following @${username}.' };
}

const followBtn = document.querySelector('[data-testid$="-follow"]');
if (followBtn) {
return { ok: false, followButtonVisible: true };
}

await new Promise(r => setTimeout(r, 500));
attempts++;
}

return { ok: false, followButtonVisible: false };
} catch (e) {
return { ok: false, message: e.toString() };
}
})()`);
}

async function clickFollowAndVerify(page, username) {
return page.evaluate(`(async () => {
try {
const followBtn = document.querySelector('[data-testid$="-follow"]');
if (!followBtn) {
return { ok: false, retryAfterRefresh: true, message: 'Could not find Follow button after loading profile.' };
}

followBtn.click();
for (let attempts = 0; attempts < 20; attempts++) {
await new Promise(r => setTimeout(r, 500));
const verify = document.querySelector('[data-testid$="-unfollow"]');
if (verify) {
return { ok: true, status: 'success', message: 'Successfully followed @${username}.' };
}
}

return { ok: false, retryAfterRefresh: true, message: 'Follow action initiated but UI did not update.' };
} catch (e) {
return { ok: false, message: e.toString() };
}
})()`);
}

export async function followOne(page, username) {
await page.goto(`https://x.com/${username}`);
await page.wait({ selector: '[data-testid="primaryColumn"]' });

let result = await readFollowState(page, username);
if (!result.ok && result.followButtonVisible) {
result = await clickFollowAndVerify(page, username);
}
if (!result.ok && result.retryAfterRefresh) {
await page.goto(`https://x.com/${username}`);
await page.wait({ selector: '[data-testid="primaryColumn"]' });
const refreshed = await readFollowState(page, username);
if (refreshed.ok) {
result = { ...refreshed, status: 'success', message: `Successfully followed @${username}.` };
}
}
if (!result.ok && !result.message) {
result = { ...result, message: 'Could not find Follow button. Are you logged in?' };
}

if (result.ok) {
await page.wait(1);
}

return {
username,
status: result.ok ? result.status : 'failed',
message: result.message,
};
}

export function parseDelayMs(input) {
if (input === undefined || input === null || input === '') {
return DEFAULT_DELAY_MS;
}
const value = Number(input);
if (!Number.isInteger(value) || value < 0 || value > MAX_DELAY_MS) {
throw new ArgumentError(`delay-ms must be an integer between 0 and ${MAX_DELAY_MS}`);
}
return value;
}

cli({
site: 'twitter',
name: 'follow-batch',
access: 'write',
description: 'Follow multiple Twitter/X users from a comma-separated username list',
domain: 'x.com',
strategy: Strategy.UI,
browser: true,
args: [
{ name: 'usernames', type: 'string', positional: true, required: true, help: 'Comma-separated Twitter/X screen names, with or without @' },
{ name: 'delay-ms', type: 'int', default: DEFAULT_DELAY_MS, help: 'Delay between follow attempts in milliseconds' },
],
columns: ['username', 'status', 'message'],
func: async (page, kwargs) => {
if (!page) {
throw new CommandExecutionError('Browser session required for twitter follow-batch');
}

const usernames = parseBatchUsernames(kwargs.usernames);
const delayMs = parseDelayMs(kwargs['delay-ms']);
const rows = [];
for (const [index, username] of usernames.entries()) {
if (index > 0 && delayMs > 0) {
await page.wait(delayMs / 1000);
}
rows.push(await followOne(page, username));
}
return rows;
}
});
Loading
Loading