@jackwener/opencli 1.7.22 → 1.8.0

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 (222) hide show
  1. package/README.md +30 -148
  2. package/README.zh-CN.md +37 -211
  3. package/cli-manifest.json +6423 -4260
  4. package/clis/12306/me.js +73 -0
  5. package/clis/12306/orders.js +96 -0
  6. package/clis/12306/passengers.js +90 -0
  7. package/clis/12306/price.js +166 -0
  8. package/clis/12306/stations.js +66 -0
  9. package/clis/12306/train.js +91 -0
  10. package/clis/12306/trains.js +119 -0
  11. package/clis/12306/utils.js +272 -0
  12. package/clis/12306/utils.test.js +331 -0
  13. package/clis/36kr/article.js +6 -3
  14. package/clis/36kr/article.test.js +46 -0
  15. package/clis/apple-podcasts/commands.test.js +20 -0
  16. package/clis/apple-podcasts/search.js +2 -2
  17. package/clis/barchart/greeks.js +144 -56
  18. package/clis/barchart/greeks.test.js +138 -0
  19. package/clis/bilibili/summary.js +167 -0
  20. package/clis/bilibili/summary.test.js +210 -0
  21. package/clis/booking/booking.test.js +356 -0
  22. package/clis/booking/search.js +351 -0
  23. package/clis/chatgpt/envelope.test.js +108 -0
  24. package/clis/chatgpt/image.js +2 -2
  25. package/clis/chatgpt/image.test.js +6 -0
  26. package/clis/chatgpt/utils.js +148 -41
  27. package/clis/chatgpt/utils.test.js +92 -2
  28. package/clis/douyin/_shared/browser-fetch.js +44 -20
  29. package/clis/douyin/_shared/browser-fetch.test.js +22 -1
  30. package/clis/douyin/_shared/evaluate-result.js +16 -0
  31. package/clis/douyin/_shared/tos-upload.js +105 -69
  32. package/clis/douyin/_shared/vod-upload.js +212 -0
  33. package/clis/douyin/_shared/vod-upload.test.js +38 -0
  34. package/clis/douyin/delete.js +137 -4
  35. package/clis/douyin/delete.test.js +90 -1
  36. package/clis/douyin/publish-upload-id.test.js +170 -0
  37. package/clis/douyin/publish.js +88 -42
  38. package/clis/douyin/user-videos.js +9 -2
  39. package/clis/douyin/user-videos.test.js +43 -0
  40. package/clis/flomo/memos.js +228 -0
  41. package/clis/flomo/memos.test.js +144 -0
  42. package/clis/gitee/search.js +2 -2
  43. package/clis/gitee/search.test.js +65 -0
  44. package/clis/jike/post.js +27 -17
  45. package/clis/jike/read.test.js +86 -0
  46. package/clis/jike/topic.js +32 -19
  47. package/clis/jike/user.js +33 -20
  48. package/clis/lesswrong/comments.js +1 -1
  49. package/clis/lesswrong/curated.js +1 -1
  50. package/clis/lesswrong/frontpage.js +1 -1
  51. package/clis/lesswrong/frontpage.test.js +37 -0
  52. package/clis/lesswrong/new.js +1 -1
  53. package/clis/lesswrong/read.js +1 -1
  54. package/clis/lesswrong/sequences.js +1 -1
  55. package/clis/lesswrong/shortform.js +1 -1
  56. package/clis/lesswrong/tag.js +1 -1
  57. package/clis/lesswrong/top-month.js +1 -1
  58. package/clis/lesswrong/top-week.js +1 -1
  59. package/clis/lesswrong/top-year.js +1 -1
  60. package/clis/lesswrong/top.js +1 -1
  61. package/clis/linkedin/connect.js +401 -0
  62. package/clis/linkedin/connect.test.js +213 -0
  63. package/clis/linkedin/inbox.js +234 -0
  64. package/clis/linkedin/inbox.test.js +152 -0
  65. package/clis/linkedin/people-search.js +262 -0
  66. package/clis/linkedin/people-search.test.js +216 -0
  67. package/clis/linkedin/safe-send.js +357 -0
  68. package/clis/linkedin/safe-send.test.js +204 -0
  69. package/clis/linkedin/salesnav-inbox.js +210 -0
  70. package/clis/linkedin/salesnav-inbox.test.js +113 -0
  71. package/clis/linkedin/salesnav-message.js +360 -0
  72. package/clis/linkedin/salesnav-message.test.js +172 -0
  73. package/clis/linkedin/salesnav-search.js +186 -0
  74. package/clis/linkedin/salesnav-search.test.js +76 -0
  75. package/clis/linkedin/salesnav-thread.js +212 -0
  76. package/clis/linkedin/salesnav-thread.test.js +79 -0
  77. package/clis/linkedin/sent-invitations.js +92 -0
  78. package/clis/linkedin/sent-invitations.test.js +62 -0
  79. package/clis/linkedin/thread-snapshot.js +214 -0
  80. package/clis/linkedin/thread-snapshot.test.js +89 -0
  81. package/clis/linkedin-learning/course.js +138 -0
  82. package/clis/linkedin-learning/course.test.js +114 -0
  83. package/clis/linkedin-learning/search.js +155 -0
  84. package/clis/linkedin-learning/search.test.js +144 -0
  85. package/clis/linkedin-learning/trending.js +133 -0
  86. package/clis/linkedin-learning/trending.test.js +123 -0
  87. package/clis/powerchina/search.js +3 -3
  88. package/clis/powerchina/search.test.js +27 -1
  89. package/clis/reddit/extract-media.test.js +149 -0
  90. package/clis/reddit/frontpage.js +47 -9
  91. package/clis/reddit/frontpage.test.js +34 -0
  92. package/clis/reddit/home.js +31 -1
  93. package/clis/reddit/home.test.js +46 -3
  94. package/clis/reddit/hot.js +32 -1
  95. package/clis/reddit/hot.test.js +15 -1
  96. package/clis/reddit/popular.js +39 -1
  97. package/clis/reddit/popular.test.js +26 -0
  98. package/clis/reddit/saved.js +1 -1
  99. package/clis/reddit/search.js +38 -1
  100. package/clis/reddit/search.test.js +26 -0
  101. package/clis/reddit/subreddit.js +52 -7
  102. package/clis/reddit/subreddit.test.js +31 -0
  103. package/clis/reddit/subscribed.js +165 -0
  104. package/clis/reddit/subscribed.test.js +168 -0
  105. package/clis/reddit/upvoted.js +1 -1
  106. package/clis/suno/commands.test.js +188 -0
  107. package/clis/suno/download.js +140 -0
  108. package/clis/suno/download.test.js +151 -0
  109. package/clis/suno/generate.js +226 -0
  110. package/clis/suno/generate.test.js +243 -0
  111. package/clis/suno/list.js +79 -0
  112. package/clis/suno/status.js +62 -0
  113. package/clis/suno/utils.js +540 -0
  114. package/clis/suno/utils.test.js +223 -0
  115. package/clis/twitter/device-follow.js +193 -0
  116. package/clis/twitter/device-follow.test.js +287 -0
  117. package/clis/twitter/download.js +443 -73
  118. package/clis/twitter/download.test.js +457 -0
  119. package/clis/twitter/list-create.js +155 -0
  120. package/clis/twitter/list-create.test.js +169 -0
  121. package/clis/twitter/list-remove.js +12 -5
  122. package/clis/twitter/list-remove.test.js +74 -0
  123. package/clis/twitter/list-tweets.js +6 -2
  124. package/clis/twitter/list-tweets.test.js +41 -1
  125. package/clis/twitter/lists.js +31 -4
  126. package/clis/twitter/lists.test.js +152 -16
  127. package/clis/twitter/search.js +6 -2
  128. package/clis/twitter/search.test.js +6 -0
  129. package/clis/twitter/shared.js +144 -0
  130. package/clis/twitter/shared.test.js +429 -1
  131. package/clis/twitter/thread.js +10 -2
  132. package/clis/twitter/thread.test.js +58 -0
  133. package/clis/twitter/timeline.js +6 -2
  134. package/clis/twitter/timeline.test.js +2 -0
  135. package/clis/twitter/tweets.js +3 -2
  136. package/clis/twitter/tweets.test.js +1 -1
  137. package/clis/weibo/delete.js +172 -0
  138. package/clis/weibo/delete.test.js +94 -0
  139. package/clis/weibo/publish.js +37 -14
  140. package/clis/weibo/publish.test.js +14 -5
  141. package/clis/weibo/user-posts.js +234 -0
  142. package/clis/weibo/user-posts.test.js +92 -0
  143. package/clis/weread/search-regression.test.js +18 -11
  144. package/clis/weread/search.js +15 -7
  145. package/clis/weread-official/book.js +135 -0
  146. package/clis/weread-official/commands.test.js +385 -0
  147. package/clis/weread-official/discover.js +107 -0
  148. package/clis/weread-official/list-apis.js +95 -0
  149. package/clis/weread-official/notes.js +171 -0
  150. package/clis/weread-official/readdata.js +158 -0
  151. package/clis/weread-official/review.js +93 -0
  152. package/clis/weread-official/search.js +106 -0
  153. package/clis/weread-official/shelf.js +97 -0
  154. package/clis/weread-official/utils.js +293 -0
  155. package/clis/weread-official/utils.test.js +242 -0
  156. package/clis/wikipedia/trending.js +7 -3
  157. package/clis/wikipedia/trending.test.js +57 -0
  158. package/clis/xianyu/chat.js +24 -109
  159. package/clis/xianyu/chat.test.js +5 -0
  160. package/clis/xianyu/im.js +322 -0
  161. package/clis/xianyu/im.test.js +253 -0
  162. package/clis/xianyu/inbox.js +96 -0
  163. package/clis/xianyu/messages.js +91 -0
  164. package/clis/xianyu/reply.js +82 -0
  165. package/clis/xiaohongshu/creator-note-detail.js +2 -1
  166. package/clis/xiaohongshu/creator-note-detail.test.js +11 -0
  167. package/clis/xiaohongshu/creator-notes-summary.js +2 -1
  168. package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
  169. package/clis/xiaohongshu/creator-notes.js +2 -1
  170. package/clis/xiaohongshu/creator-notes.test.js +12 -0
  171. package/clis/xiaohongshu/creator-stats.js +2 -1
  172. package/clis/xiaohongshu/creator-stats.test.js +24 -0
  173. package/clis/xiaohongshu/delete-note.js +260 -0
  174. package/clis/xiaohongshu/delete-note.test.js +172 -0
  175. package/clis/xiaohongshu/publish.js +48 -8
  176. package/clis/xiaohongshu/publish.test.js +65 -10
  177. package/clis/xiaohongshu/user-helpers.test.js +41 -0
  178. package/clis/xiaohongshu/user.js +27 -4
  179. package/clis/xiaoyuzhou/download.js +1 -1
  180. package/clis/xiaoyuzhou/transcript.js +1 -1
  181. package/clis/youdao/note.js +258 -0
  182. package/clis/youdao/note.test.js +99 -0
  183. package/clis/youtube/transcript.js +397 -24
  184. package/clis/youtube/transcript.test.js +196 -6
  185. package/clis/zhihu/answer-comments.js +299 -0
  186. package/clis/zhihu/answer-comments.test.js +287 -0
  187. package/clis/zhihu/answer-detail.js +12 -0
  188. package/clis/zhihu/answer-detail.test.js +8 -0
  189. package/clis/zhihu/collection.js +15 -2
  190. package/clis/zhihu/collection.test.js +46 -0
  191. package/clis/zhihu/download.js +1 -1
  192. package/clis/zhihu/question.js +42 -9
  193. package/clis/zhihu/question.test.js +111 -9
  194. package/clis/zhihu/search.js +206 -43
  195. package/clis/zhihu/search.test.js +198 -0
  196. package/dist/src/browser/errors.js +4 -2
  197. package/dist/src/browser/errors.test.js +6 -0
  198. package/dist/src/browser/page.js +30 -4
  199. package/dist/src/browser/page.test.js +42 -0
  200. package/dist/src/browser/utils.d.ts +1 -1
  201. package/dist/src/cli-argv-preprocess.d.ts +26 -0
  202. package/dist/src/cli-argv-preprocess.js +138 -0
  203. package/dist/src/cli-argv-preprocess.test.js +79 -0
  204. package/dist/src/convention-audit.js +15 -8
  205. package/dist/src/convention-audit.test.js +21 -0
  206. package/dist/src/download/media-download.js +15 -2
  207. package/dist/src/download/media-download.test.d.ts +1 -0
  208. package/dist/src/download/media-download.test.js +110 -0
  209. package/dist/src/electron-apps.js +1 -1
  210. package/dist/src/electron-apps.test.js +7 -2
  211. package/dist/src/errors.d.ts +17 -0
  212. package/dist/src/errors.js +22 -0
  213. package/dist/src/external-clis.yaml +8 -0
  214. package/dist/src/main.js +14 -2
  215. package/dist/src/utils.d.ts +43 -0
  216. package/dist/src/utils.js +97 -0
  217. package/dist/src/utils.test.d.ts +1 -0
  218. package/dist/src/utils.test.js +155 -0
  219. package/package.json +8 -2
  220. package/scripts/silent-column-drop-baseline.json +0 -52
  221. package/scripts/typed-error-lint-baseline.json +28 -380
  222. package/clis/slock/_utils.js +0 -12
