@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,202 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ cli({
3
+ site: 'twitter',
4
+ name: 'accept',
5
+ description: 'Auto-accept DM requests containing specific keywords',
6
+ domain: 'x.com',
7
+ strategy: Strategy.UI,
8
+ browser: true,
9
+ timeoutSeconds: 600, // 10 min — batch operation iterating many conversations
10
+ args: [
11
+ { name: 'keyword', type: 'string', required: true, help: 'Keywords to match (comma-separated for OR, e.g. "群,微信")' },
12
+ { name: 'max', type: 'int', required: false, default: 20, help: 'Maximum number of requests to accept (default: 20)' },
13
+ ],
14
+ columns: ['index', 'status', 'user', 'message'],
15
+ func: async (page, kwargs) => {
16
+ if (!page)
17
+ throw new Error('Requires browser');
18
+ const keywords = kwargs.keyword.split(',').map((k) => k.trim()).filter(Boolean);
19
+ const maxAccepts = kwargs.max ?? 20;
20
+ const results = [];
21
+ let acceptCount = 0;
22
+ // Track already-visited conversations to avoid infinite loops
23
+ const visited = new Set();
24
+ for (let round = 0; round < maxAccepts + 50; round++) {
25
+ if (acceptCount >= maxAccepts)
26
+ break;
27
+ // Step 1: Navigate to DM requests page
28
+ await page.goto('https://x.com/messages/requests');
29
+ await page.wait(4);
30
+ // Step 2: Get conversations with scroll-to-load
31
+ const convInfo = await page.evaluate(`(async () => {
32
+ try {
33
+ // Wait for initial items
34
+ let attempts = 0;
35
+ while (attempts < 10) {
36
+ const convs = document.querySelectorAll('[data-testid="conversation"]');
37
+ if (convs.length > 0) break;
38
+ await new Promise(r => setTimeout(r, 1000));
39
+ attempts++;
40
+ }
41
+
42
+ // Scroll to load more
43
+ const seenCount = new Set();
44
+ let noNewCount = 0;
45
+ for (let scroll = 0; scroll < 20; scroll++) {
46
+ const convs = Array.from(document.querySelectorAll('[data-testid="conversation"]'));
47
+ const prevSize = seenCount.size;
48
+ convs.forEach((_, i) => seenCount.add(i));
49
+ if (convs.length >= ${maxAccepts + 10}) break;
50
+
51
+ // Scroll last item into view
52
+ if (convs.length > 0) {
53
+ convs[convs.length - 1].scrollIntoView({ behavior: 'instant', block: 'end' });
54
+ }
55
+ await new Promise(r => setTimeout(r, 1500));
56
+
57
+ if (seenCount.size <= prevSize) {
58
+ noNewCount++;
59
+ if (noNewCount >= 3) break;
60
+ } else {
61
+ noNewCount = 0;
62
+ }
63
+ }
64
+
65
+ const convs = Array.from(document.querySelectorAll('[data-testid="conversation"]'));
66
+ if (convs.length === 0) return { ok: false, count: 0, items: [] };
67
+
68
+ const items = convs.map((conv, idx) => {
69
+ const text = conv.innerText || '';
70
+ const link = conv.querySelector('a[href]');
71
+ const href = link ? link.href : '';
72
+ const lines = text.split('\\n').filter(l => l.trim());
73
+ const user = lines[0] || 'Unknown';
74
+ return { idx, text, href, user };
75
+ });
76
+ return { ok: true, count: convs.length, items };
77
+ } catch(e) {
78
+ return { ok: false, error: String(e), count: 0, items: [] };
79
+ }
80
+ })()`);
81
+ if (!convInfo?.ok || convInfo.count === 0) {
82
+ if (results.length === 0) {
83
+ results.push({ index: 1, status: 'info', user: 'System', message: 'No message requests found' });
84
+ }
85
+ break;
86
+ }
87
+ let foundInThisRound = false;
88
+ // Step 3: Find first unvisited conversation with keyword match in preview
89
+ for (const item of convInfo.items) {
90
+ if (acceptCount >= maxAccepts)
91
+ break;
92
+ const convKey = item.href || `conv-${item.idx}`;
93
+ if (visited.has(convKey))
94
+ continue;
95
+ visited.add(convKey);
96
+ // Check if preview text contains any keyword
97
+ const previewMatch = keywords.some((k) => item.text.includes(k));
98
+ if (!previewMatch)
99
+ continue;
100
+ // Step 4: Click this conversation to open it
101
+ const clickResult = await page.evaluate(`(async () => {
102
+ try {
103
+ const convs = Array.from(document.querySelectorAll('[data-testid="conversation"]'));
104
+ const conv = convs[${item.idx}];
105
+ if (!conv) return { ok: false, error: 'Conversation element not found' };
106
+ conv.click();
107
+ await new Promise(r => setTimeout(r, 2000));
108
+ return { ok: true };
109
+ } catch(e) {
110
+ return { ok: false, error: String(e) };
111
+ }
112
+ })()`);
113
+ if (!clickResult?.ok)
114
+ continue;
115
+ // Wait for conversation to load
116
+ await page.wait(2);
117
+ // Step 5: Read full chat content and find Accept button
118
+ const res = await page.evaluate(`(async () => {
119
+ try {
120
+ const keywords = ${JSON.stringify(keywords)};
121
+
122
+ // Get username from conversation header
123
+ const heading = document.querySelector('[data-testid="conversation-header"]') ||
124
+ document.querySelector('[data-testid="DM-conversation-header"]');
125
+ let username = 'Unknown';
126
+ if (heading) {
127
+ username = heading.innerText.trim().split('\\n')[0];
128
+ }
129
+
130
+ // Read full chat area text
131
+ const chatArea = document.querySelector('[data-testid="DmScrollerContainer"]') ||
132
+ document.querySelector('[data-testid="DMConversationBody"]') ||
133
+ document.querySelector('main [data-testid="cellInnerDiv"]')?.closest('section') ||
134
+ document.querySelector('main');
135
+ const text = chatArea ? chatArea.innerText : '';
136
+
137
+ // Verify keyword match in full chat content
138
+ const matchedKw = keywords.filter(k => text.includes(k));
139
+ if (matchedKw.length === 0) {
140
+ return { status: 'skipped', user: username, message: 'No keyword match in full content' };
141
+ }
142
+
143
+ // Find the Accept button
144
+ const allBtns = Array.from(document.querySelectorAll('[role="button"]'));
145
+ const acceptBtn = allBtns.find(btn => {
146
+ const t = btn.innerText.trim().toLowerCase();
147
+ return t === 'accept' || t === '接受';
148
+ });
149
+
150
+ if (!acceptBtn) {
151
+ return { status: 'no_button', user: username, message: 'Keyword matched but no Accept button (already accepted?)' };
152
+ }
153
+
154
+ // Click Accept
155
+ acceptBtn.click();
156
+ await new Promise(r => setTimeout(r, 2000));
157
+
158
+ // Check for confirmation dialog
159
+ const btnsAfter = Array.from(document.querySelectorAll('[role="button"]'));
160
+ const confirmBtn = btnsAfter.find(btn => {
161
+ const t = btn.innerText.trim().toLowerCase();
162
+ return (t === 'accept' || t === '接受') && btn !== acceptBtn;
163
+ });
164
+ if (confirmBtn) {
165
+ confirmBtn.click();
166
+ await new Promise(r => setTimeout(r, 1000));
167
+ }
168
+
169
+ return { status: 'accepted', user: username, message: 'Accepted! Matched: ' + matchedKw.join(', ') };
170
+ } catch(e) {
171
+ return { status: 'error', user: 'system', message: String(e) };
172
+ }
173
+ })()`);
174
+ if (res?.status === 'accepted') {
175
+ acceptCount++;
176
+ foundInThisRound = true;
177
+ results.push({
178
+ index: acceptCount,
179
+ status: 'accepted',
180
+ user: res.user || 'Unknown',
181
+ message: res.message || 'Accepted',
182
+ });
183
+ // After accept, Twitter redirects to /messages — loop back to /messages/requests
184
+ await page.wait(2);
185
+ break; // break inner loop, outer loop will re-navigate to requests
186
+ }
187
+ else if (res?.status === 'no_button') {
188
+ // Already accepted, skip
189
+ continue;
190
+ }
191
+ }
192
+ // If no match found in this round, we've exhausted all visible requests
193
+ if (!foundInThisRound) {
194
+ break;
195
+ }
196
+ }
197
+ if (results.length === 0) {
198
+ results.push({ index: 0, status: 'info', user: 'System', message: `No requests matched keywords "${keywords.join(', ')}"` });
199
+ }
200
+ return results;
201
+ }
202
+ });
@@ -13,10 +13,9 @@ cli({
13
13
  columns: ['screen_name', 'name', 'bio', 'followers'],
14
14
  func: async (page, kwargs) => {
15
15
  let targetUser = kwargs.user;
16
- // If no user is specified, we must figure out the logged-in user's handle
16
+ // If no user is specified, figure out the logged-in user's handle
17
17
  if (!targetUser) {
18
18
  await page.goto('https://x.com/home');
19
- // wait for home page navigation
20
19
  await page.wait(5);
21
20
  const href = await page.evaluate(`() => {
22
21
  const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
@@ -27,34 +26,44 @@ cli({
27
26
  }
28
27
  targetUser = href.replace('/', '');
29
28
  }
30
- // 1. Navigate to user profile page
29
+ // 1. Navigate to profile page
31
30
  await page.goto(`https://x.com/${targetUser}`);
32
31
  await page.wait(3);
33
- // 2. Inject interceptor for the followers GraphQL API
32
+ // 2. Install interceptor BEFORE SPA navigation.
33
+ // goto() resets JS context, but SPA click preserves it.
34
34
  await page.installInterceptor('Followers');
35
- // 3. Click the followers link inside the profile page
36
- await page.evaluate(`() => {
37
- const target = '${targetUser}';
38
- const link = document.querySelector('a[href="/' + target + '/followers"]');
39
- if (link) link.click();
35
+ // 3. Click the followers link via SPA navigation (preserves interceptor).
36
+ // Twitter uses /verified_followers instead of /followers now.
37
+ const safeUser = JSON.stringify(targetUser);
38
+ const clicked = await page.evaluate(`() => {
39
+ const target = ${safeUser};
40
+ const selectors = [
41
+ 'a[href="/' + target + '/verified_followers"]',
42
+ 'a[href="/' + target + '/followers"]',
43
+ ];
44
+ for (const sel of selectors) {
45
+ const link = document.querySelector(sel);
46
+ if (link) { link.click(); return true; }
47
+ }
48
+ return false;
40
49
  }`);
41
- await page.wait(3);
42
- // 4. Trigger API by scrolling
50
+ if (!clicked) {
51
+ throw new Error('Could not find followers link on profile page. Twitter may have changed the layout.');
52
+ }
53
+ await page.wait(5);
54
+ // 4. Scroll to trigger pagination API calls
43
55
  await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
44
- // 4. Retrieve data from opencli's registered interceptors
45
- const allRequests = await page.getInterceptedRequests();
46
- const requestList = Array.isArray(allRequests) ? allRequests : [];
56
+ // 5. Retrieve intercepted data
57
+ const requests = await page.getInterceptedRequests();
58
+ const requestList = Array.isArray(requests) ? requests : [];
47
59
  if (requestList.length === 0) {
48
60
  return [];
49
61
  }
50
- const requests = requestList.filter((r) => r?.url?.includes('Followers'));
51
- if (!requests || requests.length === 0) {
52
- return [];
53
- }
54
62
  let results = [];
55
- for (const req of requests) {
63
+ for (const req of requestList) {
56
64
  try {
57
- let instructions = req.data?.data?.user?.result?.timeline?.timeline?.instructions;
65
+ // GraphQL response: { data: { user: { result: { timeline: ... } } } }
66
+ let instructions = req.data?.user?.result?.timeline?.timeline?.instructions;
58
67
  if (!instructions)
59
68
  continue;
60
69
  let addEntries = instructions.find((i) => i.type === 'TimelineAddEntries');
@@ -69,7 +78,6 @@ cli({
69
78
  const item = entry.content?.itemContent?.user_results?.result;
70
79
  if (!item || item.__typename !== 'User')
71
80
  continue;
72
- // Twitter GraphQL sometimes nests `core` differently depending on the endpoint profile state
73
81
  const core = item.core || {};
74
82
  const legacy = item.legacy || {};
75
83
  results.push({
@@ -84,7 +92,7 @@ cli({
84
92
  // ignore parsing errors for individual payloads
85
93
  }
86
94
  }
87
- // Deduplicate by screen_name in case multiple scrolls caught the same
95
+ // Deduplicate by screen_name
88
96
  const unique = new Map();
89
97
  results.forEach(r => unique.set(r.screen_name, r));
90
98
  const deduplicatedResults = Array.from(unique.values());
@@ -13,10 +13,9 @@ cli({
13
13
  columns: ['screen_name', 'name', 'bio', 'followers'],
14
14
  func: async (page, kwargs) => {
15
15
  let targetUser = kwargs.user;
16
- // If no user is specified, we must figure out the logged-in user's handle
16
+ // If no user is specified, figure out the logged-in user's handle
17
17
  if (!targetUser) {
18
18
  await page.goto('https://x.com/home');
19
- // wait for home page navigation
20
19
  await page.wait(5);
21
20
  const href = await page.evaluate(`() => {
22
21
  const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
@@ -27,21 +26,27 @@ cli({
27
26
  }
28
27
  targetUser = href.replace('/', '');
29
28
  }
30
- // 1. Navigate to user profile page
29
+ // 1. Navigate to profile page
31
30
  await page.goto(`https://x.com/${targetUser}`);
32
31
  await page.wait(3);
33
- // 2. Inject interceptor for Following GraphQL API
32
+ // 2. Install interceptor BEFORE SPA navigation.
33
+ // goto() resets JS context, but SPA click preserves it.
34
34
  await page.installInterceptor('Following');
35
- // 3. Click the following link inside the profile page
36
- await page.evaluate(`() => {
37
- const target = '${targetUser}';
35
+ // 3. Click the following link via SPA navigation (preserves interceptor)
36
+ const safeUser = JSON.stringify(targetUser);
37
+ const clicked = await page.evaluate(`() => {
38
+ const target = ${safeUser};
38
39
  const link = document.querySelector('a[href="/' + target + '/following"]');
39
- if (link) link.click();
40
+ if (link) { link.click(); return true; }
41
+ return false;
40
42
  }`);
41
- await page.wait(3);
42
- // 4. Trigger API by scrolling
43
+ if (!clicked) {
44
+ throw new Error('Could not find following link on profile page. Twitter may have changed the layout.');
45
+ }
46
+ await page.wait(5);
47
+ // 4. Scroll to trigger pagination API calls
43
48
  await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
44
- // 4. Retrieve data from opencli's registered interceptors
49
+ // 5. Retrieve intercepted data
45
50
  const requests = await page.getInterceptedRequests();
46
51
  const requestList = Array.isArray(requests) ? requests : [];
47
52
  if (requestList.length === 0) {
@@ -50,7 +55,8 @@ cli({
50
55
  let results = [];
51
56
  for (const req of requestList) {
52
57
  try {
53
- let instructions = req.data?.data?.user?.result?.timeline?.timeline?.instructions;
58
+ // GraphQL response: { data: { user: { result: { timeline: ... } } } }
59
+ let instructions = req.data?.user?.result?.timeline?.timeline?.instructions;
54
60
  if (!instructions)
55
61
  continue;
56
62
  let addEntries = instructions.find((i) => i.type === 'TimelineAddEntries');
@@ -65,7 +71,6 @@ cli({
65
71
  const item = entry.content?.itemContent?.user_results?.result;
66
72
  if (!item || item.__typename !== 'User')
67
73
  continue;
68
- // Twitter GraphQL sometimes nests `core` differently depending on the endpoint profile state
69
74
  const core = item.core || {};
70
75
  const legacy = item.legacy || {};
71
76
  results.push({
@@ -80,7 +85,7 @@ cli({
80
85
  // ignore parsing errors for individual payloads
81
86
  }
82
87
  }
83
- // Deduplicate by screen_name in case multiple scrolls caught the same
88
+ // Deduplicate by screen_name
84
89
  const unique = new Map();
85
90
  results.forEach(r => unique.set(r.screen_name, r));
86
91
  const deduplicatedResults = Array.from(unique.values());
@@ -11,17 +11,25 @@ cli({
11
11
  ],
12
12
  columns: ['id', 'action', 'author', 'text', 'url'],
13
13
  func: async (page, kwargs) => {
14
- // Install the interceptor before loading the notifications page so we
15
- // capture the initial timeline request triggered during page load.
16
- await page.goto('https://x.com');
17
- await page.wait(2);
14
+ // 1. Navigate to home first (we need a loaded Twitter page for SPA navigation)
15
+ await page.goto('https://x.com/home');
16
+ await page.wait(3);
17
+ // 2. Install interceptor BEFORE SPA navigation
18
18
  await page.installInterceptor('NotificationsTimeline');
19
- // 1. Navigate to notifications
20
- await page.goto('https://x.com/notifications');
19
+ // 3. SPA navigate to notifications via history API
20
+ await page.evaluate(`() => {
21
+ window.history.pushState({}, '', '/notifications');
22
+ window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
23
+ }`);
21
24
  await page.wait(5);
22
- // 3. Trigger API by scrolling (if we need to load more)
25
+ // Verify SPA navigation succeeded
26
+ const currentUrl = await page.evaluate('() => window.location.pathname');
27
+ if (currentUrl !== '/notifications') {
28
+ throw new Error('SPA navigation to notifications failed. Twitter may have changed its routing.');
29
+ }
30
+ // 4. Scroll to trigger pagination
23
31
  await page.autoScroll({ times: 2, delayMs: 2000 });
24
- // 4. Retrieve data
32
+ // 5. Retrieve data
25
33
  const requests = await page.getInterceptedRequests();
26
34
  if (!requests || requests.length === 0)
27
35
  return [];
@@ -29,18 +37,18 @@ cli({
29
37
  const seen = new Set();
30
38
  for (const req of requests) {
31
39
  try {
40
+ // GraphQL response: { data: { viewer: ... } } (one level of .data)
32
41
  let instructions = [];
33
- if (req.data?.data?.viewer?.timeline_response?.timeline?.instructions) {
34
- instructions = req.data.data.viewer.timeline_response.timeline.instructions;
42
+ if (req.data?.viewer?.timeline_response?.timeline?.instructions) {
43
+ instructions = req.data.viewer.timeline_response.timeline.instructions;
35
44
  }
36
- else if (req.data?.data?.viewer_v2?.user_results?.result?.notification_timeline?.timeline?.instructions) {
37
- instructions = req.data.data.viewer_v2.user_results.result.notification_timeline.timeline.instructions;
45
+ else if (req.data?.viewer_v2?.user_results?.result?.notification_timeline?.timeline?.instructions) {
46
+ instructions = req.data.viewer_v2.user_results.result.notification_timeline.timeline.instructions;
38
47
  }
39
- else if (req.data?.data?.timeline?.instructions) {
40
- instructions = req.data.data.timeline.instructions;
48
+ else if (req.data?.timeline?.instructions) {
49
+ instructions = req.data.timeline.instructions;
41
50
  }
42
51
  let addEntries = instructions.find((i) => i.type === 'TimelineAddEntries');
43
- // Sometimes it's the first object without a 'type' field but has 'entries'
44
52
  if (!addEntries) {
45
53
  addEntries = instructions.find((i) => i.entries && Array.isArray(i.entries));
46
54
  }
@@ -60,20 +68,18 @@ cli({
60
68
  function processNotificationItem(itemContent, entryId) {
61
69
  if (!itemContent)
62
70
  return;
63
- // Twitter wraps standard notifications
64
71
  let item = itemContent?.notification_results?.result || itemContent?.tweet_results?.result || itemContent;
65
72
  let actionText = 'Notification';
66
73
  let author = 'unknown';
67
74
  let text = '';
68
75
  let urlStr = '';
69
76
  if (item.__typename === 'TimelineNotification') {
70
- // Greet likes, retweet, mentions
71
77
  text = item.rich_message?.text || item.message?.text || '';
72
78
  const fromUser = item.template?.from_users?.[0]?.user_results?.result;
73
- author = fromUser?.legacy?.screen_name || fromUser?.core?.screen_name || 'unknown';
79
+ // Twitter moved screen_name from legacy to core
80
+ author = fromUser?.core?.screen_name || fromUser?.legacy?.screen_name || 'unknown';
74
81
  urlStr = item.notification_url?.url || '';
75
82
  actionText = item.notification_icon || 'Activity';
76
- // If there's an attached tweet
77
83
  const targetTweet = item.template?.target_objects?.[0]?.tweet_results?.result;
78
84
  if (targetTweet) {
79
85
  const targetText = targetTweet.note_tweet?.note_tweet_results?.result?.text || targetTweet.legacy?.full_text || '';
@@ -84,15 +90,16 @@ cli({
84
90
  }
85
91
  }
86
92
  else if (item.__typename === 'TweetNotification') {
87
- // Direct mention/reply
88
93
  const tweet = item.tweet_result?.result;
89
- author = tweet?.core?.user_results?.result?.legacy?.screen_name || 'unknown';
94
+ const tweetUser = tweet?.core?.user_results?.result;
95
+ author = tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || 'unknown';
90
96
  text = tweet?.note_tweet?.note_tweet_results?.result?.text || tweet?.legacy?.full_text || item.message?.text || '';
91
97
  actionText = 'Mention/Reply';
92
98
  urlStr = `https://x.com/i/status/${tweet?.rest_id}`;
93
99
  }
94
100
  else if (item.__typename === 'Tweet') {
95
- author = item.core?.user_results?.result?.legacy?.screen_name || 'unknown';
101
+ const tweetUser = item.core?.user_results?.result;
102
+ author = tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || 'unknown';
96
103
  text = item.note_tweet?.note_tweet_results?.result?.text || item.legacy?.full_text || '';
97
104
  actionText = 'Mention';
98
105
  urlStr = `https://x.com/i/status/${item.rest_id}`;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,181 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ cli({
3
+ site: 'twitter',
4
+ name: 'reply-dm',
5
+ description: 'Send a message to recent DM conversations',
6
+ domain: 'x.com',
7
+ strategy: Strategy.UI,
8
+ browser: true,
9
+ timeoutSeconds: 600, // 10 min — batch operation
10
+ args: [
11
+ { name: 'text', type: 'string', required: true, help: 'Message text to send (e.g. "我的微信 wxkabi")' },
12
+ { name: 'max', type: 'int', required: false, default: 20, help: 'Maximum number of conversations to reply to (default: 20)' },
13
+ { name: 'skip-replied', type: 'boolean', required: false, default: true, help: 'Skip conversations where you already sent the same text (default: true)' },
14
+ ],
15
+ columns: ['index', 'status', 'user', 'message'],
16
+ func: async (page, kwargs) => {
17
+ if (!page)
18
+ throw new Error('Requires browser');
19
+ const messageText = kwargs.text;
20
+ const maxSend = kwargs.max ?? 20;
21
+ const skipReplied = kwargs['skip-replied'] !== false;
22
+ const results = [];
23
+ let sentCount = 0;
24
+ // Step 1: Navigate to messages to get conversation list
25
+ await page.goto('https://x.com/messages');
26
+ await page.wait(5);
27
+ // Step 2: Collect conversations with scroll-to-load
28
+ const needed = maxSend + 10; // extra buffer for skips
29
+ const convList = await page.evaluate(`(async () => {
30
+ try {
31
+ // Wait for initial items
32
+ let attempts = 0;
33
+ while (attempts < 10) {
34
+ const items = document.querySelectorAll('[data-testid^="dm-conversation-item-"], [data-testid="conversation"]');
35
+ if (items.length > 0) break;
36
+ await new Promise(r => setTimeout(r, 1000));
37
+ attempts++;
38
+ }
39
+
40
+ // Scroll to load more conversations
41
+ const needed = ${needed};
42
+ const seenIds = new Set();
43
+ let noNewCount = 0;
44
+
45
+ for (let scroll = 0; scroll < 30; scroll++) {
46
+ const items = Array.from(document.querySelectorAll('[data-testid^="dm-conversation-item-"], [data-testid="conversation"]'));
47
+ items.forEach(el => seenIds.add(el.getAttribute('data-testid')));
48
+
49
+ if (seenIds.size >= needed) break;
50
+
51
+ // Find the scrollable container and scroll it
52
+ const scrollContainer = document.querySelector('[data-testid="dm-inbox-panel"]') ||
53
+ items[items.length - 1]?.closest('[class*="scroll"]') ||
54
+ items[items.length - 1]?.parentElement;
55
+ if (scrollContainer) {
56
+ scrollContainer.scrollTop = scrollContainer.scrollHeight;
57
+ }
58
+ // Also try scrolling the last item into view
59
+ if (items.length > 0) {
60
+ items[items.length - 1].scrollIntoView({ behavior: 'instant', block: 'end' });
61
+ }
62
+
63
+ await new Promise(r => setTimeout(r, 1500));
64
+
65
+ // Check if new items appeared
66
+ const newItems = Array.from(document.querySelectorAll('[data-testid^="dm-conversation-item-"], [data-testid="conversation"]'));
67
+ const newIds = new Set(newItems.map(el => el.getAttribute('data-testid')));
68
+ if (newIds.size <= seenIds.size) {
69
+ noNewCount++;
70
+ if (noNewCount >= 3) break; // No more loading after 3 tries
71
+ } else {
72
+ noNewCount = 0;
73
+ }
74
+ }
75
+
76
+ // Collect all visible conversations
77
+ const finalItems = Array.from(document.querySelectorAll('[data-testid^="dm-conversation-item-"], [data-testid="conversation"]'));
78
+ const conversations = finalItems.map((item, idx) => {
79
+ const testId = item.getAttribute('data-testid') || '';
80
+ const text = item.innerText || '';
81
+ const lines = text.split('\\n').filter(l => l.trim());
82
+ const user = lines[0] || 'Unknown';
83
+ const match = testId.match(/dm-conversation-item-(.+)/);
84
+ const convId = match ? match[1].replace(':', '-') : '';
85
+ const link = item.querySelector('a[href*="/messages/"]');
86
+ const href = link ? link.href : '';
87
+ return { idx, user, convId, href, preview: text.substring(0, 100) };
88
+ });
89
+
90
+ return { ok: true, conversations, total: conversations.length };
91
+ } catch(e) {
92
+ return { ok: false, error: String(e), conversations: [], total: 0 };
93
+ }
94
+ })()`);
95
+ if (!convList?.ok || !convList.conversations?.length) {
96
+ return [{ index: 1, status: 'info', user: 'System', message: 'No conversations found' }];
97
+ }
98
+ const conversations = convList.conversations;
99
+ // Step 3: Iterate through conversations and send message
100
+ for (const conv of conversations) {
101
+ if (sentCount >= maxSend)
102
+ break;
103
+ const convUrl = conv.convId
104
+ ? `https://x.com/messages/${conv.convId}`
105
+ : conv.href;
106
+ if (!convUrl)
107
+ continue;
108
+ await page.goto(convUrl);
109
+ await page.wait(3);
110
+ const sendResult = await page.evaluate(`(async () => {
111
+ try {
112
+ const messageText = ${JSON.stringify(messageText)};
113
+ const skipReplied = ${skipReplied};
114
+
115
+ // Get username from conversation
116
+ const dmHeader = document.querySelector('[data-testid="DmActivityContainer"] [dir="ltr"] span') ||
117
+ document.querySelector('[data-testid="conversation-header"]') ||
118
+ document.querySelector('[data-testid="DmActivityContainer"] h2');
119
+ const username = dmHeader ? dmHeader.innerText.trim().split('\\\\n')[0] : '${conv.user}';
120
+
121
+ // Check if we already sent this message
122
+ if (skipReplied) {
123
+ const chatArea = document.querySelector('[data-testid="DmScrollerContainer"]') ||
124
+ document.querySelector('main');
125
+ const chatText = chatArea ? chatArea.innerText : '';
126
+ if (chatText.includes(messageText)) {
127
+ return { status: 'skipped', user: username, message: 'Already sent this message' };
128
+ }
129
+ }
130
+
131
+ // Find the text input
132
+ const input = document.querySelector('[data-testid="dmComposerTextInput"]');
133
+ if (!input) {
134
+ return { status: 'error', user: username, message: 'No message input found' };
135
+ }
136
+
137
+ // Focus and type into the DraftEditor
138
+ input.focus();
139
+ await new Promise(r => setTimeout(r, 300));
140
+ document.execCommand('insertText', false, messageText);
141
+ await new Promise(r => setTimeout(r, 500));
142
+
143
+ // Click send button
144
+ const sendBtn = document.querySelector('[data-testid="dmComposerSendButton"]');
145
+ if (!sendBtn) {
146
+ return { status: 'error', user: username, message: 'No send button found' };
147
+ }
148
+
149
+ sendBtn.click();
150
+ await new Promise(r => setTimeout(r, 1500));
151
+
152
+ return { status: 'sent', user: username, message: 'Message sent: ' + messageText };
153
+ } catch(e) {
154
+ return { status: 'error', user: 'system', message: String(e) };
155
+ }
156
+ })()`);
157
+ if (sendResult?.status === 'sent') {
158
+ sentCount++;
159
+ results.push({
160
+ index: sentCount,
161
+ status: 'sent',
162
+ user: sendResult.user || conv.user,
163
+ message: sendResult.message,
164
+ });
165
+ }
166
+ else if (sendResult?.status === 'skipped') {
167
+ results.push({
168
+ index: results.length + 1,
169
+ status: 'skipped',
170
+ user: sendResult.user || conv.user,
171
+ message: sendResult.message,
172
+ });
173
+ }
174
+ await page.wait(1);
175
+ }
176
+ if (results.length === 0) {
177
+ results.push({ index: 0, status: 'info', user: 'System', message: 'No conversations processed' });
178
+ }
179
+ return results;
180
+ }
181
+ });