@jackwener/opencli 1.0.0 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/build-extension.yml +62 -0
- package/.github/workflows/ci.yml +6 -6
- package/.github/workflows/e2e-headed.yml +2 -2
- package/.github/workflows/pkg-pr-new.yml +2 -2
- package/.github/workflows/release.yml +2 -5
- package/.github/workflows/security.yml +2 -2
- package/CDP.md +1 -1
- package/CDP.zh-CN.md +1 -1
- package/README.md +35 -8
- package/README.zh-CN.md +35 -8
- package/SKILL.md +3 -5
- package/dist/browser/cdp.d.ts +27 -0
- package/dist/browser/cdp.js +295 -0
- package/dist/browser/daemon-client.d.ts +1 -1
- package/dist/browser/index.d.ts +4 -2
- package/dist/browser/index.js +5 -5
- package/dist/browser/mcp.d.ts +5 -8
- package/dist/browser/mcp.js +9 -10
- package/dist/browser/page.d.ts +8 -1
- package/dist/browser/page.js +25 -40
- package/dist/browser/utils.d.ts +10 -0
- package/dist/browser/utils.js +27 -0
- package/dist/browser.test.js +48 -7
- package/dist/chaoxing.d.ts +58 -0
- package/dist/chaoxing.js +225 -0
- package/dist/chaoxing.test.d.ts +1 -0
- package/dist/chaoxing.test.js +38 -0
- package/dist/cli-manifest.json +597 -14
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +197 -0
- package/dist/clis/apple-podcasts/episodes.d.ts +1 -0
- package/dist/clis/apple-podcasts/episodes.js +28 -0
- package/dist/clis/apple-podcasts/search.d.ts +1 -0
- package/dist/clis/apple-podcasts/search.js +29 -0
- package/dist/clis/apple-podcasts/top.d.ts +1 -0
- package/dist/clis/apple-podcasts/top.js +34 -0
- package/dist/clis/apple-podcasts/utils.d.ts +11 -0
- package/dist/clis/apple-podcasts/utils.js +30 -0
- package/dist/clis/apple-podcasts/utils.test.d.ts +1 -0
- package/dist/clis/apple-podcasts/utils.test.js +57 -0
- package/dist/clis/boss/chatlist.d.ts +1 -0
- package/dist/clis/boss/chatlist.js +50 -0
- package/dist/clis/boss/chatmsg.d.ts +1 -0
- package/dist/clis/boss/chatmsg.js +73 -0
- package/dist/clis/boss/send.d.ts +1 -0
- package/dist/clis/boss/send.js +176 -0
- package/dist/clis/chaoxing/assignments.d.ts +1 -0
- package/dist/clis/chaoxing/assignments.js +74 -0
- package/dist/clis/chaoxing/exams.d.ts +1 -0
- package/dist/clis/chaoxing/exams.js +74 -0
- package/dist/clis/chatgpt/ask.js +15 -14
- package/dist/clis/chatgpt/ax.d.ts +1 -0
- package/dist/clis/chatgpt/ax.js +78 -0
- package/dist/clis/chatgpt/read.js +5 -6
- package/dist/clis/chatwise/history.js +18 -1
- package/dist/clis/discord-app/channels.js +33 -21
- package/dist/clis/twitter/accept.d.ts +1 -0
- package/dist/clis/twitter/accept.js +202 -0
- package/dist/clis/twitter/followers.js +30 -22
- package/dist/clis/twitter/following.js +19 -14
- package/dist/clis/twitter/notifications.js +29 -22
- package/dist/clis/twitter/post.js +9 -2
- package/dist/clis/twitter/reply-dm.d.ts +1 -0
- package/dist/clis/twitter/reply-dm.js +181 -0
- package/dist/clis/twitter/search.js +30 -11
- package/dist/clis/weread/book.d.ts +1 -0
- package/dist/clis/weread/book.js +26 -0
- package/dist/clis/weread/highlights.d.ts +1 -0
- package/dist/clis/weread/highlights.js +23 -0
- package/dist/clis/weread/notebooks.d.ts +1 -0
- package/dist/clis/weread/notebooks.js +21 -0
- package/dist/clis/weread/notes.d.ts +1 -0
- package/dist/clis/weread/notes.js +29 -0
- package/dist/clis/weread/ranking.d.ts +1 -0
- package/dist/clis/weread/ranking.js +28 -0
- package/dist/clis/weread/search.d.ts +1 -0
- package/dist/clis/weread/search.js +25 -0
- package/dist/clis/weread/shelf.d.ts +1 -0
- package/dist/clis/weread/shelf.js +24 -0
- package/dist/clis/weread/utils.d.ts +20 -0
- package/dist/clis/weread/utils.js +72 -0
- package/dist/clis/weread/utils.test.d.ts +1 -0
- package/dist/clis/weread/utils.test.js +85 -0
- package/dist/clis/xiaohongshu/download.d.ts +1 -1
- package/dist/clis/xiaohongshu/download.js +1 -1
- package/dist/daemon.js +2 -2
- package/dist/doctor.d.ts +0 -21
- package/dist/doctor.js +2 -24
- package/dist/engine.js +24 -13
- package/dist/explore.js +46 -101
- package/dist/main.js +4 -203
- package/dist/output.d.ts +1 -1
- package/dist/registry.d.ts +3 -3
- package/dist/runtime.d.ts +1 -4
- package/dist/runtime.js +1 -4
- package/dist/scripts/framework.d.ts +4 -0
- package/dist/scripts/framework.js +21 -0
- package/dist/scripts/interact.d.ts +4 -0
- package/dist/scripts/interact.js +20 -0
- package/dist/scripts/store.d.ts +9 -0
- package/dist/scripts/store.js +44 -0
- package/dist/setup.js +2 -2
- package/dist/synthesize.js +1 -1
- package/extension/dist/background.js +392 -0
- package/extension/manifest.json +3 -3
- package/extension/package.json +1 -1
- package/extension/src/background.ts +101 -24
- package/extension/src/protocol.ts +1 -1
- package/package.json +1 -1
- package/src/browser/cdp.ts +295 -0
- package/src/browser/daemon-client.ts +1 -1
- package/src/browser/index.ts +5 -6
- package/src/browser/mcp.ts +14 -15
- package/src/browser/page.ts +25 -41
- package/src/browser/utils.ts +27 -0
- package/src/browser.test.ts +52 -6
- package/src/chaoxing.test.ts +45 -0
- package/src/chaoxing.ts +268 -0
- package/src/cli.ts +185 -0
- package/src/clis/antigravity/SKILL.md +5 -0
- package/src/clis/apple-podcasts/episodes.ts +28 -0
- package/src/clis/apple-podcasts/search.ts +29 -0
- package/src/clis/apple-podcasts/top.ts +34 -0
- package/src/clis/apple-podcasts/utils.test.ts +72 -0
- package/src/clis/apple-podcasts/utils.ts +37 -0
- package/src/clis/boss/chatlist.ts +50 -0
- package/src/clis/boss/chatmsg.ts +70 -0
- package/src/clis/boss/send.ts +193 -0
- package/src/clis/chaoxing/README.md +36 -0
- package/src/clis/chaoxing/README.zh-CN.md +35 -0
- package/src/clis/chaoxing/assignments.ts +88 -0
- package/src/clis/chaoxing/exams.ts +88 -0
- package/src/clis/chatgpt/ask.ts +14 -15
- package/src/clis/chatgpt/ax.ts +81 -0
- package/src/clis/chatgpt/read.ts +5 -7
- package/src/clis/chatwise/history.ts +15 -1
- package/src/clis/discord-app/channels.ts +33 -21
- package/src/clis/twitter/accept.ts +213 -0
- package/src/clis/twitter/followers.ts +36 -29
- package/src/clis/twitter/following.ts +25 -20
- package/src/clis/twitter/notifications.ts +34 -27
- package/src/clis/twitter/post.ts +9 -2
- package/src/clis/twitter/reply-dm.ts +193 -0
- package/src/clis/twitter/search.ts +34 -12
- package/src/clis/weread/book.ts +28 -0
- package/src/clis/weread/highlights.ts +25 -0
- package/src/clis/weread/notebooks.ts +23 -0
- package/src/clis/weread/notes.ts +31 -0
- package/src/clis/weread/ranking.ts +29 -0
- package/src/clis/weread/search.ts +26 -0
- package/src/clis/weread/shelf.ts +26 -0
- package/src/clis/weread/utils.test.ts +104 -0
- package/src/clis/weread/utils.ts +74 -0
- package/src/clis/xiaohongshu/download.ts +1 -1
- package/src/daemon.ts +2 -2
- package/src/doctor.ts +2 -19
- package/src/engine.ts +20 -13
- package/src/explore.ts +51 -100
- package/src/main.ts +4 -186
- package/src/output.ts +12 -12
- package/src/registry.ts +3 -3
- package/src/runtime.ts +2 -6
- package/src/scripts/framework.ts +20 -0
- package/src/scripts/interact.ts +22 -0
- package/src/scripts/store.ts +40 -0
- package/src/setup.ts +2 -2
- package/src/synthesize.ts +1 -1
- package/tests/e2e/public-commands.test.ts +68 -1
- package/dist/clis/grok/debug.d.ts +0 -1
- package/dist/clis/grok/debug.js +0 -45
- package/src/clis/grok/debug.ts +0 -49
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
cli({
|
|
3
|
+
site: 'twitter',
|
|
4
|
+
name: 'accept',
|
|
5
|
+
description: 'Auto-accept DM requests containing specific keywords',
|
|
6
|
+
domain: 'x.com',
|
|
7
|
+
strategy: Strategy.UI,
|
|
8
|
+
browser: true,
|
|
9
|
+
timeoutSeconds: 600, // 10 min — batch operation iterating many conversations
|
|
10
|
+
args: [
|
|
11
|
+
{ name: 'keyword', type: 'string', required: true, help: 'Keywords to match (comma-separated for OR, e.g. "群,微信")' },
|
|
12
|
+
{ name: 'max', type: 'int', required: false, default: 20, help: 'Maximum number of requests to accept (default: 20)' },
|
|
13
|
+
],
|
|
14
|
+
columns: ['index', 'status', 'user', 'message'],
|
|
15
|
+
func: async (page, kwargs) => {
|
|
16
|
+
if (!page)
|
|
17
|
+
throw new Error('Requires browser');
|
|
18
|
+
const keywords = kwargs.keyword.split(',').map((k) => k.trim()).filter(Boolean);
|
|
19
|
+
const maxAccepts = kwargs.max ?? 20;
|
|
20
|
+
const results = [];
|
|
21
|
+
let acceptCount = 0;
|
|
22
|
+
// Track already-visited conversations to avoid infinite loops
|
|
23
|
+
const visited = new Set();
|
|
24
|
+
for (let round = 0; round < maxAccepts + 50; round++) {
|
|
25
|
+
if (acceptCount >= maxAccepts)
|
|
26
|
+
break;
|
|
27
|
+
// Step 1: Navigate to DM requests page
|
|
28
|
+
await page.goto('https://x.com/messages/requests');
|
|
29
|
+
await page.wait(4);
|
|
30
|
+
// Step 2: Get conversations with scroll-to-load
|
|
31
|
+
const convInfo = await page.evaluate(`(async () => {
|
|
32
|
+
try {
|
|
33
|
+
// Wait for initial items
|
|
34
|
+
let attempts = 0;
|
|
35
|
+
while (attempts < 10) {
|
|
36
|
+
const convs = document.querySelectorAll('[data-testid="conversation"]');
|
|
37
|
+
if (convs.length > 0) break;
|
|
38
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
39
|
+
attempts++;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Scroll to load more
|
|
43
|
+
const seenCount = new Set();
|
|
44
|
+
let noNewCount = 0;
|
|
45
|
+
for (let scroll = 0; scroll < 20; scroll++) {
|
|
46
|
+
const convs = Array.from(document.querySelectorAll('[data-testid="conversation"]'));
|
|
47
|
+
const prevSize = seenCount.size;
|
|
48
|
+
convs.forEach((_, i) => seenCount.add(i));
|
|
49
|
+
if (convs.length >= ${maxAccepts + 10}) break;
|
|
50
|
+
|
|
51
|
+
// Scroll last item into view
|
|
52
|
+
if (convs.length > 0) {
|
|
53
|
+
convs[convs.length - 1].scrollIntoView({ behavior: 'instant', block: 'end' });
|
|
54
|
+
}
|
|
55
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
56
|
+
|
|
57
|
+
if (seenCount.size <= prevSize) {
|
|
58
|
+
noNewCount++;
|
|
59
|
+
if (noNewCount >= 3) break;
|
|
60
|
+
} else {
|
|
61
|
+
noNewCount = 0;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const convs = Array.from(document.querySelectorAll('[data-testid="conversation"]'));
|
|
66
|
+
if (convs.length === 0) return { ok: false, count: 0, items: [] };
|
|
67
|
+
|
|
68
|
+
const items = convs.map((conv, idx) => {
|
|
69
|
+
const text = conv.innerText || '';
|
|
70
|
+
const link = conv.querySelector('a[href]');
|
|
71
|
+
const href = link ? link.href : '';
|
|
72
|
+
const lines = text.split('\\n').filter(l => l.trim());
|
|
73
|
+
const user = lines[0] || 'Unknown';
|
|
74
|
+
return { idx, text, href, user };
|
|
75
|
+
});
|
|
76
|
+
return { ok: true, count: convs.length, items };
|
|
77
|
+
} catch(e) {
|
|
78
|
+
return { ok: false, error: String(e), count: 0, items: [] };
|
|
79
|
+
}
|
|
80
|
+
})()`);
|
|
81
|
+
if (!convInfo?.ok || convInfo.count === 0) {
|
|
82
|
+
if (results.length === 0) {
|
|
83
|
+
results.push({ index: 1, status: 'info', user: 'System', message: 'No message requests found' });
|
|
84
|
+
}
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
let foundInThisRound = false;
|
|
88
|
+
// Step 3: Find first unvisited conversation with keyword match in preview
|
|
89
|
+
for (const item of convInfo.items) {
|
|
90
|
+
if (acceptCount >= maxAccepts)
|
|
91
|
+
break;
|
|
92
|
+
const convKey = item.href || `conv-${item.idx}`;
|
|
93
|
+
if (visited.has(convKey))
|
|
94
|
+
continue;
|
|
95
|
+
visited.add(convKey);
|
|
96
|
+
// Check if preview text contains any keyword
|
|
97
|
+
const previewMatch = keywords.some((k) => item.text.includes(k));
|
|
98
|
+
if (!previewMatch)
|
|
99
|
+
continue;
|
|
100
|
+
// Step 4: Click this conversation to open it
|
|
101
|
+
const clickResult = await page.evaluate(`(async () => {
|
|
102
|
+
try {
|
|
103
|
+
const convs = Array.from(document.querySelectorAll('[data-testid="conversation"]'));
|
|
104
|
+
const conv = convs[${item.idx}];
|
|
105
|
+
if (!conv) return { ok: false, error: 'Conversation element not found' };
|
|
106
|
+
conv.click();
|
|
107
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
108
|
+
return { ok: true };
|
|
109
|
+
} catch(e) {
|
|
110
|
+
return { ok: false, error: String(e) };
|
|
111
|
+
}
|
|
112
|
+
})()`);
|
|
113
|
+
if (!clickResult?.ok)
|
|
114
|
+
continue;
|
|
115
|
+
// Wait for conversation to load
|
|
116
|
+
await page.wait(2);
|
|
117
|
+
// Step 5: Read full chat content and find Accept button
|
|
118
|
+
const res = await page.evaluate(`(async () => {
|
|
119
|
+
try {
|
|
120
|
+
const keywords = ${JSON.stringify(keywords)};
|
|
121
|
+
|
|
122
|
+
// Get username from conversation header
|
|
123
|
+
const heading = document.querySelector('[data-testid="conversation-header"]') ||
|
|
124
|
+
document.querySelector('[data-testid="DM-conversation-header"]');
|
|
125
|
+
let username = 'Unknown';
|
|
126
|
+
if (heading) {
|
|
127
|
+
username = heading.innerText.trim().split('\\n')[0];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Read full chat area text
|
|
131
|
+
const chatArea = document.querySelector('[data-testid="DmScrollerContainer"]') ||
|
|
132
|
+
document.querySelector('[data-testid="DMConversationBody"]') ||
|
|
133
|
+
document.querySelector('main [data-testid="cellInnerDiv"]')?.closest('section') ||
|
|
134
|
+
document.querySelector('main');
|
|
135
|
+
const text = chatArea ? chatArea.innerText : '';
|
|
136
|
+
|
|
137
|
+
// Verify keyword match in full chat content
|
|
138
|
+
const matchedKw = keywords.filter(k => text.includes(k));
|
|
139
|
+
if (matchedKw.length === 0) {
|
|
140
|
+
return { status: 'skipped', user: username, message: 'No keyword match in full content' };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Find the Accept button
|
|
144
|
+
const allBtns = Array.from(document.querySelectorAll('[role="button"]'));
|
|
145
|
+
const acceptBtn = allBtns.find(btn => {
|
|
146
|
+
const t = btn.innerText.trim().toLowerCase();
|
|
147
|
+
return t === 'accept' || t === '接受';
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
if (!acceptBtn) {
|
|
151
|
+
return { status: 'no_button', user: username, message: 'Keyword matched but no Accept button (already accepted?)' };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Click Accept
|
|
155
|
+
acceptBtn.click();
|
|
156
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
157
|
+
|
|
158
|
+
// Check for confirmation dialog
|
|
159
|
+
const btnsAfter = Array.from(document.querySelectorAll('[role="button"]'));
|
|
160
|
+
const confirmBtn = btnsAfter.find(btn => {
|
|
161
|
+
const t = btn.innerText.trim().toLowerCase();
|
|
162
|
+
return (t === 'accept' || t === '接受') && btn !== acceptBtn;
|
|
163
|
+
});
|
|
164
|
+
if (confirmBtn) {
|
|
165
|
+
confirmBtn.click();
|
|
166
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { status: 'accepted', user: username, message: 'Accepted! Matched: ' + matchedKw.join(', ') };
|
|
170
|
+
} catch(e) {
|
|
171
|
+
return { status: 'error', user: 'system', message: String(e) };
|
|
172
|
+
}
|
|
173
|
+
})()`);
|
|
174
|
+
if (res?.status === 'accepted') {
|
|
175
|
+
acceptCount++;
|
|
176
|
+
foundInThisRound = true;
|
|
177
|
+
results.push({
|
|
178
|
+
index: acceptCount,
|
|
179
|
+
status: 'accepted',
|
|
180
|
+
user: res.user || 'Unknown',
|
|
181
|
+
message: res.message || 'Accepted',
|
|
182
|
+
});
|
|
183
|
+
// After accept, Twitter redirects to /messages — loop back to /messages/requests
|
|
184
|
+
await page.wait(2);
|
|
185
|
+
break; // break inner loop, outer loop will re-navigate to requests
|
|
186
|
+
}
|
|
187
|
+
else if (res?.status === 'no_button') {
|
|
188
|
+
// Already accepted, skip
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// If no match found in this round, we've exhausted all visible requests
|
|
193
|
+
if (!foundInThisRound) {
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (results.length === 0) {
|
|
198
|
+
results.push({ index: 0, status: 'info', user: 'System', message: `No requests matched keywords "${keywords.join(', ')}"` });
|
|
199
|
+
}
|
|
200
|
+
return results;
|
|
201
|
+
}
|
|
202
|
+
});
|
|
@@ -13,10 +13,9 @@ cli({
|
|
|
13
13
|
columns: ['screen_name', 'name', 'bio', 'followers'],
|
|
14
14
|
func: async (page, kwargs) => {
|
|
15
15
|
let targetUser = kwargs.user;
|
|
16
|
-
// If no user is specified,
|
|
16
|
+
// If no user is specified, figure out the logged-in user's handle
|
|
17
17
|
if (!targetUser) {
|
|
18
18
|
await page.goto('https://x.com/home');
|
|
19
|
-
// wait for home page navigation
|
|
20
19
|
await page.wait(5);
|
|
21
20
|
const href = await page.evaluate(`() => {
|
|
22
21
|
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
|
@@ -27,34 +26,44 @@ cli({
|
|
|
27
26
|
}
|
|
28
27
|
targetUser = href.replace('/', '');
|
|
29
28
|
}
|
|
30
|
-
// 1. Navigate to
|
|
29
|
+
// 1. Navigate to profile page
|
|
31
30
|
await page.goto(`https://x.com/${targetUser}`);
|
|
32
31
|
await page.wait(3);
|
|
33
|
-
// 2.
|
|
32
|
+
// 2. Install interceptor BEFORE SPA navigation.
|
|
33
|
+
// goto() resets JS context, but SPA click preserves it.
|
|
34
34
|
await page.installInterceptor('Followers');
|
|
35
|
-
// 3. Click the followers link
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
35
|
+
// 3. Click the followers link via SPA navigation (preserves interceptor).
|
|
36
|
+
// Twitter uses /verified_followers instead of /followers now.
|
|
37
|
+
const safeUser = JSON.stringify(targetUser);
|
|
38
|
+
const clicked = await page.evaluate(`() => {
|
|
39
|
+
const target = ${safeUser};
|
|
40
|
+
const selectors = [
|
|
41
|
+
'a[href="/' + target + '/verified_followers"]',
|
|
42
|
+
'a[href="/' + target + '/followers"]',
|
|
43
|
+
];
|
|
44
|
+
for (const sel of selectors) {
|
|
45
|
+
const link = document.querySelector(sel);
|
|
46
|
+
if (link) { link.click(); return true; }
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
40
49
|
}`);
|
|
41
|
-
|
|
42
|
-
|
|
50
|
+
if (!clicked) {
|
|
51
|
+
throw new Error('Could not find followers link on profile page. Twitter may have changed the layout.');
|
|
52
|
+
}
|
|
53
|
+
await page.wait(5);
|
|
54
|
+
// 4. Scroll to trigger pagination API calls
|
|
43
55
|
await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
|
|
44
|
-
//
|
|
45
|
-
const
|
|
46
|
-
const requestList = Array.isArray(
|
|
56
|
+
// 5. Retrieve intercepted data
|
|
57
|
+
const requests = await page.getInterceptedRequests();
|
|
58
|
+
const requestList = Array.isArray(requests) ? requests : [];
|
|
47
59
|
if (requestList.length === 0) {
|
|
48
60
|
return [];
|
|
49
61
|
}
|
|
50
|
-
const requests = requestList.filter((r) => r?.url?.includes('Followers'));
|
|
51
|
-
if (!requests || requests.length === 0) {
|
|
52
|
-
return [];
|
|
53
|
-
}
|
|
54
62
|
let results = [];
|
|
55
|
-
for (const req of
|
|
63
|
+
for (const req of requestList) {
|
|
56
64
|
try {
|
|
57
|
-
|
|
65
|
+
// GraphQL response: { data: { user: { result: { timeline: ... } } } }
|
|
66
|
+
let instructions = req.data?.user?.result?.timeline?.timeline?.instructions;
|
|
58
67
|
if (!instructions)
|
|
59
68
|
continue;
|
|
60
69
|
let addEntries = instructions.find((i) => i.type === 'TimelineAddEntries');
|
|
@@ -69,7 +78,6 @@ cli({
|
|
|
69
78
|
const item = entry.content?.itemContent?.user_results?.result;
|
|
70
79
|
if (!item || item.__typename !== 'User')
|
|
71
80
|
continue;
|
|
72
|
-
// Twitter GraphQL sometimes nests `core` differently depending on the endpoint profile state
|
|
73
81
|
const core = item.core || {};
|
|
74
82
|
const legacy = item.legacy || {};
|
|
75
83
|
results.push({
|
|
@@ -84,7 +92,7 @@ cli({
|
|
|
84
92
|
// ignore parsing errors for individual payloads
|
|
85
93
|
}
|
|
86
94
|
}
|
|
87
|
-
// Deduplicate by screen_name
|
|
95
|
+
// Deduplicate by screen_name
|
|
88
96
|
const unique = new Map();
|
|
89
97
|
results.forEach(r => unique.set(r.screen_name, r));
|
|
90
98
|
const deduplicatedResults = Array.from(unique.values());
|
|
@@ -13,10 +13,9 @@ cli({
|
|
|
13
13
|
columns: ['screen_name', 'name', 'bio', 'followers'],
|
|
14
14
|
func: async (page, kwargs) => {
|
|
15
15
|
let targetUser = kwargs.user;
|
|
16
|
-
// If no user is specified,
|
|
16
|
+
// If no user is specified, figure out the logged-in user's handle
|
|
17
17
|
if (!targetUser) {
|
|
18
18
|
await page.goto('https://x.com/home');
|
|
19
|
-
// wait for home page navigation
|
|
20
19
|
await page.wait(5);
|
|
21
20
|
const href = await page.evaluate(`() => {
|
|
22
21
|
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
|
@@ -27,21 +26,27 @@ cli({
|
|
|
27
26
|
}
|
|
28
27
|
targetUser = href.replace('/', '');
|
|
29
28
|
}
|
|
30
|
-
// 1. Navigate to
|
|
29
|
+
// 1. Navigate to profile page
|
|
31
30
|
await page.goto(`https://x.com/${targetUser}`);
|
|
32
31
|
await page.wait(3);
|
|
33
|
-
// 2.
|
|
32
|
+
// 2. Install interceptor BEFORE SPA navigation.
|
|
33
|
+
// goto() resets JS context, but SPA click preserves it.
|
|
34
34
|
await page.installInterceptor('Following');
|
|
35
|
-
// 3. Click the following link
|
|
36
|
-
|
|
37
|
-
const
|
|
35
|
+
// 3. Click the following link via SPA navigation (preserves interceptor)
|
|
36
|
+
const safeUser = JSON.stringify(targetUser);
|
|
37
|
+
const clicked = await page.evaluate(`() => {
|
|
38
|
+
const target = ${safeUser};
|
|
38
39
|
const link = document.querySelector('a[href="/' + target + '/following"]');
|
|
39
|
-
if (link) link.click();
|
|
40
|
+
if (link) { link.click(); return true; }
|
|
41
|
+
return false;
|
|
40
42
|
}`);
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
if (!clicked) {
|
|
44
|
+
throw new Error('Could not find following link on profile page. Twitter may have changed the layout.');
|
|
45
|
+
}
|
|
46
|
+
await page.wait(5);
|
|
47
|
+
// 4. Scroll to trigger pagination API calls
|
|
43
48
|
await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
|
|
44
|
-
//
|
|
49
|
+
// 5. Retrieve intercepted data
|
|
45
50
|
const requests = await page.getInterceptedRequests();
|
|
46
51
|
const requestList = Array.isArray(requests) ? requests : [];
|
|
47
52
|
if (requestList.length === 0) {
|
|
@@ -50,7 +55,8 @@ cli({
|
|
|
50
55
|
let results = [];
|
|
51
56
|
for (const req of requestList) {
|
|
52
57
|
try {
|
|
53
|
-
|
|
58
|
+
// GraphQL response: { data: { user: { result: { timeline: ... } } } }
|
|
59
|
+
let instructions = req.data?.user?.result?.timeline?.timeline?.instructions;
|
|
54
60
|
if (!instructions)
|
|
55
61
|
continue;
|
|
56
62
|
let addEntries = instructions.find((i) => i.type === 'TimelineAddEntries');
|
|
@@ -65,7 +71,6 @@ cli({
|
|
|
65
71
|
const item = entry.content?.itemContent?.user_results?.result;
|
|
66
72
|
if (!item || item.__typename !== 'User')
|
|
67
73
|
continue;
|
|
68
|
-
// Twitter GraphQL sometimes nests `core` differently depending on the endpoint profile state
|
|
69
74
|
const core = item.core || {};
|
|
70
75
|
const legacy = item.legacy || {};
|
|
71
76
|
results.push({
|
|
@@ -80,7 +85,7 @@ cli({
|
|
|
80
85
|
// ignore parsing errors for individual payloads
|
|
81
86
|
}
|
|
82
87
|
}
|
|
83
|
-
// Deduplicate by screen_name
|
|
88
|
+
// Deduplicate by screen_name
|
|
84
89
|
const unique = new Map();
|
|
85
90
|
results.forEach(r => unique.set(r.screen_name, r));
|
|
86
91
|
const deduplicatedResults = Array.from(unique.values());
|
|
@@ -11,17 +11,25 @@ cli({
|
|
|
11
11
|
],
|
|
12
12
|
columns: ['id', 'action', 'author', 'text', 'url'],
|
|
13
13
|
func: async (page, kwargs) => {
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
await page.
|
|
17
|
-
|
|
14
|
+
// 1. Navigate to home first (we need a loaded Twitter page for SPA navigation)
|
|
15
|
+
await page.goto('https://x.com/home');
|
|
16
|
+
await page.wait(3);
|
|
17
|
+
// 2. Install interceptor BEFORE SPA navigation
|
|
18
18
|
await page.installInterceptor('NotificationsTimeline');
|
|
19
|
-
//
|
|
20
|
-
await page.
|
|
19
|
+
// 3. SPA navigate to notifications via history API
|
|
20
|
+
await page.evaluate(`() => {
|
|
21
|
+
window.history.pushState({}, '', '/notifications');
|
|
22
|
+
window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
|
|
23
|
+
}`);
|
|
21
24
|
await page.wait(5);
|
|
22
|
-
//
|
|
25
|
+
// Verify SPA navigation succeeded
|
|
26
|
+
const currentUrl = await page.evaluate('() => window.location.pathname');
|
|
27
|
+
if (currentUrl !== '/notifications') {
|
|
28
|
+
throw new Error('SPA navigation to notifications failed. Twitter may have changed its routing.');
|
|
29
|
+
}
|
|
30
|
+
// 4. Scroll to trigger pagination
|
|
23
31
|
await page.autoScroll({ times: 2, delayMs: 2000 });
|
|
24
|
-
//
|
|
32
|
+
// 5. Retrieve data
|
|
25
33
|
const requests = await page.getInterceptedRequests();
|
|
26
34
|
if (!requests || requests.length === 0)
|
|
27
35
|
return [];
|
|
@@ -29,18 +37,18 @@ cli({
|
|
|
29
37
|
const seen = new Set();
|
|
30
38
|
for (const req of requests) {
|
|
31
39
|
try {
|
|
40
|
+
// GraphQL response: { data: { viewer: ... } } (one level of .data)
|
|
32
41
|
let instructions = [];
|
|
33
|
-
if (req.data?.
|
|
34
|
-
instructions = req.data.
|
|
42
|
+
if (req.data?.viewer?.timeline_response?.timeline?.instructions) {
|
|
43
|
+
instructions = req.data.viewer.timeline_response.timeline.instructions;
|
|
35
44
|
}
|
|
36
|
-
else if (req.data?.
|
|
37
|
-
instructions = req.data.
|
|
45
|
+
else if (req.data?.viewer_v2?.user_results?.result?.notification_timeline?.timeline?.instructions) {
|
|
46
|
+
instructions = req.data.viewer_v2.user_results.result.notification_timeline.timeline.instructions;
|
|
38
47
|
}
|
|
39
|
-
else if (req.data?.
|
|
40
|
-
instructions = req.data.
|
|
48
|
+
else if (req.data?.timeline?.instructions) {
|
|
49
|
+
instructions = req.data.timeline.instructions;
|
|
41
50
|
}
|
|
42
51
|
let addEntries = instructions.find((i) => i.type === 'TimelineAddEntries');
|
|
43
|
-
// Sometimes it's the first object without a 'type' field but has 'entries'
|
|
44
52
|
if (!addEntries) {
|
|
45
53
|
addEntries = instructions.find((i) => i.entries && Array.isArray(i.entries));
|
|
46
54
|
}
|
|
@@ -60,20 +68,18 @@ cli({
|
|
|
60
68
|
function processNotificationItem(itemContent, entryId) {
|
|
61
69
|
if (!itemContent)
|
|
62
70
|
return;
|
|
63
|
-
// Twitter wraps standard notifications
|
|
64
71
|
let item = itemContent?.notification_results?.result || itemContent?.tweet_results?.result || itemContent;
|
|
65
72
|
let actionText = 'Notification';
|
|
66
73
|
let author = 'unknown';
|
|
67
74
|
let text = '';
|
|
68
75
|
let urlStr = '';
|
|
69
76
|
if (item.__typename === 'TimelineNotification') {
|
|
70
|
-
// Greet likes, retweet, mentions
|
|
71
77
|
text = item.rich_message?.text || item.message?.text || '';
|
|
72
78
|
const fromUser = item.template?.from_users?.[0]?.user_results?.result;
|
|
73
|
-
|
|
79
|
+
// Twitter moved screen_name from legacy to core
|
|
80
|
+
author = fromUser?.core?.screen_name || fromUser?.legacy?.screen_name || 'unknown';
|
|
74
81
|
urlStr = item.notification_url?.url || '';
|
|
75
82
|
actionText = item.notification_icon || 'Activity';
|
|
76
|
-
// If there's an attached tweet
|
|
77
83
|
const targetTweet = item.template?.target_objects?.[0]?.tweet_results?.result;
|
|
78
84
|
if (targetTweet) {
|
|
79
85
|
const targetText = targetTweet.note_tweet?.note_tweet_results?.result?.text || targetTweet.legacy?.full_text || '';
|
|
@@ -84,15 +90,16 @@ cli({
|
|
|
84
90
|
}
|
|
85
91
|
}
|
|
86
92
|
else if (item.__typename === 'TweetNotification') {
|
|
87
|
-
// Direct mention/reply
|
|
88
93
|
const tweet = item.tweet_result?.result;
|
|
89
|
-
|
|
94
|
+
const tweetUser = tweet?.core?.user_results?.result;
|
|
95
|
+
author = tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || 'unknown';
|
|
90
96
|
text = tweet?.note_tweet?.note_tweet_results?.result?.text || tweet?.legacy?.full_text || item.message?.text || '';
|
|
91
97
|
actionText = 'Mention/Reply';
|
|
92
98
|
urlStr = `https://x.com/i/status/${tweet?.rest_id}`;
|
|
93
99
|
}
|
|
94
100
|
else if (item.__typename === 'Tweet') {
|
|
95
|
-
|
|
101
|
+
const tweetUser = item.core?.user_results?.result;
|
|
102
|
+
author = tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || 'unknown';
|
|
96
103
|
text = item.note_tweet?.note_tweet_results?.result?.text || item.legacy?.full_text || '';
|
|
97
104
|
actionText = 'Mention';
|
|
98
105
|
urlStr = `https://x.com/i/status/${item.rest_id}`;
|
|
@@ -23,8 +23,15 @@ cli({
|
|
|
23
23
|
const box = document.querySelector('[data-testid="tweetTextarea_0"]');
|
|
24
24
|
if (box) {
|
|
25
25
|
box.focus();
|
|
26
|
-
//
|
|
27
|
-
|
|
26
|
+
// Simulate a paste event to properly handle newlines in Draft.js/React
|
|
27
|
+
const textToInsert = ${JSON.stringify(kwargs.text)};
|
|
28
|
+
const dataTransfer = new DataTransfer();
|
|
29
|
+
dataTransfer.setData('text/plain', textToInsert);
|
|
30
|
+
box.dispatchEvent(new ClipboardEvent('paste', {
|
|
31
|
+
clipboardData: dataTransfer,
|
|
32
|
+
bubbles: true,
|
|
33
|
+
cancelable: true
|
|
34
|
+
}));
|
|
28
35
|
} else {
|
|
29
36
|
return { ok: false, message: 'Could not find the tweet composer text area.' };
|
|
30
37
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|