@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.
Files changed (171) hide show
  1. package/.github/workflows/build-extension.yml +62 -0
  2. package/.github/workflows/ci.yml +6 -6
  3. package/.github/workflows/e2e-headed.yml +2 -2
  4. package/.github/workflows/pkg-pr-new.yml +2 -2
  5. package/.github/workflows/release.yml +2 -5
  6. package/.github/workflows/security.yml +2 -2
  7. package/CDP.md +1 -1
  8. package/CDP.zh-CN.md +1 -1
  9. package/README.md +35 -8
  10. package/README.zh-CN.md +35 -8
  11. package/SKILL.md +3 -5
  12. package/dist/browser/cdp.d.ts +27 -0
  13. package/dist/browser/cdp.js +295 -0
  14. package/dist/browser/daemon-client.d.ts +1 -1
  15. package/dist/browser/index.d.ts +4 -2
  16. package/dist/browser/index.js +5 -5
  17. package/dist/browser/mcp.d.ts +5 -8
  18. package/dist/browser/mcp.js +9 -10
  19. package/dist/browser/page.d.ts +8 -1
  20. package/dist/browser/page.js +25 -40
  21. package/dist/browser/utils.d.ts +10 -0
  22. package/dist/browser/utils.js +27 -0
  23. package/dist/browser.test.js +48 -7
  24. package/dist/chaoxing.d.ts +58 -0
  25. package/dist/chaoxing.js +225 -0
  26. package/dist/chaoxing.test.d.ts +1 -0
  27. package/dist/chaoxing.test.js +38 -0
  28. package/dist/cli-manifest.json +597 -14
  29. package/dist/cli.d.ts +1 -0
  30. package/dist/cli.js +197 -0
  31. package/dist/clis/apple-podcasts/episodes.d.ts +1 -0
  32. package/dist/clis/apple-podcasts/episodes.js +28 -0
  33. package/dist/clis/apple-podcasts/search.d.ts +1 -0
  34. package/dist/clis/apple-podcasts/search.js +29 -0
  35. package/dist/clis/apple-podcasts/top.d.ts +1 -0
  36. package/dist/clis/apple-podcasts/top.js +34 -0
  37. package/dist/clis/apple-podcasts/utils.d.ts +11 -0
  38. package/dist/clis/apple-podcasts/utils.js +30 -0
  39. package/dist/clis/apple-podcasts/utils.test.d.ts +1 -0
  40. package/dist/clis/apple-podcasts/utils.test.js +57 -0
  41. package/dist/clis/boss/chatlist.d.ts +1 -0
  42. package/dist/clis/boss/chatlist.js +50 -0
  43. package/dist/clis/boss/chatmsg.d.ts +1 -0
  44. package/dist/clis/boss/chatmsg.js +73 -0
  45. package/dist/clis/boss/send.d.ts +1 -0
  46. package/dist/clis/boss/send.js +176 -0
  47. package/dist/clis/chaoxing/assignments.d.ts +1 -0
  48. package/dist/clis/chaoxing/assignments.js +74 -0
  49. package/dist/clis/chaoxing/exams.d.ts +1 -0
  50. package/dist/clis/chaoxing/exams.js +74 -0
  51. package/dist/clis/chatgpt/ask.js +15 -14
  52. package/dist/clis/chatgpt/ax.d.ts +1 -0
  53. package/dist/clis/chatgpt/ax.js +78 -0
  54. package/dist/clis/chatgpt/read.js +5 -6
  55. package/dist/clis/chatwise/history.js +18 -1
  56. package/dist/clis/discord-app/channels.js +33 -21
  57. package/dist/clis/twitter/accept.d.ts +1 -0
  58. package/dist/clis/twitter/accept.js +202 -0
  59. package/dist/clis/twitter/followers.js +30 -22
  60. package/dist/clis/twitter/following.js +19 -14
  61. package/dist/clis/twitter/notifications.js +29 -22
  62. package/dist/clis/twitter/post.js +9 -2
  63. package/dist/clis/twitter/reply-dm.d.ts +1 -0
  64. package/dist/clis/twitter/reply-dm.js +181 -0
  65. package/dist/clis/twitter/search.js +30 -11
  66. package/dist/clis/weread/book.d.ts +1 -0
  67. package/dist/clis/weread/book.js +26 -0
  68. package/dist/clis/weread/highlights.d.ts +1 -0
  69. package/dist/clis/weread/highlights.js +23 -0
  70. package/dist/clis/weread/notebooks.d.ts +1 -0
  71. package/dist/clis/weread/notebooks.js +21 -0
  72. package/dist/clis/weread/notes.d.ts +1 -0
  73. package/dist/clis/weread/notes.js +29 -0
  74. package/dist/clis/weread/ranking.d.ts +1 -0
  75. package/dist/clis/weread/ranking.js +28 -0
  76. package/dist/clis/weread/search.d.ts +1 -0
  77. package/dist/clis/weread/search.js +25 -0
  78. package/dist/clis/weread/shelf.d.ts +1 -0
  79. package/dist/clis/weread/shelf.js +24 -0
  80. package/dist/clis/weread/utils.d.ts +20 -0
  81. package/dist/clis/weread/utils.js +72 -0
  82. package/dist/clis/weread/utils.test.d.ts +1 -0
  83. package/dist/clis/weread/utils.test.js +85 -0
  84. package/dist/clis/xiaohongshu/download.d.ts +1 -1
  85. package/dist/clis/xiaohongshu/download.js +1 -1
  86. package/dist/daemon.js +2 -2
  87. package/dist/doctor.d.ts +0 -21
  88. package/dist/doctor.js +2 -24
  89. package/dist/engine.js +24 -13
  90. package/dist/explore.js +46 -101
  91. package/dist/main.js +4 -203
  92. package/dist/output.d.ts +1 -1
  93. package/dist/registry.d.ts +3 -3
  94. package/dist/runtime.d.ts +1 -4
  95. package/dist/runtime.js +1 -4
  96. package/dist/scripts/framework.d.ts +4 -0
  97. package/dist/scripts/framework.js +21 -0
  98. package/dist/scripts/interact.d.ts +4 -0
  99. package/dist/scripts/interact.js +20 -0
  100. package/dist/scripts/store.d.ts +9 -0
  101. package/dist/scripts/store.js +44 -0
  102. package/dist/setup.js +2 -2
  103. package/dist/synthesize.js +1 -1
  104. package/extension/dist/background.js +392 -0
  105. package/extension/manifest.json +3 -3
  106. package/extension/package.json +1 -1
  107. package/extension/src/background.ts +101 -24
  108. package/extension/src/protocol.ts +1 -1
  109. package/package.json +1 -1
  110. package/src/browser/cdp.ts +295 -0
  111. package/src/browser/daemon-client.ts +1 -1
  112. package/src/browser/index.ts +5 -6
  113. package/src/browser/mcp.ts +14 -15
  114. package/src/browser/page.ts +25 -41
  115. package/src/browser/utils.ts +27 -0
  116. package/src/browser.test.ts +52 -6
  117. package/src/chaoxing.test.ts +45 -0
  118. package/src/chaoxing.ts +268 -0
  119. package/src/cli.ts +185 -0
  120. package/src/clis/antigravity/SKILL.md +5 -0
  121. package/src/clis/apple-podcasts/episodes.ts +28 -0
  122. package/src/clis/apple-podcasts/search.ts +29 -0
  123. package/src/clis/apple-podcasts/top.ts +34 -0
  124. package/src/clis/apple-podcasts/utils.test.ts +72 -0
  125. package/src/clis/apple-podcasts/utils.ts +37 -0
  126. package/src/clis/boss/chatlist.ts +50 -0
  127. package/src/clis/boss/chatmsg.ts +70 -0
  128. package/src/clis/boss/send.ts +193 -0
  129. package/src/clis/chaoxing/README.md +36 -0
  130. package/src/clis/chaoxing/README.zh-CN.md +35 -0
  131. package/src/clis/chaoxing/assignments.ts +88 -0
  132. package/src/clis/chaoxing/exams.ts +88 -0
  133. package/src/clis/chatgpt/ask.ts +14 -15
  134. package/src/clis/chatgpt/ax.ts +81 -0
  135. package/src/clis/chatgpt/read.ts +5 -7
  136. package/src/clis/chatwise/history.ts +15 -1
  137. package/src/clis/discord-app/channels.ts +33 -21
  138. package/src/clis/twitter/accept.ts +213 -0
  139. package/src/clis/twitter/followers.ts +36 -29
  140. package/src/clis/twitter/following.ts +25 -20
  141. package/src/clis/twitter/notifications.ts +34 -27
  142. package/src/clis/twitter/post.ts +9 -2
  143. package/src/clis/twitter/reply-dm.ts +193 -0
  144. package/src/clis/twitter/search.ts +34 -12
  145. package/src/clis/weread/book.ts +28 -0
  146. package/src/clis/weread/highlights.ts +25 -0
  147. package/src/clis/weread/notebooks.ts +23 -0
  148. package/src/clis/weread/notes.ts +31 -0
  149. package/src/clis/weread/ranking.ts +29 -0
  150. package/src/clis/weread/search.ts +26 -0
  151. package/src/clis/weread/shelf.ts +26 -0
  152. package/src/clis/weread/utils.test.ts +104 -0
  153. package/src/clis/weread/utils.ts +74 -0
  154. package/src/clis/xiaohongshu/download.ts +1 -1
  155. package/src/daemon.ts +2 -2
  156. package/src/doctor.ts +2 -19
  157. package/src/engine.ts +20 -13
  158. package/src/explore.ts +51 -100
  159. package/src/main.ts +4 -186
  160. package/src/output.ts +12 -12
  161. package/src/registry.ts +3 -3
  162. package/src/runtime.ts +2 -6
  163. package/src/scripts/framework.ts +20 -0
  164. package/src/scripts/interact.ts +22 -0
  165. package/src/scripts/store.ts +40 -0
  166. package/src/setup.ts +2 -2
  167. package/src/synthesize.ts +1 -1
  168. package/tests/e2e/public-commands.test.ts +68 -1
  169. package/dist/clis/grok/debug.d.ts +0 -1
  170. package/dist/clis/grok/debug.js +0 -45
  171. 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
