@jackwener/opencli 1.0.0 → 1.0.1
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/README.md +20 -1
- package/README.zh-CN.md +20 -1
- package/dist/browser/daemon-client.d.ts +1 -1
- package/dist/browser/index.d.ts +1 -2
- package/dist/browser/index.js +1 -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 +23 -17
- package/dist/browser.test.js +6 -6
- package/dist/cli-manifest.json +394 -14
- 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/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/reply-dm.d.ts +1 -0
- package/dist/clis/twitter/reply-dm.js +181 -0
- package/dist/clis/twitter/search.js +50 -12
- 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/daemon.js +2 -2
- package/dist/doctor.d.ts +0 -21
- package/dist/doctor.js +2 -24
- package/dist/main.js +6 -16
- package/dist/runtime.d.ts +1 -4
- package/dist/runtime.js +1 -4
- package/dist/setup.js +2 -2
- package/extension/dist/background.js +484 -0
- package/extension/manifest.json +1 -1
- package/extension/package.json +1 -1
- package/extension/src/background.ts +99 -22
- package/extension/src/protocol.ts +1 -1
- package/package.json +1 -1
- package/src/browser/daemon-client.ts +1 -1
- package/src/browser/index.ts +1 -6
- package/src/browser/mcp.ts +14 -15
- package/src/browser/page.ts +23 -17
- package/src/browser.test.ts +6 -6
- 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/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/reply-dm.ts +193 -0
- package/src/clis/twitter/search.ts +53 -13
- 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/daemon.ts +2 -2
- package/src/doctor.ts +2 -19
- package/src/main.ts +5 -11
- package/src/runtime.ts +2 -6
- package/src/setup.ts +2 -2
- 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
|
@@ -14,28 +14,40 @@ export const channelsCommand = cli({
|
|
|
14
14
|
const channels = await page.evaluate(`
|
|
15
15
|
(function() {
|
|
16
16
|
const results = [];
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
17
|
+
|
|
18
|
+
// Discord channel links: <a> tags with href like /channels/GUILD/CHANNEL
|
|
19
|
+
const links = document.querySelectorAll('a[href*="/channels/"][data-list-item-id^="channels___"]');
|
|
20
|
+
|
|
21
|
+
links.forEach(function(el) {
|
|
22
|
+
var label = el.getAttribute('aria-label') || '';
|
|
23
|
+
if (!label) return;
|
|
24
|
+
|
|
25
|
+
// Skip categories
|
|
26
|
+
if (/[((]category[))]/i.test(label)) return;
|
|
27
|
+
|
|
28
|
+
// Strip any leading status prefix before the first comma (e.g. "unread, ", locale-agnostic)
|
|
29
|
+
var commaIdx = label.search(/[,,]/);
|
|
30
|
+
var cleaned = commaIdx !== -1 ? label.slice(commaIdx + 1).trimStart() : label;
|
|
31
|
+
|
|
32
|
+
// Extract name and type from "name (type)" or "name(type)"
|
|
33
|
+
var m = cleaned.match(/^(.+?)\s*[((](.+?)[))]\s*$/);
|
|
34
|
+
// If no type annotation found, skip — real channels always have "(Type channel)" in aria-label
|
|
35
|
+
if (!m) return;
|
|
36
|
+
var name = m[1].trim();
|
|
37
|
+
var rawType = m[2].toLowerCase();
|
|
38
|
+
|
|
39
|
+
// Discord channel names are ASCII-only; skip placeholder entries (e.g. locked channels)
|
|
40
|
+
if (!name || !/^[\x20-\x7E]+$/.test(name)) return;
|
|
41
|
+
|
|
42
|
+
var type = 'Text';
|
|
43
|
+
if (rawType.includes('voice')) type = 'Voice';
|
|
44
|
+
else if (rawType.includes('forum')) type = 'Forum';
|
|
45
|
+
else if (rawType.includes('announcement')) type = 'Announcement';
|
|
46
|
+
else if (rawType.includes('stage')) type = 'Stage';
|
|
47
|
+
|
|
48
|
+
results.push({ Index: results.length + 1, Channel: name, Type: type });
|
|
37
49
|
});
|
|
38
|
-
|
|
50
|
+
|
|
39
51
|
return results;
|
|
40
52
|
})()
|
|
41
53
|
`);
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import type { IPage } from '../../types.js';
|
|
3
|
+
|
|
4
|
+
cli({
|
|
5
|
+
site: 'twitter',
|
|
6
|
+
name: 'accept',
|
|
7
|
+
description: 'Auto-accept DM requests containing specific keywords',
|
|
8
|
+
domain: 'x.com',
|
|
9
|
+
strategy: Strategy.UI,
|
|
10
|
+
browser: true,
|
|
11
|
+
timeoutSeconds: 600, // 10 min — batch operation iterating many conversations
|
|
12
|
+
args: [
|
|
13
|
+
{ name: 'keyword', type: 'string', required: true, help: 'Keywords to match (comma-separated for OR, e.g. "群,微信")' },
|
|
14
|
+
{ name: 'max', type: 'int', required: false, default: 20, help: 'Maximum number of requests to accept (default: 20)' },
|
|
15
|
+
],
|
|
16
|
+
columns: ['index', 'status', 'user', 'message'],
|
|
17
|
+
func: async (page: IPage | null, kwargs: any) => {
|
|
18
|
+
if (!page) throw new Error('Requires browser');
|
|
19
|
+
|
|
20
|
+
const keywords: string[] = kwargs.keyword.split(',').map((k: string) => k.trim()).filter(Boolean);
|
|
21
|
+
const maxAccepts: number = kwargs.max ?? 20;
|
|
22
|
+
const results: Array<{ index: number; status: string; user: string; message: string }> = [];
|
|
23
|
+
let acceptCount = 0;
|
|
24
|
+
// Track already-visited conversations to avoid infinite loops
|
|
25
|
+
const visited = new Set<string>();
|
|
26
|
+
|
|
27
|
+
for (let round = 0; round < maxAccepts + 50; round++) {
|
|
28
|
+
if (acceptCount >= maxAccepts) break;
|
|
29
|
+
|
|
30
|
+
// Step 1: Navigate to DM requests page
|
|
31
|
+
await page.goto('https://x.com/messages/requests');
|
|
32
|
+
await page.wait(4);
|
|
33
|
+
|
|
34
|
+
// Step 2: Get conversations with scroll-to-load
|
|
35
|
+
const convInfo = await page.evaluate(`(async () => {
|
|
36
|
+
try {
|
|
37
|
+
// Wait for initial items
|
|
38
|
+
let attempts = 0;
|
|
39
|
+
while (attempts < 10) {
|
|
40
|
+
const convs = document.querySelectorAll('[data-testid="conversation"]');
|
|
41
|
+
if (convs.length > 0) break;
|
|
42
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
43
|
+
attempts++;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Scroll to load more
|
|
47
|
+
const seenCount = new Set();
|
|
48
|
+
let noNewCount = 0;
|
|
49
|
+
for (let scroll = 0; scroll < 20; scroll++) {
|
|
50
|
+
const convs = Array.from(document.querySelectorAll('[data-testid="conversation"]'));
|
|
51
|
+
const prevSize = seenCount.size;
|
|
52
|
+
convs.forEach((_, i) => seenCount.add(i));
|
|
53
|
+
if (convs.length >= ${maxAccepts + 10}) break;
|
|
54
|
+
|
|
55
|
+
// Scroll last item into view
|
|
56
|
+
if (convs.length > 0) {
|
|
57
|
+
convs[convs.length - 1].scrollIntoView({ behavior: 'instant', block: 'end' });
|
|
58
|
+
}
|
|
59
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
60
|
+
|
|
61
|
+
if (seenCount.size <= prevSize) {
|
|
62
|
+
noNewCount++;
|
|
63
|
+
if (noNewCount >= 3) break;
|
|
64
|
+
} else {
|
|
65
|
+
noNewCount = 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const convs = Array.from(document.querySelectorAll('[data-testid="conversation"]'));
|
|
70
|
+
if (convs.length === 0) return { ok: false, count: 0, items: [] };
|
|
71
|
+
|
|
72
|
+
const items = convs.map((conv, idx) => {
|
|
73
|
+
const text = conv.innerText || '';
|
|
74
|
+
const link = conv.querySelector('a[href]');
|
|
75
|
+
const href = link ? link.href : '';
|
|
76
|
+
const lines = text.split('\\n').filter(l => l.trim());
|
|
77
|
+
const user = lines[0] || 'Unknown';
|
|
78
|
+
return { idx, text, href, user };
|
|
79
|
+
});
|
|
80
|
+
return { ok: true, count: convs.length, items };
|
|
81
|
+
} catch(e) {
|
|
82
|
+
return { ok: false, error: String(e), count: 0, items: [] };
|
|
83
|
+
}
|
|
84
|
+
})()`);
|
|
85
|
+
|
|
86
|
+
if (!convInfo?.ok || convInfo.count === 0) {
|
|
87
|
+
if (results.length === 0) {
|
|
88
|
+
results.push({ index: 1, status: 'info', user: 'System', message: 'No message requests found' });
|
|
89
|
+
}
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let foundInThisRound = false;
|
|
94
|
+
|
|
95
|
+
// Step 3: Find first unvisited conversation with keyword match in preview
|
|
96
|
+
for (const item of convInfo.items) {
|
|
97
|
+
if (acceptCount >= maxAccepts) break;
|
|
98
|
+
const convKey = item.href || `conv-${item.idx}`;
|
|
99
|
+
if (visited.has(convKey)) continue;
|
|
100
|
+
visited.add(convKey);
|
|
101
|
+
|
|
102
|
+
// Check if preview text contains any keyword
|
|
103
|
+
const previewMatch = keywords.some((k: string) => item.text.includes(k));
|
|
104
|
+
if (!previewMatch) continue;
|
|
105
|
+
|
|
106
|
+
// Step 4: Click this conversation to open it
|
|
107
|
+
const clickResult = await page.evaluate(`(async () => {
|
|
108
|
+
try {
|
|
109
|
+
const convs = Array.from(document.querySelectorAll('[data-testid="conversation"]'));
|
|
110
|
+
const conv = convs[${item.idx}];
|
|
111
|
+
if (!conv) return { ok: false, error: 'Conversation element not found' };
|
|
112
|
+
conv.click();
|
|
113
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
114
|
+
return { ok: true };
|
|
115
|
+
} catch(e) {
|
|
116
|
+
return { ok: false, error: String(e) };
|
|
117
|
+
}
|
|
118
|
+
})()`);
|
|
119
|
+
|
|
120
|
+
if (!clickResult?.ok) continue;
|
|
121
|
+
|
|
122
|
+
// Wait for conversation to load
|
|
123
|
+
await page.wait(2);
|
|
124
|
+
|
|
125
|
+
// Step 5: Read full chat content and find Accept button
|
|
126
|
+
const res = await page.evaluate(`(async () => {
|
|
127
|
+
try {
|
|
128
|
+
const keywords = ${JSON.stringify(keywords)};
|
|
129
|
+
|
|
130
|
+
// Get username from conversation header
|
|
131
|
+
const heading = document.querySelector('[data-testid="conversation-header"]') ||
|
|
132
|
+
document.querySelector('[data-testid="DM-conversation-header"]');
|
|
133
|
+
let username = 'Unknown';
|
|
134
|
+
if (heading) {
|
|
135
|
+
username = heading.innerText.trim().split('\\n')[0];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Read full chat area text
|
|
139
|
+
const chatArea = document.querySelector('[data-testid="DmScrollerContainer"]') ||
|
|
140
|
+
document.querySelector('[data-testid="DMConversationBody"]') ||
|
|
141
|
+
document.querySelector('main [data-testid="cellInnerDiv"]')?.closest('section') ||
|
|
142
|
+
document.querySelector('main');
|
|
143
|
+
const text = chatArea ? chatArea.innerText : '';
|
|
144
|
+
|
|
145
|
+
// Verify keyword match in full chat content
|
|
146
|
+
const matchedKw = keywords.filter(k => text.includes(k));
|
|
147
|
+
if (matchedKw.length === 0) {
|
|
148
|
+
return { status: 'skipped', user: username, message: 'No keyword match in full content' };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Find the Accept button
|
|
152
|
+
const allBtns = Array.from(document.querySelectorAll('[role="button"]'));
|
|
153
|
+
const acceptBtn = allBtns.find(btn => {
|
|
154
|
+
const t = btn.innerText.trim().toLowerCase();
|
|
155
|
+
return t === 'accept' || t === '接受';
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (!acceptBtn) {
|
|
159
|
+
return { status: 'no_button', user: username, message: 'Keyword matched but no Accept button (already accepted?)' };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Click Accept
|
|
163
|
+
acceptBtn.click();
|
|
164
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
165
|
+
|
|
166
|
+
// Check for confirmation dialog
|
|
167
|
+
const btnsAfter = Array.from(document.querySelectorAll('[role="button"]'));
|
|
168
|
+
const confirmBtn = btnsAfter.find(btn => {
|
|
169
|
+
const t = btn.innerText.trim().toLowerCase();
|
|
170
|
+
return (t === 'accept' || t === '接受') && btn !== acceptBtn;
|
|
171
|
+
});
|
|
172
|
+
if (confirmBtn) {
|
|
173
|
+
confirmBtn.click();
|
|
174
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { status: 'accepted', user: username, message: 'Accepted! Matched: ' + matchedKw.join(', ') };
|
|
178
|
+
} catch(e) {
|
|
179
|
+
return { status: 'error', user: 'system', message: String(e) };
|
|
180
|
+
}
|
|
181
|
+
})()`);
|
|
182
|
+
|
|
183
|
+
if (res?.status === 'accepted') {
|
|
184
|
+
acceptCount++;
|
|
185
|
+
foundInThisRound = true;
|
|
186
|
+
results.push({
|
|
187
|
+
index: acceptCount,
|
|
188
|
+
status: 'accepted',
|
|
189
|
+
user: res.user || 'Unknown',
|
|
190
|
+
message: res.message || 'Accepted',
|
|
191
|
+
});
|
|
192
|
+
// After accept, Twitter redirects to /messages — loop back to /messages/requests
|
|
193
|
+
await page.wait(2);
|
|
194
|
+
break; // break inner loop, outer loop will re-navigate to requests
|
|
195
|
+
} else if (res?.status === 'no_button') {
|
|
196
|
+
// Already accepted, skip
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// If no match found in this round, we've exhausted all visible requests
|
|
202
|
+
if (!foundInThisRound) {
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (results.length === 0) {
|
|
208
|
+
results.push({ index: 0, status: 'info', user: 'System', message: `No requests matched keywords "${keywords.join(', ')}"` });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return results;
|
|
212
|
+
}
|
|
213
|
+
});
|
|
@@ -15,65 +15,73 @@ cli({
|
|
|
15
15
|
func: async (page, kwargs) => {
|
|
16
16
|
let targetUser = kwargs.user;
|
|
17
17
|
|
|
18
|
-
// If no user is specified,
|
|
18
|
+
// If no user is specified, figure out the logged-in user's handle
|
|
19
19
|
if (!targetUser) {
|
|
20
20
|
await page.goto('https://x.com/home');
|
|
21
|
-
// wait for home page navigation
|
|
22
21
|
await page.wait(5);
|
|
23
|
-
|
|
22
|
+
|
|
24
23
|
const href = await page.evaluate(`() => {
|
|
25
24
|
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
|
26
25
|
return link ? link.getAttribute('href') : null;
|
|
27
26
|
}`);
|
|
28
|
-
|
|
27
|
+
|
|
29
28
|
if (!href) {
|
|
30
29
|
throw new Error('Could not find logged-in user profile link. Are you logged in?');
|
|
31
30
|
}
|
|
32
31
|
targetUser = href.replace('/', '');
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
// 1. Navigate to
|
|
34
|
+
// 1. Navigate to profile page
|
|
36
35
|
await page.goto(`https://x.com/${targetUser}`);
|
|
37
36
|
await page.wait(3);
|
|
38
37
|
|
|
39
|
-
// 2.
|
|
38
|
+
// 2. Install interceptor BEFORE SPA navigation.
|
|
39
|
+
// goto() resets JS context, but SPA click preserves it.
|
|
40
40
|
await page.installInterceptor('Followers');
|
|
41
|
-
|
|
42
|
-
// 3. Click the followers link
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
41
|
+
|
|
42
|
+
// 3. Click the followers link via SPA navigation (preserves interceptor).
|
|
43
|
+
// Twitter uses /verified_followers instead of /followers now.
|
|
44
|
+
const safeUser = JSON.stringify(targetUser);
|
|
45
|
+
const clicked = await page.evaluate(`() => {
|
|
46
|
+
const target = ${safeUser};
|
|
47
|
+
const selectors = [
|
|
48
|
+
'a[href="/' + target + '/verified_followers"]',
|
|
49
|
+
'a[href="/' + target + '/followers"]',
|
|
50
|
+
];
|
|
51
|
+
for (const sel of selectors) {
|
|
52
|
+
const link = document.querySelector(sel);
|
|
53
|
+
if (link) { link.click(); return true; }
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
47
56
|
}`);
|
|
48
|
-
|
|
57
|
+
if (!clicked) {
|
|
58
|
+
throw new Error('Could not find followers link on profile page. Twitter may have changed the layout.');
|
|
59
|
+
}
|
|
60
|
+
await page.wait(5);
|
|
49
61
|
|
|
50
|
-
// 4.
|
|
62
|
+
// 4. Scroll to trigger pagination API calls
|
|
51
63
|
await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
|
|
52
64
|
|
|
53
|
-
//
|
|
54
|
-
const
|
|
55
|
-
const requestList = Array.isArray(
|
|
56
|
-
|
|
65
|
+
// 5. Retrieve intercepted data
|
|
66
|
+
const requests = await page.getInterceptedRequests();
|
|
67
|
+
const requestList = Array.isArray(requests) ? requests : [];
|
|
68
|
+
|
|
57
69
|
if (requestList.length === 0) {
|
|
58
70
|
return [];
|
|
59
71
|
}
|
|
60
|
-
|
|
61
|
-
const requests = requestList.filter((r: any) => r?.url?.includes('Followers'));
|
|
62
|
-
if (!requests || requests.length === 0) {
|
|
63
|
-
return [];
|
|
64
|
-
}
|
|
65
72
|
|
|
66
73
|
let results: any[] = [];
|
|
67
|
-
for (const req of
|
|
74
|
+
for (const req of requestList) {
|
|
68
75
|
try {
|
|
69
|
-
|
|
76
|
+
// GraphQL response: { data: { user: { result: { timeline: ... } } } }
|
|
77
|
+
let instructions = req.data?.user?.result?.timeline?.timeline?.instructions;
|
|
70
78
|
if (!instructions) continue;
|
|
71
79
|
|
|
72
80
|
let addEntries = instructions.find((i: any) => i.type === 'TimelineAddEntries');
|
|
73
81
|
if (!addEntries) {
|
|
74
82
|
addEntries = instructions.find((i: any) => i.entries && Array.isArray(i.entries));
|
|
75
83
|
}
|
|
76
|
-
|
|
84
|
+
|
|
77
85
|
if (!addEntries) continue;
|
|
78
86
|
|
|
79
87
|
for (const entry of addEntries.entries) {
|
|
@@ -82,10 +90,9 @@ cli({
|
|
|
82
90
|
const item = entry.content?.itemContent?.user_results?.result;
|
|
83
91
|
if (!item || item.__typename !== 'User') continue;
|
|
84
92
|
|
|
85
|
-
// Twitter GraphQL sometimes nests `core` differently depending on the endpoint profile state
|
|
86
93
|
const core = item.core || {};
|
|
87
94
|
const legacy = item.legacy || {};
|
|
88
|
-
|
|
95
|
+
|
|
89
96
|
results.push({
|
|
90
97
|
screen_name: core.screen_name || legacy.screen_name || 'unknown',
|
|
91
98
|
name: core.name || legacy.name || 'unknown',
|
|
@@ -98,7 +105,7 @@ cli({
|
|
|
98
105
|
}
|
|
99
106
|
}
|
|
100
107
|
|
|
101
|
-
// Deduplicate by screen_name
|
|
108
|
+
// Deduplicate by screen_name
|
|
102
109
|
const unique = new Map();
|
|
103
110
|
results.forEach(r => unique.set(r.screen_name, r));
|
|
104
111
|
const deduplicatedResults = Array.from(unique.values());
|
|
@@ -15,45 +15,50 @@ cli({
|
|
|
15
15
|
func: async (page, kwargs) => {
|
|
16
16
|
let targetUser = kwargs.user;
|
|
17
17
|
|
|
18
|
-
// If no user is specified,
|
|
18
|
+
// If no user is specified, figure out the logged-in user's handle
|
|
19
19
|
if (!targetUser) {
|
|
20
20
|
await page.goto('https://x.com/home');
|
|
21
|
-
// wait for home page navigation
|
|
22
21
|
await page.wait(5);
|
|
23
|
-
|
|
22
|
+
|
|
24
23
|
const href = await page.evaluate(`() => {
|
|
25
24
|
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
|
26
25
|
return link ? link.getAttribute('href') : null;
|
|
27
26
|
}`);
|
|
28
|
-
|
|
27
|
+
|
|
29
28
|
if (!href) {
|
|
30
29
|
throw new Error('Could not find logged-in user profile link. Are you logged in?');
|
|
31
30
|
}
|
|
32
31
|
targetUser = href.replace('/', '');
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
// 1. Navigate to
|
|
34
|
+
// 1. Navigate to profile page
|
|
36
35
|
await page.goto(`https://x.com/${targetUser}`);
|
|
37
36
|
await page.wait(3);
|
|
38
37
|
|
|
39
|
-
// 2.
|
|
38
|
+
// 2. Install interceptor BEFORE SPA navigation.
|
|
39
|
+
// goto() resets JS context, but SPA click preserves it.
|
|
40
40
|
await page.installInterceptor('Following');
|
|
41
|
-
|
|
42
|
-
// 3. Click the following link
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
|
|
42
|
+
// 3. Click the following link via SPA navigation (preserves interceptor)
|
|
43
|
+
const safeUser = JSON.stringify(targetUser);
|
|
44
|
+
const clicked = await page.evaluate(`() => {
|
|
45
|
+
const target = ${safeUser};
|
|
45
46
|
const link = document.querySelector('a[href="/' + target + '/following"]');
|
|
46
|
-
if (link) link.click();
|
|
47
|
+
if (link) { link.click(); return true; }
|
|
48
|
+
return false;
|
|
47
49
|
}`);
|
|
48
|
-
|
|
50
|
+
if (!clicked) {
|
|
51
|
+
throw new Error('Could not find following link on profile page. Twitter may have changed the layout.');
|
|
52
|
+
}
|
|
53
|
+
await page.wait(5);
|
|
49
54
|
|
|
50
|
-
// 4.
|
|
55
|
+
// 4. Scroll to trigger pagination API calls
|
|
51
56
|
await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
|
|
52
57
|
|
|
53
|
-
//
|
|
58
|
+
// 5. Retrieve intercepted data
|
|
54
59
|
const requests = await page.getInterceptedRequests();
|
|
55
60
|
const requestList = Array.isArray(requests) ? requests : [];
|
|
56
|
-
|
|
61
|
+
|
|
57
62
|
if (requestList.length === 0) {
|
|
58
63
|
return [];
|
|
59
64
|
}
|
|
@@ -61,14 +66,15 @@ cli({
|
|
|
61
66
|
let results: any[] = [];
|
|
62
67
|
for (const req of requestList) {
|
|
63
68
|
try {
|
|
64
|
-
|
|
69
|
+
// GraphQL response: { data: { user: { result: { timeline: ... } } } }
|
|
70
|
+
let instructions = req.data?.user?.result?.timeline?.timeline?.instructions;
|
|
65
71
|
if (!instructions) continue;
|
|
66
72
|
|
|
67
73
|
let addEntries = instructions.find((i: any) => i.type === 'TimelineAddEntries');
|
|
68
74
|
if (!addEntries) {
|
|
69
75
|
addEntries = instructions.find((i: any) => i.entries && Array.isArray(i.entries));
|
|
70
76
|
}
|
|
71
|
-
|
|
77
|
+
|
|
72
78
|
if (!addEntries) continue;
|
|
73
79
|
|
|
74
80
|
for (const entry of addEntries.entries) {
|
|
@@ -77,10 +83,9 @@ cli({
|
|
|
77
83
|
const item = entry.content?.itemContent?.user_results?.result;
|
|
78
84
|
if (!item || item.__typename !== 'User') continue;
|
|
79
85
|
|
|
80
|
-
// Twitter GraphQL sometimes nests `core` differently depending on the endpoint profile state
|
|
81
86
|
const core = item.core || {};
|
|
82
87
|
const legacy = item.legacy || {};
|
|
83
|
-
|
|
88
|
+
|
|
84
89
|
results.push({
|
|
85
90
|
screen_name: core.screen_name || legacy.screen_name || 'unknown',
|
|
86
91
|
name: core.name || legacy.name || 'unknown',
|
|
@@ -93,7 +98,7 @@ cli({
|
|
|
93
98
|
}
|
|
94
99
|
}
|
|
95
100
|
|
|
96
|
-
// Deduplicate by screen_name
|
|
101
|
+
// Deduplicate by screen_name
|
|
97
102
|
const unique = new Map();
|
|
98
103
|
results.forEach(r => unique.set(r.screen_name, r));
|
|
99
104
|
const deduplicatedResults = Array.from(unique.values());
|
|
@@ -12,20 +12,30 @@ cli({
|
|
|
12
12
|
],
|
|
13
13
|
columns: ['id', 'action', 'author', 'text', 'url'],
|
|
14
14
|
func: async (page, kwargs) => {
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
await page.
|
|
18
|
-
|
|
15
|
+
// 1. Navigate to home first (we need a loaded Twitter page for SPA navigation)
|
|
16
|
+
await page.goto('https://x.com/home');
|
|
17
|
+
await page.wait(3);
|
|
18
|
+
|
|
19
|
+
// 2. Install interceptor BEFORE SPA navigation
|
|
19
20
|
await page.installInterceptor('NotificationsTimeline');
|
|
20
21
|
|
|
21
|
-
//
|
|
22
|
-
await page.
|
|
22
|
+
// 3. SPA navigate to notifications via history API
|
|
23
|
+
await page.evaluate(`() => {
|
|
24
|
+
window.history.pushState({}, '', '/notifications');
|
|
25
|
+
window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
|
|
26
|
+
}`);
|
|
23
27
|
await page.wait(5);
|
|
24
28
|
|
|
25
|
-
//
|
|
29
|
+
// Verify SPA navigation succeeded
|
|
30
|
+
const currentUrl = await page.evaluate('() => window.location.pathname');
|
|
31
|
+
if (currentUrl !== '/notifications') {
|
|
32
|
+
throw new Error('SPA navigation to notifications failed. Twitter may have changed its routing.');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 4. Scroll to trigger pagination
|
|
26
36
|
await page.autoScroll({ times: 2, delayMs: 2000 });
|
|
27
37
|
|
|
28
|
-
//
|
|
38
|
+
// 5. Retrieve data
|
|
29
39
|
const requests = await page.getInterceptedRequests();
|
|
30
40
|
if (!requests || requests.length === 0) return [];
|
|
31
41
|
|
|
@@ -33,22 +43,20 @@ cli({
|
|
|
33
43
|
const seen = new Set<string>();
|
|
34
44
|
for (const req of requests) {
|
|
35
45
|
try {
|
|
46
|
+
// GraphQL response: { data: { viewer: ... } } (one level of .data)
|
|
36
47
|
let instructions: any[] = [];
|
|
37
|
-
if (req.data?.
|
|
38
|
-
instructions = req.data.
|
|
39
|
-
} else if (req.data?.
|
|
40
|
-
instructions = req.data.
|
|
41
|
-
} else if (req.data?.
|
|
42
|
-
instructions = req.data.
|
|
48
|
+
if (req.data?.viewer?.timeline_response?.timeline?.instructions) {
|
|
49
|
+
instructions = req.data.viewer.timeline_response.timeline.instructions;
|
|
50
|
+
} else if (req.data?.viewer_v2?.user_results?.result?.notification_timeline?.timeline?.instructions) {
|
|
51
|
+
instructions = req.data.viewer_v2.user_results.result.notification_timeline.timeline.instructions;
|
|
52
|
+
} else if (req.data?.timeline?.instructions) {
|
|
53
|
+
instructions = req.data.timeline.instructions;
|
|
43
54
|
}
|
|
44
55
|
|
|
45
56
|
let addEntries = instructions.find((i: any) => i.type === 'TimelineAddEntries');
|
|
46
|
-
|
|
47
|
-
// Sometimes it's the first object without a 'type' field but has 'entries'
|
|
48
57
|
if (!addEntries) {
|
|
49
58
|
addEntries = instructions.find((i: any) => i.entries && Array.isArray(i.entries));
|
|
50
59
|
}
|
|
51
|
-
|
|
52
60
|
if (!addEntries) continue;
|
|
53
61
|
|
|
54
62
|
for (const entry of addEntries.entries) {
|
|
@@ -66,24 +74,22 @@ cli({
|
|
|
66
74
|
|
|
67
75
|
function processNotificationItem(itemContent: any, entryId: string) {
|
|
68
76
|
if (!itemContent) return;
|
|
69
|
-
|
|
70
|
-
// Twitter wraps standard notifications
|
|
77
|
+
|
|
71
78
|
let item = itemContent?.notification_results?.result || itemContent?.tweet_results?.result || itemContent;
|
|
72
79
|
|
|
73
80
|
let actionText = 'Notification';
|
|
74
81
|
let author = 'unknown';
|
|
75
82
|
let text = '';
|
|
76
83
|
let urlStr = '';
|
|
77
|
-
|
|
84
|
+
|
|
78
85
|
if (item.__typename === 'TimelineNotification') {
|
|
79
|
-
// Greet likes, retweet, mentions
|
|
80
86
|
text = item.rich_message?.text || item.message?.text || '';
|
|
81
87
|
const fromUser = item.template?.from_users?.[0]?.user_results?.result;
|
|
82
|
-
|
|
88
|
+
// Twitter moved screen_name from legacy to core
|
|
89
|
+
author = fromUser?.core?.screen_name || fromUser?.legacy?.screen_name || 'unknown';
|
|
83
90
|
urlStr = item.notification_url?.url || '';
|
|
84
91
|
actionText = item.notification_icon || 'Activity';
|
|
85
|
-
|
|
86
|
-
// If there's an attached tweet
|
|
92
|
+
|
|
87
93
|
const targetTweet = item.template?.target_objects?.[0]?.tweet_results?.result;
|
|
88
94
|
if (targetTweet) {
|
|
89
95
|
const targetText = targetTweet.note_tweet?.note_tweet_results?.result?.text || targetTweet.legacy?.full_text || '';
|
|
@@ -93,14 +99,15 @@ cli({
|
|
|
93
99
|
}
|
|
94
100
|
}
|
|
95
101
|
} else if (item.__typename === 'TweetNotification') {
|
|
96
|
-
// Direct mention/reply
|
|
97
102
|
const tweet = item.tweet_result?.result;
|
|
98
|
-
|
|
103
|
+
const tweetUser = tweet?.core?.user_results?.result;
|
|
104
|
+
author = tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || 'unknown';
|
|
99
105
|
text = tweet?.note_tweet?.note_tweet_results?.result?.text || tweet?.legacy?.full_text || item.message?.text || '';
|
|
100
106
|
actionText = 'Mention/Reply';
|
|
101
107
|
urlStr = `https://x.com/i/status/${tweet?.rest_id}`;
|
|
102
108
|
} else if (item.__typename === 'Tweet') {
|
|
103
|
-
|
|
109
|
+
const tweetUser = item.core?.user_results?.result;
|
|
110
|
+
author = tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || 'unknown';
|
|
104
111
|
text = item.note_tweet?.note_tweet_results?.result?.text || item.legacy?.full_text || '';
|
|
105
112
|
actionText = 'Mention';
|
|
106
113
|
urlStr = `https://x.com/i/status/${item.rest_id}`;
|