Skip to content

Commit c63bad3

Browse files
isanwenyujackwener
andauthored
feat(twitter): add lists command to retrieve user lists (#958)
* feat(twitter): add lists command to retrieve user lists Add twitter/lists command that fetches Twitter/X lists for a user. Supports: - Lists with member and follower counts - Private/public mode detection - Default to current user if no user specified - Works for any Twitter user * docs: add lists command to twitter commands in README Add twitter lists command to Built-in Commands table in both English and Chinese README files * fix(twitter): parse lists from card DOM instead of locale-specific page text --------- Co-authored-by: isanwenyu <isanwenyu@users.noreply.github.com> Co-authored-by: jackwener <jakevingoo@gmail.com>
1 parent 1ffe12f commit c63bad3

File tree

6 files changed

+196
-2
lines changed

6 files changed

+196
-2
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ To load the source Browser Bridge extension:
198198
| **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `me` `subtitle` `user-videos` |
199199
| **tieba** | `hot` `posts` `search` `read` |
200200
| **hupu** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` |
201-
| **twitter** | `trending` `search` `timeline` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` |
201+
| **twitter** | `trending` `search` `timeline` `lists` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` |
202202
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `upvoted` `save` `saved` `comment` `subscribe` |
203203
| **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` |
204204
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` |

README.zh-CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ npm link
184184

185185
| 站点 | 命令 | 模式 |
186186
|------|------|------|
187-
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | 浏览器 |
187+
| **twitter** | `trending` `search` `timeline` `lists` `bookmarks` `profile` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | 浏览器 |
188188
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 浏览器 |
189189
| **tieba** | `hot` `posts` `search` `read` | 浏览器 |
190190
| **hupu** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` | 浏览器 |

clis/twitter/lists-parser.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
const MEMBER_PATTERNS = [
2+
/([\d.,]+(?:\s?[KMB亿])?)\s*members?/i,
3+
/([\d.,]+(?:\s?[KMB亿])?)\s*/,
4+
];
5+
const FOLLOWER_PATTERNS = [
6+
/([\d.,]+(?:\s?[KMB亿])?)\s*followers?/i,
7+
/([\d.,]+(?:\s?[KMB亿])?)\s*/,
8+
];
9+
const PRIVATE_PATTERNS = [/\bprivate\b/i, //];
10+
const EMPTY_STATE_PATTERNS = [
11+
/hasn't created any lists/i,
12+
/has not created any lists/i,
13+
/no lists yet/i,
14+
//,
15+
//,
16+
];
17+
function normalizeText(text) {
18+
return String(text || '').replace(/\s+/g, ' ').trim();
19+
}
20+
function matchMetric(text, patterns) {
21+
for (const pattern of patterns) {
22+
const match = text.match(pattern);
23+
if (match)
24+
return normalizeText(match[1]);
25+
}
26+
return '0';
27+
}
28+
function looksLikeMetadata(line) {
29+
const text = normalizeText(line);
30+
if (!text)
31+
return true;
32+
if (text.startsWith('@'))
33+
return true;
34+
if (MEMBER_PATTERNS.some((pattern) => pattern.test(text)))
35+
return true;
36+
if (FOLLOWER_PATTERNS.some((pattern) => pattern.test(text)))
37+
return true;
38+
if (PRIVATE_PATTERNS.some((pattern) => pattern.test(text)))
39+
return true;
40+
if (/^(public|pinned)$/i.test(text))
41+
return true;
42+
if (/^(lists?|)$/i.test(text))
43+
return true;
44+
return false;
45+
}
46+
export function parseListCards(cards) {
47+
const seen = new Set();
48+
const results = [];
49+
for (const card of cards || []) {
50+
const href = normalizeText(card?.href);
51+
const rawText = String(card?.text || '');
52+
if (!href || seen.has(href))
53+
continue;
54+
seen.add(href);
55+
const text = normalizeText(rawText);
56+
if (!text)
57+
continue;
58+
const lines = rawText
59+
.split('\n')
60+
.map((line) => normalizeText(line))
61+
.filter(Boolean);
62+
const name = lines.find((line) => !looksLikeMetadata(line));
63+
if (!name)
64+
continue;
65+
results.push({
66+
name,
67+
members: matchMetric(text, MEMBER_PATTERNS),
68+
followers: matchMetric(text, FOLLOWER_PATTERNS),
69+
mode: PRIVATE_PATTERNS.some((pattern) => pattern.test(text)) ? 'private' : 'public',
70+
});
71+
}
72+
return results;
73+
}
74+
export function isEmptyListsState(text) {
75+
const normalized = normalizeText(text);
76+
return EMPTY_STATE_PATTERNS.some((pattern) => pattern.test(normalized));
77+
}

clis/twitter/lists.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { Argument, Column } from '@jackwener/opencli/types';
2+
declare const args: Argument[];
3+
declare const columns: Column[];
4+
export { args, columns };
5+
export default {};

clis/twitter/lists.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { AuthRequiredError, SelectorError } from '@jackwener/opencli/errors';
2+
import { cli, Strategy } from '@jackwener/opencli/registry';
3+
import { isEmptyListsState, parseListCards } from './lists-parser.js';
4+
5+
cli({
6+
site: 'twitter',
7+
name: 'lists',
8+
description: 'Get Twitter/X lists for a user',
9+
domain: 'x.com',
10+
strategy: Strategy.COOKIE,
11+
browser: true,
12+
args: [
13+
{ name: 'user', positional: true, type: 'string', required: false },
14+
{ name: 'limit', type: 'int', default: 50 },
15+
],
16+
columns: ['name', 'members', 'followers', 'mode'],
17+
func: async (page, kwargs) => {
18+
let targetUser = kwargs.user;
19+
if (!targetUser) {
20+
await page.goto('https://x.com/home');
21+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
22+
const href = await page.evaluate(`() => {
23+
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
24+
return link ? link.getAttribute('href') : null;
25+
}`);
26+
if (!href) {
27+
throw new AuthRequiredError('x.com', 'Could not find logged-in user profile link. Are you logged in?');
28+
}
29+
targetUser = href.replace('/', '');
30+
}
31+
await page.goto(`https://x.com/${targetUser}/lists`);
32+
await page.wait(3);
33+
const pageData = await page.evaluate(`() => {
34+
const cards = [];
35+
const seen = new Set();
36+
for (const anchor of Array.from(document.querySelectorAll('a[href*="/i/lists/"]'))) {
37+
const href = anchor.getAttribute('href') || '';
38+
if (!/\\/i\\/lists\\/\\d+/.test(href) || seen.has(href)) continue;
39+
seen.add(href);
40+
const container = anchor.closest('[data-testid="cellInnerDiv"]') || anchor;
41+
const text = (container.innerText || anchor.innerText || '').trim();
42+
if (!text) continue;
43+
cards.push({ href, text });
44+
}
45+
return {
46+
cards,
47+
pageText: document.body.innerText || '',
48+
};
49+
}`);
50+
if (!pageData?.pageText) {
51+
throw new SelectorError('Twitter lists', 'Empty page text');
52+
}
53+
const results = parseListCards(pageData.cards);
54+
if (results.length === 0) {
55+
if (isEmptyListsState(pageData.pageText)) {
56+
return [];
57+
}
58+
throw new SelectorError('Twitter lists', `Could not parse list data`);
59+
}
60+
return results.slice(0, kwargs.limit);
61+
}
62+
});

clis/twitter/lists.test.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { isEmptyListsState, parseListCards } from './lists-parser.js';
3+
4+
describe('twitter lists parser', () => {
5+
it('parses english list cards without relying on page locale', () => {
6+
const result = parseListCards([
7+
{
8+
href: '/i/lists/123',
9+
text: `AI Researchers
10+
@jack
11+
124 Members 3.4K Followers
12+
Private`,
13+
},
14+
]);
15+
expect(result).toEqual([
16+
{
17+
name: 'AI Researchers',
18+
members: '124',
19+
followers: '3.4K',
20+
mode: 'private',
21+
},
22+
]);
23+
});
24+
25+
it('parses chinese list cards without scanning document.body.innerText', () => {
26+
const result = parseListCards([
27+
{
28+
href: '/i/lists/456',
29+
text: `AI观察
30+
@jack
31+
321 位成员 8.8K 位关注者
32+
锁定列表`,
33+
},
34+
]);
35+
expect(result).toEqual([
36+
{
37+
name: 'AI观察',
38+
members: '321',
39+
followers: '8.8K',
40+
mode: 'private',
41+
},
42+
]);
43+
});
44+
45+
it('detects empty state text in english and chinese', () => {
46+
expect(isEmptyListsState(`@jack hasn't created any Lists yet`)).toBe(true);
47+
expect(isEmptyListsState('这个账号还没有创建任何列表')).toBe(true);
48+
expect(isEmptyListsState('AI Researchers 124 Members')).toBe(false);
49+
});
50+
});

0 commit comments

Comments
 (0)