@@ -0,0 +1,169 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
4
+ import { buildListCreateRow, parseListCreateArgs } from './list-create.js';
5
+ import './list-create.js';
6
+
7
+ function createPayload(overrides = {}) {
8
+ return {
9
+ ok: true,
10
+ httpStatus: 200,
11
+ bodyText: '{}',
12
+ bodyJson: {
13
+ data: {
14
+ list: {
15
+ id_str: '123456789',
16
+ name: 'My List',
17
+ description: 'A list',
18
+ mode: 'Private',
19
+ ...overrides,
20
+ },
21
+ },
22
+ },
23
+ };
24
+ }
25
+
26
+ describe('twitter list-create registration', () => {
27
+ it('registers the list-create command with the expected shape', () => {
28
+ const cmd = getRegistry().get('twitter/list-create');
29
+ expect(cmd?.func).toBeTypeOf('function');
30
+ expect(cmd?.columns).toEqual(['id', 'name', 'description', 'mode', 'status']);
31
+ const nameArg = cmd?.args?.find((a) => a.name === 'name');
32
+ expect(nameArg).toBeTruthy();
33
+ expect(nameArg?.required).toBe(true);
34
+ expect(nameArg?.positional).toBe(true);
35
+ const modeArg = cmd?.args?.find((a) => a.name === 'mode');
36
+ expect(modeArg?.default).toBe('public');
37
+ const descArg = cmd?.args?.find((a) => a.name === 'description');
38
+ expect(descArg?.default).toBe('');
39
+ });
40
+
41
+ it('rejects empty name', async () => {
42
+ const cmd = getRegistry().get('twitter/list-create');
43
+ await expect(cmd.func({}, { name: ' ' })).rejects.toBeInstanceOf(ArgumentError);
44
+ });
45
+
46
+ it('rejects names over 25 chars', async () => {
47
+ const cmd = getRegistry().get('twitter/list-create');
48
+ await expect(cmd.func({}, { name: 'x'.repeat(26) })).rejects.toBeInstanceOf(ArgumentError);
49
+ });
50
+
51
+ it('rejects descriptions over 100 chars', () => {
52
+ expect(() => parseListCreateArgs({ name: 'ok', description: 'x'.repeat(101) })).toThrow(ArgumentError);
53
+ });
54
+
55
+ it('rejects invalid mode', async () => {
56
+ const cmd = getRegistry().get('twitter/list-create');
57
+ await expect(cmd.func({}, { name: 'ok', mode: 'secret' })).rejects.toBeInstanceOf(ArgumentError);
58
+ });
59
+
60
+ it('reads ct0 from cookies and unwraps Browser Bridge mutation envelopes', async () => {
61
+ const cmd = getRegistry().get('twitter/list-create');
62
+ const page = {
63
+ goto: vi.fn().mockResolvedValue(undefined),
64
+ wait: vi.fn().mockResolvedValue(undefined),
65
+ getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'csrf-token' }]),
66
+ evaluate: vi.fn().mockResolvedValue({ session: 'browser:default', data: createPayload() }),
67
+ };
68
+
69
+ const rows = await cmd.func(page, { name: 'My List', description: 'A list', mode: 'private' });
70
+
71
+ expect(page.goto).toHaveBeenCalledWith('https://x.com');
72
+ expect(page.wait).toHaveBeenCalledWith(3);
73
+ expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
74
+ expect(rows).toEqual([{ id: '123456789', name: 'My List', description: 'A list', mode: 'private', status: 'success' }]);
75
+ });
76
+
77
+ it('rejects missing ct0 before mutation', async () => {
78
+ const cmd = getRegistry().get('twitter/list-create');
79
+ const page = {
80
+ goto: vi.fn().mockResolvedValue(undefined),
81
+ wait: vi.fn().mockResolvedValue(undefined),
82
+ getCookies: vi.fn().mockResolvedValue([]),
83
+ evaluate: vi.fn(),
84
+ };
85
+
86
+ await expect(cmd.func(page, { name: 'My List' })).rejects.toBeInstanceOf(AuthRequiredError);
87
+ expect(page.evaluate).not.toHaveBeenCalled();
88
+ });
89
+
90
+ it('keeps non-fatal GraphQL errors when a valid created list payload exists', () => {
91
+ const row = buildListCreateRow({
92
+ result: {
93
+ ...createPayload(),
94
+ bodyJson: {
95
+ ...createPayload().bodyJson,
96
+ errors: [{ message: 'DecodeException' }],
97
+ },
98
+ },
99
+ name: 'My List',
100
+ description: 'A list',
101
+ mode: 'private',
102
+ });
103
+
104
+ expect(row).toEqual({ id: '123456789', name: 'My List', description: 'A list', mode: 'private', status: 'success' });
105
+ });
106
+
107
+ it('maps mutation auth and HTTP failures to typed errors', () => {
108
+ expect(() => buildListCreateRow({
109
+ result: { ok: false, httpStatus: 401, bodyText: 'login' },
110
+ name: 'My List',
111
+ description: '',
112
+ mode: 'public',
113
+ })).toThrow(AuthRequiredError);
114
+
115
+ expect(() => buildListCreateRow({
116
+ result: { ok: false, httpStatus: 500, bodyText: 'server' },
117
+ name: 'My List',
118
+ description: '',
119
+ mode: 'public',
120
+ })).toThrow(CommandExecutionError);
121
+ });
122
+
123
+ it('fails typed when the mutation response lacks post-condition evidence', () => {
124
+ for (const result of [
125
+ createPayload({ id_str: '', id: '' }),
126
+ createPayload({ name: '' }),
127
+ createPayload({ mode: '' }),
128
+ ]) {
129
+ expect(() => buildListCreateRow({
130
+ result,
131
+ name: 'My List',
132
+ description: '',
133
+ mode: 'public',
134
+ })).toThrow(CommandExecutionError);
135
+ }
136
+ });
137
+
138
+ it('fails typed when returned list name does not match the requested name', () => {
139
+ expect(() => buildListCreateRow({
140
+ result: createPayload({ name: 'Other List' }),
141
+ name: 'My List',
142
+ description: '',
143
+ mode: 'private',
144
+ })).toThrow(/expected "My List"/);
145
+ });
146
+
147
+ it('fails typed when returned list mode does not match requested mode', () => {
148
+ expect(() => buildListCreateRow({
149
+ result: createPayload({ mode: 'Public' }),
150
+ name: 'My List',
151
+ description: '',
152
+ mode: 'private',
153
+ })).toThrow(/expected private/);
154
+ });
155
+
156
+ it('fails typed when errors appear without a list payload', () => {
157
+ expect(() => buildListCreateRow({
158
+ result: {
159
+ ok: true,
160
+ httpStatus: 200,
161
+ bodyText: '{}',
162
+ bodyJson: { errors: [{ message: 'duplicate name' }] },
163
+ },
164
+ name: 'My List',
165
+ description: '',
166
+ mode: 'public',
167
+ })).toThrow(/duplicate name/);
168
+ });
169
+ });
@@ -1,7 +1,7 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
3
  import { resolveTwitterQueryId } from './shared.js';
