@jackwener/opencli 0.9.8 → 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.
Files changed (165) hide show
  1. package/CDP.md +1 -1
  2. package/CDP.zh-CN.md +1 -1
  3. package/CLI-ELECTRON.md +2 -2
  4. package/CLI-EXPLORER.md +4 -4
  5. package/README.md +35 -58
  6. package/README.zh-CN.md +36 -60
  7. package/SKILL.md +10 -8
  8. package/TESTING.md +7 -7
  9. package/dist/browser/daemon-client.d.ts +37 -0
  10. package/dist/browser/daemon-client.js +82 -0
  11. package/dist/browser/discover.d.ts +11 -34
  12. package/dist/browser/discover.js +15 -205
  13. package/dist/browser/errors.d.ts +6 -20
  14. package/dist/browser/errors.js +24 -63
  15. package/dist/browser/index.d.ts +2 -12
  16. package/dist/browser/index.js +2 -12
  17. package/dist/browser/mcp.d.ts +9 -21
  18. package/dist/browser/mcp.js +70 -285
  19. package/dist/browser/page.d.ts +36 -7
  20. package/dist/browser/page.js +212 -81
  21. package/dist/browser.test.js +10 -231
  22. package/dist/cli-manifest.json +561 -14
  23. package/dist/clis/apple-podcasts/episodes.d.ts +1 -0
  24. package/dist/clis/apple-podcasts/episodes.js +28 -0
  25. package/dist/clis/apple-podcasts/search.d.ts +1 -0
  26. package/dist/clis/apple-podcasts/search.js +29 -0
  27. package/dist/clis/apple-podcasts/top.d.ts +1 -0
  28. package/dist/clis/apple-podcasts/top.js +34 -0
  29. package/dist/clis/apple-podcasts/utils.d.ts +11 -0
  30. package/dist/clis/apple-podcasts/utils.js +30 -0
  31. package/dist/clis/apple-podcasts/utils.test.d.ts +1 -0
  32. package/dist/clis/apple-podcasts/utils.test.js +57 -0
  33. package/dist/clis/chatwise/history.js +18 -1
  34. package/dist/clis/discord-app/channels.js +33 -21
  35. package/dist/clis/neteasemusic/like.d.ts +1 -0
  36. package/dist/clis/neteasemusic/like.js +25 -0
  37. package/dist/clis/neteasemusic/lyrics.d.ts +1 -0
  38. package/dist/clis/neteasemusic/lyrics.js +47 -0
  39. package/dist/clis/neteasemusic/next.d.ts +1 -0
  40. package/dist/clis/neteasemusic/next.js +26 -0
  41. package/dist/clis/neteasemusic/play.d.ts +1 -0
  42. package/dist/clis/neteasemusic/play.js +26 -0
  43. package/dist/clis/neteasemusic/playing.d.ts +1 -0
  44. package/dist/clis/neteasemusic/playing.js +59 -0
  45. package/dist/clis/neteasemusic/playlist.d.ts +1 -0
  46. package/dist/clis/neteasemusic/playlist.js +46 -0
  47. package/dist/clis/neteasemusic/prev.d.ts +1 -0
  48. package/dist/clis/neteasemusic/prev.js +25 -0
  49. package/dist/clis/neteasemusic/search.d.ts +1 -0
  50. package/dist/clis/neteasemusic/search.js +52 -0
  51. package/dist/clis/neteasemusic/status.d.ts +1 -0
  52. package/dist/clis/neteasemusic/status.js +16 -0
  53. package/dist/clis/neteasemusic/volume.d.ts +1 -0
  54. package/dist/clis/neteasemusic/volume.js +54 -0
  55. package/dist/clis/twitter/accept.d.ts +1 -0
  56. package/dist/clis/twitter/accept.js +202 -0
  57. package/dist/clis/twitter/followers.js +30 -22
  58. package/dist/clis/twitter/following.js +19 -14
  59. package/dist/clis/twitter/notifications.js +29 -22
  60. package/dist/clis/twitter/reply-dm.d.ts +1 -0
  61. package/dist/clis/twitter/reply-dm.js +181 -0
  62. package/dist/clis/twitter/search.js +50 -12
  63. package/dist/clis/weread/book.d.ts +1 -0
  64. package/dist/clis/weread/book.js +26 -0
  65. package/dist/clis/weread/highlights.d.ts +1 -0
  66. package/dist/clis/weread/highlights.js +23 -0
  67. package/dist/clis/weread/notebooks.d.ts +1 -0
  68. package/dist/clis/weread/notebooks.js +21 -0
  69. package/dist/clis/weread/notes.d.ts +1 -0
  70. package/dist/clis/weread/notes.js +29 -0
  71. package/dist/clis/weread/ranking.d.ts +1 -0
  72. package/dist/clis/weread/ranking.js +28 -0
  73. package/dist/clis/weread/search.d.ts +1 -0
  74. package/dist/clis/weread/search.js +25 -0
  75. package/dist/clis/weread/shelf.d.ts +1 -0
  76. package/dist/clis/weread/shelf.js +24 -0
  77. package/dist/clis/weread/utils.d.ts +20 -0
  78. package/dist/clis/weread/utils.js +72 -0
  79. package/dist/clis/weread/utils.test.d.ts +1 -0
  80. package/dist/clis/weread/utils.test.js +85 -0
  81. package/dist/daemon.d.ts +13 -0
  82. package/dist/daemon.js +187 -0
  83. package/dist/doctor.d.ts +10 -65
  84. package/dist/doctor.js +49 -602
  85. package/dist/doctor.test.js +30 -170
  86. package/dist/main.js +12 -41
  87. package/dist/pipeline/executor.test.js +1 -0
  88. package/dist/pipeline/steps/browser.js +2 -2
  89. package/dist/pipeline/steps/intercept.js +1 -2
  90. package/dist/runtime.d.ts +1 -4
  91. package/dist/runtime.js +1 -4
  92. package/dist/setup.d.ts +6 -0
  93. package/dist/setup.js +46 -160
  94. package/dist/types.d.ts +6 -0
  95. package/extension/dist/background.js +484 -0
  96. package/extension/icons/icon-128.png +0 -0
  97. package/extension/icons/icon-16.png +0 -0
  98. package/extension/icons/icon-32.png +0 -0
  99. package/extension/icons/icon-48.png +0 -0
  100. package/extension/manifest.json +31 -0
  101. package/extension/package.json +16 -0
  102. package/extension/src/background.ts +370 -0
  103. package/extension/src/cdp.ts +125 -0
  104. package/extension/src/protocol.ts +57 -0
  105. package/extension/store-assets/screenshot-1280x800.png +0 -0
  106. package/extension/tsconfig.json +15 -0
  107. package/extension/vite.config.ts +18 -0
  108. package/package.json +5 -5
  109. package/src/browser/daemon-client.ts +113 -0
  110. package/src/browser/discover.ts +18 -232
  111. package/src/browser/errors.ts +30 -100
  112. package/src/browser/index.ts +2 -13
  113. package/src/browser/mcp.ts +81 -282
  114. package/src/browser/page.ts +223 -83
  115. package/src/browser.test.ts +9 -239
  116. package/src/clis/apple-podcasts/episodes.ts +28 -0
  117. package/src/clis/apple-podcasts/search.ts +29 -0
  118. package/src/clis/apple-podcasts/top.ts +34 -0
  119. package/src/clis/apple-podcasts/utils.test.ts +72 -0
  120. package/src/clis/apple-podcasts/utils.ts +37 -0
  121. package/src/clis/chatgpt/README.md +1 -1
  122. package/src/clis/chatgpt/README.zh-CN.md +1 -1
  123. package/src/clis/chatwise/history.ts +15 -1
  124. package/src/clis/discord-app/channels.ts +33 -21
  125. package/src/clis/neteasemusic/README.md +31 -0
  126. package/src/clis/neteasemusic/README.zh-CN.md +31 -0
  127. package/src/clis/neteasemusic/like.ts +28 -0
  128. package/src/clis/neteasemusic/lyrics.ts +53 -0
  129. package/src/clis/neteasemusic/next.ts +30 -0
  130. package/src/clis/neteasemusic/play.ts +30 -0
  131. package/src/clis/neteasemusic/playing.ts +62 -0
  132. package/src/clis/neteasemusic/playlist.ts +51 -0
  133. package/src/clis/neteasemusic/prev.ts +29 -0
  134. package/src/clis/neteasemusic/search.ts +58 -0
  135. package/src/clis/neteasemusic/status.ts +18 -0
  136. package/src/clis/neteasemusic/volume.ts +61 -0
  137. package/src/clis/twitter/accept.ts +213 -0
  138. package/src/clis/twitter/followers.ts +36 -29
  139. package/src/clis/twitter/following.ts +25 -20
  140. package/src/clis/twitter/notifications.ts +34 -27
  141. package/src/clis/twitter/reply-dm.ts +193 -0
  142. package/src/clis/twitter/search.ts +53 -13
  143. package/src/clis/weread/book.ts +28 -0
  144. package/src/clis/weread/highlights.ts +25 -0
  145. package/src/clis/weread/notebooks.ts +23 -0
  146. package/src/clis/weread/notes.ts +31 -0
  147. package/src/clis/weread/ranking.ts +29 -0
  148. package/src/clis/weread/search.ts +26 -0
  149. package/src/clis/weread/shelf.ts +26 -0
  150. package/src/clis/weread/utils.test.ts +104 -0
  151. package/src/clis/weread/utils.ts +74 -0
  152. package/src/daemon.ts +217 -0
  153. package/src/doctor.test.ts +32 -193
  154. package/src/doctor.ts +58 -669
  155. package/src/main.ts +11 -34
  156. package/src/pipeline/executor.test.ts +1 -0
  157. package/src/pipeline/steps/browser.ts +2 -2
  158. package/src/pipeline/steps/intercept.ts +1 -2
  159. package/src/runtime.ts +2 -6
  160. package/src/setup.ts +47 -183
  161. package/src/types.ts +1 -0
  162. package/tests/e2e/public-commands.test.ts +68 -1
  163. package/dist/clis/grok/debug.d.ts +0 -1
  164. package/dist/clis/grok/debug.js +0 -45
  165. package/src/clis/grok/debug.ts +0 -49