- // Discord channel list items
18
- const items = document.querySelectorAll('[data-list-item-id*="channels___"], [class*="containerDefault_"]');
19
-
20
- items.forEach((item, i) => {
21
- const nameEl = item.querySelector('[class*="name_"], [class*="channelName"]');
22
- const name = nameEl ? nameEl.textContent.trim() : (item.textContent || '').trim().substring(0, 50);
23
-
24
- if (!name || name.length < 1) return;
25
-
26
- // Detect channel type from icon or aria-label
27
- const iconEl = item.querySelector('[class*="icon"]');
28
- let type = 'Text';
29
- if (iconEl) {
30
- const cls = iconEl.className || '';
31
- if (cls.includes('voice') || cls.includes('speaker')) type = 'Voice';
32
- else if (cls.includes('forum')) type = 'Forum';
33
- else if (cls.includes('announcement')) type = 'Announcement';
34
- }
35
-
36
- results.push({ Index: i + 1, Channel: name, Type: type });
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, we must figure out the logged-in user's handle
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 user profile page
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. Inject interceptor for the followers GraphQL API
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 inside the profile page
43
- await page.evaluate(`() => {
44
- const target = '${targetUser}';
45
- const link = document.querySelector('a[href="/' + target + '/followers"]');
46
- if (link) link.click();
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
- await page.wait(3);
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. Trigger API by scrolling
62
+ // 4. Scroll to trigger pagination API calls
51
63
  await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
52
64
 
53
- // 4. Retrieve data from opencli's registered interceptors
54
- const allRequests = await page.getInterceptedRequests();
55
- const requestList = Array.isArray(allRequests) ? allRequests : [];
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 requests) {
74
+ for (const req of requestList) {
68
75
  try {
69
- let instructions = req.data?.data?.user?.result?.timeline?.timeline?.instructions;
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 in case multiple scrolls caught the same
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, we must figure out the logged-in user's handle
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 user profile page
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. Inject interceptor for Following GraphQL API
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 inside the profile page
43
- await page.evaluate(`() => {
44
- const target = '${targetUser}';
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
- await page.wait(3);
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. Trigger API by scrolling
55
+ // 4. Scroll to trigger pagination API calls
51
56
  await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
52
57
 
53
- // 4. Retrieve data from opencli's registered interceptors
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
- let instructions = req.data?.data?.user?.result?.timeline?.timeline?.instructions;
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 in case multiple scrolls caught the same
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
- // Install the interceptor before loading the notifications page so we
16
- // capture the initial timeline request triggered during page load.
17
- await page.goto('https://x.com');
18
- await page.wait(2);
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
- // 1. Navigate to notifications
22
- await page.goto('https://x.com/notifications');
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
- // 3. Trigger API by scrolling (if we need to load more)
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
- // 4. Retrieve data
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?.data?.viewer?.timeline_response?.timeline?.instructions) {
38
- instructions = req.data.data.viewer.timeline_response.timeline.instructions;
39
- } else if (req.data?.data?.viewer_v2?.user_results?.result?.notification_timeline?.timeline?.instructions) {
40
- instructions = req.data.data.viewer_v2.user_results.result.notification_timeline.timeline.instructions;
41
- } else if (req.data?.data?.timeline?.instructions) {
42
- instructions = req.data.data.timeline.instructions;
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
- author = fromUser?.legacy?.screen_name || fromUser?.core?.screen_name || 'unknown';
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
- author = tweet?.core?.user_results?.result?.legacy?.screen_name || 'unknown';
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
- author = item.core?.user_results?.result?.legacy?.screen_name || 'unknown';
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}`;
@@ -26,8 +26,15 @@ cli({
26
26
  const box = document.querySelector('[data-testid="tweetTextarea_0"]');
27
27
  if (box) {
28
28
  box.focus();
29
- // insertText is the most reliable way to trigger React's onChange events
30
- document.execCommand('insertText', false, ${JSON.stringify(kwargs.text)});
29
+ // Simulate a paste event to properly handle newlines in Draft.js/React
30
+ const textToInsert = ${JSON.stringify(kwargs.text)};
31
+ const dataTransfer = new DataTransfer();
32
+ dataTransfer.setData('text/plain', textToInsert);
33
+ box.dispatchEvent(new ClipboardEvent('paste', {
34
+ clipboardData: dataTransfer,
35
+ bubbles: true,
36
+ cancelable: true
37
+ }));
31
38
  } else {
32
39
  return { ok: false, message: 'Could not find the tweet composer text area.' };
33
40
  }