4
- import { parseListsManagement } from './lists.js';
4
+ import { getListsManagementInstructions, parseListsManagement } from './lists.js';
5
5
  import { TWITTER_BEARER_TOKEN } from './utils.js';
6
6
 
7
7
  const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
@@ -274,11 +274,18 @@ cli({
274
274
  if (!r.ok) return { __error: 'HTTP ' + r.status };
275
275
  return await r.json();
276
276
  }`);
277
- const parsedAfter = listsAfter && !listsAfter.__error
278
- ? parseListsManagement(listsAfter, new Set())
279
- : [];
277
+ if (listsAfter && listsAfter.__error) {
278
+ throw new CommandExecutionError(`Could not verify list removal: ${listsAfter.__error}`);
279
+ }
280
+ if (!getListsManagementInstructions(listsAfter)) {
281
+ throw new CommandExecutionError('Could not verify list removal: unexpected lists payload shape');
282
+ }
283
+ const parsedAfter = parseListsManagement(listsAfter, new Set());
280
284
  const afterList = parsedAfter.find((l) => l.id === listId);
281
- const memberCountAfter = afterList ? Number(afterList.members) || 0 : -1;
285
+ if (!afterList) {
286
+ throw new CommandExecutionError(`Could not verify list removal: list ${listId} missing from post-delete payload`);
287
+ }
288
+ const memberCountAfter = Number(afterList.members) || 0;
282
289
  if (memberCountAfter < memberCountBefore) {
283
290
  verifiedBy = `member_count ${memberCountBefore} → ${memberCountAfter}`;
284
291
  } else {
@@ -1,7 +1,57 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
3
4
  import './list-remove.js';
4
5
 
6
+ function buildListsPayload(listId = '123', memberCount = 10) {
7
+ return {
8
+ data: {
9
+ viewer: {
10
+ list_management_timeline: {
11
+ timeline: {
12
+ instructions: [{
13
+ entries: [{
14
+ entryId: 'owned-subscribed-list-module-0',
15
+ content: {
16
+ items: [{
17
+ item: {
18
+ itemContent: {
19
+ list: {
20
+ id_str: listId,
21
+ name: 'My List',
22
+ member_count: memberCount,
23
+ subscriber_count: 0,
24
+ mode: 'Public',
25
+ },
26
+ },
27
+ },
28
+ }],
29
+ },
30
+ }],
31
+ }],
32
+ },
33
+ },
34
+ },
35
+ },
36
+ };
37
+ }
38
+
39
+ function buildRemovePage(afterPayload) {
40
+ return {
41
+ goto: vi.fn().mockResolvedValue(undefined),
42
+ wait: vi.fn().mockResolvedValue(undefined),
43
+ getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'token' }]),
44
+ nativeClick: vi.fn().mockResolvedValue(undefined),
45
+ evaluate: vi.fn()
46
+ .mockResolvedValueOnce(null) // UserByScreenName queryId fallback
47
+ .mockResolvedValueOnce('user-1')
48
+ .mockResolvedValueOnce(null) // ListsManagement queryId fallback
49
+ .mockResolvedValueOnce(buildListsPayload('123', 10))
50
+ .mockResolvedValueOnce({ ok: true, needsNativeInteraction: true, rowClickX: 1, rowClickY: 2, saveClickX: 3, saveClickY: 4 })
51
+ .mockResolvedValueOnce(afterPayload),
52
+ };
53
+ }
54
+
5
55
  describe('twitter list-remove registration', () => {
6
56
  it('registers the list-remove command with the expected shape', () => {
7
57
  const cmd = getRegistry().get('twitter/list-remove');
@@ -33,4 +83,28 @@ describe('twitter list-remove registration', () => {
33
83
  expect(page.wait).toHaveBeenCalledWith(3);
34
84
  expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
35
85
  });
86
+
87
+ it('does not treat post-delete fetch failure as successful member_count decrease', async () => {
88
+ const cmd = getRegistry().get('twitter/list-remove');
89
+ const page = buildRemovePage({ __error: 'HTTP 500' });
90
+
91
+ await expect(cmd.func(page, { listId: '123', username: 'alice' }))
92
+ .rejects.toBeInstanceOf(CommandExecutionError);
93
+ });
94
+
95
+ it('does not treat malformed post-delete payload as successful member_count decrease', async () => {
96
+ const cmd = getRegistry().get('twitter/list-remove');
97
+ const page = buildRemovePage({ data: {} });
98
+
99
+ await expect(cmd.func(page, { listId: '123', username: 'alice' }))
100
+ .rejects.toBeInstanceOf(CommandExecutionError);
101
+ });
102
+
103
+ it('does not treat a missing target list in post-delete payload as success', async () => {
104
+ const cmd = getRegistry().get('twitter/list-remove');
105
+ const page = buildRemovePage(buildListsPayload('456', 1));
106
+
107
+ await expect(cmd.func(page, { listId: '123', username: 'alice' }))
108
+ .rejects.toBeInstanceOf(CommandExecutionError);
109
+ });
36
110
  });
@@ -1,6 +1,6 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
- import { extractMedia } from './shared.js';
3
+ import { extractMedia, extractCard, extractQuotedTweet } from './shared.js';
4
4
  import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
5
5
 
6
6
  const LIST_TWEETS_QUERY_ID = 'RlZzktZY_9wJynoepm8ZsA';
@@ -61,11 +61,13 @@ export function extractTimelineTweet(result, seen) {
61
61
  const user = tw.core?.user_results?.result;
62
62
  const screenName = user?.legacy?.screen_name || user?.core?.screen_name || 'unknown';
63
63
  const displayName = user?.legacy?.name || user?.core?.name || '';
64
+ const bio = user?.legacy?.description || '';
64
65
  const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
65
66
  return {
66
67
  id: tw.rest_id,
67
68
  author: screenName,
68
69
  name: displayName,
70
+ bio,
69
71
  text: noteText || legacy.full_text || '',
70
72
  likes: legacy.favorite_count || 0,
71
73
  retweets: legacy.retweet_count || 0,
@@ -73,6 +75,8 @@ export function extractTimelineTweet(result, seen) {
73
75
  created_at: legacy.created_at || '',
74
76
  url: `https://x.com/${screenName}/status/${tw.rest_id}`,
75
77
  ...extractMedia(legacy),
78
+ card: extractCard(tw),
79
+ quoted_tweet: extractQuotedTweet(tw),
76
80
  };