@@ -0,0 +1,58 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import type { IPage } from '../../types.js';
3
+
4
+ export const searchCommand = cli({
5
+ site: 'neteasemusic',
6
+ name: 'search',
7
+ description: 'Search for songs, artists, albums, or playlists',
8
+ domain: 'localhost',
9
+ strategy: Strategy.UI,
10
+ browser: true,
11
+ args: [{ name: 'query', required: true, positional: true, help: 'Search query' }],
12
+ columns: ['Index', 'Title', 'Artist'],
13
+ func: async (page: IPage, kwargs: any) => {
14
+ const query = kwargs.query as string;
15
+
16
+ // Focus and fill the search box
17
+ await page.evaluate(`
18
+ (function(q) {
19
+ const input = document.querySelector('.m-search input, #srch, [class*="search"] input, input[type="search"]');
20
+ if (!input) throw new Error('Search input not found');
21
+ input.focus();
22
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
23
+ setter.call(input, q);
24
+ input.dispatchEvent(new Event('input', { bubbles: true }));
25
+ })(${JSON.stringify(query)})
26
+ `);
27
+
28
+ await page.pressKey('Enter');
29
+ await page.wait(2);
30
+
31
+ // Scrape results
32
+ const results = await page.evaluate(`
33
+ (function() {
34
+ const items = [];
35
+ // Song list items in search results
36
+ const rows = document.querySelectorAll('.srchsongst li, .m-table tbody tr, [class*="songlist"] [class*="item"], table tbody tr');
37
+
38
+ rows.forEach((row, i) => {
39
+ if (i >= 20) return;
40
+ const nameEl = row.querySelector('.sn, .name a, [class*="songName"], td:nth-child(2) a, b[title]');
41
+ const artistEl = row.querySelector('.ar, .artist, [class*="artist"], td:nth-child(4) a, td:nth-child(3) a');
42
+
43
+ const title = nameEl ? (nameEl.getAttribute('title') || nameEl.textContent || '').trim() : '';
44
+ const artist = artistEl ? (artistEl.getAttribute('title') || artistEl.textContent || '').trim() : '';
45
+
46
+ if (title) items.push({ Index: i + 1, Title: title, Artist: artist });
47
+ });
48
+
49
+ return items;
50
+ })()
51
+ `);
52
+
53
+ if (results.length === 0) {
54
+ return [{ Index: 0, Title: `No results for "${query}"`, Artist: '—' }];
55
+ }
56
+ return results;
57
+ },
58
+ });
@@ -0,0 +1,18 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import type { IPage } from '../../types.js';
3
+
4
+ export const statusCommand = cli({
5
+ site: 'neteasemusic',
6
+ name: 'status',
7
+ description: 'Check CDP connection to NeteaseMusic Desktop',
8
+ domain: 'localhost',
9
+ strategy: Strategy.UI,
10
+ browser: true,
11
+ args: [],
12
+ columns: ['Status', 'Url', 'Title'],
13
+ func: async (page: IPage) => {
14
+ const url = await page.evaluate('window.location.href');
15
+ const title = await page.evaluate('document.title');
16
+ return [{ Status: 'Connected', Url: url, Title: title }];
17
+ },
18
+ });
@@ -0,0 +1,61 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import type { IPage } from '../../types.js';
3
+
4
+ export const volumeCommand = cli({
5
+ site: 'neteasemusic',
6
+ name: 'volume',
7
+ description: 'Get or set the volume level (0-100)',
8
+ domain: 'localhost',
9
+ strategy: Strategy.UI,
10
+ browser: true,
11
+ args: [
12
+ { name: 'level', required: false, positional: true, help: 'Volume level 0-100 (omit to read current)' },
13
+ ],
14
+ columns: ['Status', 'Volume'],
15
+ func: async (page: IPage, kwargs: any) => {
16
+ const level = kwargs.level as string | undefined;
17
+
18
+ if (!level) {
19
+ // Read current volume
20
+ const vol = await page.evaluate(`
21
+ (function() {
22
+ const bar = document.querySelector('.m-playbar .vol .barbg .rng, [class*="volume"] [class*="progress"], [class*="volume"] [class*="played"]');
23
+ if (bar) {
24
+ const style = bar.getAttribute('style') || '';
25
+ const match = style.match(/width:\\s*(\\d+\\.?\\d*)%/);
26
+ if (match) return match[1];
27
+ }
28
+
29
+ const vol = document.querySelector('.m-playbar .j-vol, [class*="volume-value"]');
30
+ if (vol) return vol.textContent.trim();
31
+
32
+ return 'Unknown';
33
+ })()
34
+ `);
35
+
36
+ return [{ Status: 'Current', Volume: vol + '%' }];
37
+ }
38
+
39
+ // Set volume by clicking on the volume bar at the right position
40
+ const targetVol = Math.max(0, Math.min(100, parseInt(level, 10)));
41
+
42
+ await page.evaluate(`
43
+ (function(target) {
44
+ const bar = document.querySelector('.m-playbar .vol .barbg, [class*="volume-bar"], [class*="volume"] [class*="track"]');
45
+ if (!bar) return;
46
+
47
+ const rect = bar.getBoundingClientRect();
48
+ const x = rect.left + (rect.width * target / 100);
49
+ const y = rect.top + rect.height / 2;
50
+
51
+ bar.dispatchEvent(new MouseEvent('click', {
52
+ clientX: x,
53
+ clientY: y,
54
+ bubbles: true,
55
+ }));
56
+ })(${targetVol})
57
+ `);
58
+
59
+ return [{ Status: 'Set', Volume: targetVol + '%' }];
60
+ },
61
+ });
@@ -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());