77
81
  }
78
82
 
@@ -120,7 +124,7 @@ cli({
120
124
  { name: 'limit', type: 'int', default: 50 },
121
125
  { name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the list timeline by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the list\'s native (recency) ordering.' },
122
126
  ],
123
- columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'created_at', 'url', 'has_media', 'media_urls'],
127
+ columns: ['id', 'author', 'bio', 'text', 'likes', 'retweets', 'replies', 'created_at', 'url', 'has_media', 'media_urls', 'card', 'quoted_tweet'],
124
128
  func: async (page, kwargs) => {
125
129
  const listId = String(kwargs.listId || '').trim();
126
130
  if (!listId || !/^\d+$/.test(listId)) {
@@ -15,7 +15,7 @@ describe('twitter list-tweets parser', () => {
15
15
  core: {
16
16
  user_results: {
17
17
  result: {
18
- legacy: { screen_name: 'bob', name: 'Bob' },
18
+ legacy: { screen_name: 'bob', name: 'Bob', description: 'List author bio' },
19
19
  },
20
20
  },
21
21
  },
@@ -24,6 +24,7 @@ describe('twitter list-tweets parser', () => {
24
24
  id: '99',
25
25
  author: 'bob',
26
26
  name: 'Bob',
27
+ bio: 'List author bio',
27
28
  text: 'hello list',
28
29
  likes: 3,
29
30
  retweets: 1,
@@ -32,6 +33,45 @@ describe('twitter list-tweets parser', () => {
32
33
  url: 'https://x.com/bob/status/99',
33
34
  has_media: false,
34
35
  media_urls: [],
36
+ card: null,
37
+ quoted_tweet: null,
38
+ });
39
+ });
40
+
41
+ it('surfaces quoted_tweet field on quote tweets (mini-tweet shape)', () => {
42
+ // 1778721843 stale-snapshot case from ml-scout — downstream consumers
43
+ // need quote-tweet content to render the embedded preview card.
44
+ const tweet = extractTimelineTweet({
45
+ rest_id: '500',
46
+ legacy: {
47
+ full_text: '总的来说,还是有个好爹',
48
+ is_quote_status: true,
49
+ quoted_status_id_str: '499',
50
+ },
51
+ core: { user_results: { result: { legacy: { screen_name: 'rwayne' } } } },
52
+ quoted_status_result: {
53
+ result: {
54
+ rest_id: '499',
55
+ legacy: {
56
+ full_text: '罗某官二代背景考',
57
+ created_at: 'Wed May 13 22:00:00 +0000 2026',
58
+ extended_entities: {
59
+ media: [{ type: 'photo', media_url_https: 'https://pbs.twimg.com/media/x.jpg' }],
60
+ },
61
+ },
62
+ core: { user_results: { result: { legacy: { screen_name: 'alice', name: 'Alice' } } } },
63
+ },
64
+ },
65
+ }, new Set());
66
+ expect(tweet?.quoted_tweet).toEqual({
67
+ id: '499',
68
+ author: 'alice',
69
+ name: 'Alice',
70
+ text: '罗某官二代背景考',
71
+ created_at: 'Wed May 13 22:00:00 +0000 2026',
72
+ url: 'https://x.com/alice/status/499',
73
+ has_media: true,
74
+ media_urls: ['https://pbs.twimg.com/media/x.jpg'],
35
75
  });
36
76
  });
37
77
 
@@ -1,5 +1,5 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
- import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
2
+ import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
3
  import { TWITTER_BEARER_TOKEN } from './utils.js';
4
4
 
5
5
  const LISTS_QUERY_ID = '78UbkyXwXBD98IgUWXOy9g';
@@ -62,14 +62,35 @@ export function extractListEntry(entry, seen) {
62
62
  };
63
63
  }
64
64
 
65
- export function parseListsManagement(data, seen) {
66
- const lists = [];
65
+ // X ListsManagementPageTimeline 把 /<user>/lists 整个页面的所有 section
66
+ // 都塞在同一个 TimelineAddEntries instruction 里,靠 entry.entryId 前缀区分:
67
+ // - `owned-subscribed-list-module-*` → 用户的 owned + subscribed list(要保留)
68
+ // - `list-to-follow-module-*` → "Discover new Lists" 算法推荐(要剔除)
69
+ // - `cursor-*` → 分页游标(无 list 数据)
70
+ // 旧版 parser 忽略 entryId 一律下钻,导致推荐 list 被当成自建/订阅泄漏出来。
71
+ const OWNED_SUBSCRIBED_ENTRY_PREFIX = 'owned-subscribed-list-module-';
72
+
73
+ export function isOwnedSubscribedEntry(entry) {
74
+ return typeof entry?.entryId === 'string'
75
+ && entry.entryId.startsWith(OWNED_SUBSCRIBED_ENTRY_PREFIX);
76
+ }
77
+
78
+ export function getListsManagementInstructions(data) {
67
79
  const instructions = data?.data?.viewer?.list_management_timeline?.timeline?.instructions
68
80
  || data?.data?.viewer_v2?.user_results?.result?.list_management_timeline?.timeline?.instructions
69
81
  || data?.data?.list_management_timeline?.timeline?.instructions
70
- || [];
82
+ || data?.data?.data?.viewer?.list_management_timeline?.timeline?.instructions
83
+ || data?.data?.data?.viewer_v2?.user_results?.result?.list_management_timeline?.timeline?.instructions
84
+ || data?.data?.data?.list_management_timeline?.timeline?.instructions;
85
+ return Array.isArray(instructions) ? instructions : null;
86
+ }
87
+
88
+ export function parseListsManagement(data, seen) {
89
+ const lists = [];
90
+ const instructions = getListsManagementInstructions(data) || [];
71
91
  for (const inst of instructions) {
72
92
  for (const entry of inst.entries || []) {
93
+ if (!isOwnedSubscribedEntry(entry)) continue;
73
94
  const direct = extractListEntry(entry, seen);
74
95
  if (direct) {
75
96
  lists.push(direct);
@@ -144,7 +165,13 @@ export const command = cli({
144
165
  throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch lists. queryId may have expired.`);
145
166
  }
146
167
  const seen = new Set();
168
+ if (!getListsManagementInstructions(data)) {
169
+ throw new CommandExecutionError('Twitter lists returned an unexpected payload shape');
170
+ }
147
171
  const lists = parseListsManagement(data, seen);
172
+ if (lists.length === 0) {
173
+ throw new EmptyResultError('twitter lists', 'No owned or subscribed lists found');
174
+ }
148
175
  return lists.slice(0, limit);
149
176
  },
150
177
  });
@@ -1,5 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { extractListEntry, parseListsManagement } from './lists.js';
2
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
+ import { getRegistry } from '@jackwener/opencli/registry';
4
+ import { extractListEntry, isOwnedSubscribedEntry, parseListsManagement } from './lists.js';
3
5
 
4
6
  describe('twitter lists parser', () => {
5
7
  it('extracts a list entry with full metadata', () => {
@@ -56,7 +58,7 @@ describe('twitter lists parser', () => {
56
58
  expect(extractListEntry({ content: { itemContent: {} } }, new Set())).toBeNull();
57
59
  });
58
60
 
59
- it('parses ListsManagementPageTimeline payload instructions', () => {
61
+ it('parses ListsManagementPageTimeline payload instructions (real shape: nested module items)', () => {
60
62
  const payload = {
61
63
  data: {
62
64
  viewer: {
@@ -64,23 +66,92 @@ describe('twitter lists parser', () => {
64
66
  timeline: {
65
67
  instructions: [
66
68
  {
69
+ type: 'TimelineAddEntries',
67
70
  entries: [
68
71
  {
69
- entryId: 'owned-list-1',
72
+ entryId: 'owned-subscribed-list-module-0',
70
73
  content: {
71
- itemContent: {
72
- list: { id_str: '1', name: 'Crypto', member_count: 44, subscriber_count: 8747, mode: 'Public' },
73
- },
74
+ entryType: 'TimelineTimelineModule',
75
+ items: [
76
+ {
77
+ entryId: 'owned-subscribed-list-module-0-list-1',
78
+ item: {
79
+ itemContent: {
80
+ itemType: 'TimelineTwitterList',
81
+ list: { id_str: '1', name: 'AI & Agents', member_count: 33, subscriber_count: 0, mode: 'Private' },
82
+ },
83
+ },
84
+ },
85
+ {
86
+ entryId: 'owned-subscribed-list-module-0-list-2',
87
+ item: {
88
+ itemContent: {
89
+ itemType: 'TimelineTwitterList',
90
+ list: { id_str: '2', name: 'Anthropic Team', member_count: 10, subscriber_count: 0, mode: 'Public' },
91
+ },
92
+ },
93
+ },
94
+ ],
95
+ },
96
+ },
97
+ ],
98
+ },
99
+ ],
100
+ },
101
+ },
102
+ },
103
+ },
104
+ };
105
+ const result = parseListsManagement(payload, new Set());
106
+ expect(result).toHaveLength(2);
107
+ expect(result[0]).toMatchObject({ id: '1', name: 'AI & Agents', mode: 'private' });
108
+ expect(result[1]).toMatchObject({ id: '2', name: 'Anthropic Team', mode: 'public' });
109
+ });
110
+
111
+ it('skips "Discover new Lists" recommendations (list-to-follow-module-*)', () => {
112
+ // 真实 X.com /<user>/lists 响应:Discover 推荐 + Your Lists 同 instruction,
113
+ // 区别只在 entry.entryId 前缀。Parser 必须按前缀剔除推荐。
114
+ const payload = {
115
+ data: {
116
+ viewer: {
117
+ list_management_timeline: {
118
+ timeline: {
119
+ instructions: [
120
+ {
121
+ type: 'TimelineAddEntries',
122
+ entries: [
123
+ {
124
+ entryId: 'list-to-follow-module-2050754937725386752',
125
+ content: {
126
+ entryType: 'TimelineTimelineModule',
127
+ items: [
128
+ {
129
+ entryId: 'list-to-follow-module-XYZ-list-1597593475389984769',
130
+ item: { itemContent: { itemType: 'TimelineTwitterList', list: { id_str: '1597593475389984769', name: 'Crypto', member_count: 44, subscriber_count: 8947, mode: 'Public' } } },
131
+ },
132
+ {
133
+ entryId: 'list-to-follow-module-XYZ-list-1499395616262217730',
134
+ item: { itemContent: { itemType: 'TimelineTwitterList', list: { id_str: '1499395616262217730', name: 'Crypto Blockchain', member_count: 24, subscriber_count: 1166, mode: 'Public' } } },
135
+ },
136
+ ],
74
137
  },
75
138
  },
76
139
  {
77
- entryId: 'subscribed-list-2',
140
+ entryId: 'owned-subscribed-list-module-0',
78
141
  content: {
79
- itemContent: {
80
- list: { id_str: '2', name: 'AI', member_count: 15, subscriber_count: 0, mode: 'Private' },
81
- },
142
+ entryType: 'TimelineTimelineModule',
143
+ items: [
144
+ {
145
+ entryId: 'owned-subscribed-list-module-0-list-2044679538156912976',
146
+ item: { itemContent: { itemType: 'TimelineTwitterList', list: { id_str: '2044679538156912976', name: 'AI & Agents', member_count: 33, subscriber_count: 0, mode: 'Private' } } },
147
+ },
148
+ ],
82
149
  },
83
150
  },
151
+ {
152
+ entryId: 'cursor-bottom-2050754937725386750',
153
+ content: { entryType: 'TimelineTimelineCursor' },
154
+ },
84
155
  ],
85
156
  },
86
157
  ],
@@ -90,9 +161,19 @@ describe('twitter lists parser', () => {
90
161
  },
91
162
  };
92
163
  const result = parseListsManagement(payload, new Set());
93
- expect(result).toHaveLength(2);
94
- expect(result[0]).toMatchObject({ id: '1', name: 'Crypto', mode: 'public' });
95
- expect(result[1]).toMatchObject({ id: '2', name: 'AI', mode: 'private' });
164
+ expect(result).toHaveLength(1);
165
+ expect(result[0]).toMatchObject({ id: '2044679538156912976', name: 'AI & Agents' });
166
+ // No Crypto/Blockchain leakage
167
+ expect(result.find(l => l.name === 'Crypto')).toBeUndefined();
168
+ expect(result.find(l => l.name === 'Crypto Blockchain')).toBeUndefined();
169
+ });
170
+
171
+ it('isOwnedSubscribedEntry classifies entryIds', () => {
172
+ expect(isOwnedSubscribedEntry({ entryId: 'owned-subscribed-list-module-0' })).toBe(true);
173
+ expect(isOwnedSubscribedEntry({ entryId: 'list-to-follow-module-2050754937725386752' })).toBe(false);
174
+ expect(isOwnedSubscribedEntry({ entryId: 'cursor-bottom-XYZ' })).toBe(false);
175
+ expect(isOwnedSubscribedEntry({})).toBe(false);
176
+ expect(isOwnedSubscribedEntry({ entryId: null })).toBe(false);
96
177
  });
97
178
 
98
179
  it('returns empty list for malformed payload', () => {
@@ -100,13 +181,23 @@ describe('twitter lists parser', () => {
100
181
  expect(parseListsManagement({ data: {} }, new Set())).toEqual([]);
101
182
  });
102
183
 
103
- it('dedupes across repeated entries', () => {
104
- const entryA = { content: { itemContent: { list: { id_str: '1', name: 'A' } } } };
184
+ it('dedupes across repeated entries within owned-subscribed module', () => {
185
+ const itemA = {
186
+ entryId: 'owned-subscribed-list-module-0-list-1',
187
+ item: { itemContent: { list: { id_str: '1', name: 'A' } } },
188
+ };
105
189
  const payload = {
106
190
  data: {
107
191
  viewer: {
108
192
  list_management_timeline: {
109
- timeline: { instructions: [{ entries: [entryA, entryA] }] },
193
+ timeline: {
194
+ instructions: [{
195
+ entries: [{
196
+ entryId: 'owned-subscribed-list-module-0',
197
+ content: { items: [itemA, itemA] },
198
+ }],
199
+ }],
200
+ },
110
201
  },
111
202
  },
112
203
  },
@@ -114,4 +205,49 @@ describe('twitter lists parser', () => {
114
205
  const result = parseListsManagement(payload, new Set());
115
206
  expect(result).toHaveLength(1);
116
207
  });
208
+
209
+ it('fails malformed command payloads as parser drift instead of empty success', async () => {
210
+ const command = getRegistry().get('twitter/lists');
211
+ const page = {
212
+ getCookies: async () => [{ name: 'ct0', value: 'token' }],
213
+ evaluate: async (script) => {
214
+ if (script.includes('placeholder.json')) return null;
215
+ return { data: {} };
216
+ },
217
+ };
218
+
219
+ await expect(command.func(page, { limit: 10 })).rejects.toBeInstanceOf(CommandExecutionError);
220
+ });
221
+
222
+ it('treats a recommendation-only timeline as a true empty result', async () => {
223
+ const command = getRegistry().get('twitter/lists');
224
+ const page = {
225
+ getCookies: async () => [{ name: 'ct0', value: 'token' }],
226
+ evaluate: async (script) => {
227
+ if (script.includes('placeholder.json')) return null;
228
+ return {
229
+ data: {
230
+ viewer: {
231
+ list_management_timeline: {
232
+ timeline: {
233
+ instructions: [{
234
+ entries: [{
235
+ entryId: 'list-to-follow-module-1',
236
+ content: {
237
+ items: [{
238
+ item: { itemContent: { list: { id_str: '9', name: 'Recommended' } } },
239
+ }],
240
+ },
241
+ }],
242
+ }],
243
+ },
244
+ },
245
+ },
246
+ },
247
+ };
248
+ },
249
+ };
250
+
251
+ await expect(command.func(page, { limit: 10 })).rejects.toBeInstanceOf(EmptyResultError);
252
+ });
117
253
  });