@jackwener/opencli 1.7.21 → 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 (238) hide show
  1. package/README.md +31 -148
  2. package/README.zh-CN.md +38 -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/boss/utils.js +17 -1
  24. package/clis/boss/utils.test.js +34 -0
  25. package/clis/chatgpt/envelope.test.js +108 -0
  26. package/clis/chatgpt/image.js +2 -2
  27. package/clis/chatgpt/image.test.js +6 -0
  28. package/clis/chatgpt/utils.js +148 -41
  29. package/clis/chatgpt/utils.test.js +92 -2
  30. package/clis/douyin/_shared/browser-fetch.js +44 -20
  31. package/clis/douyin/_shared/browser-fetch.test.js +22 -1
  32. package/clis/douyin/_shared/evaluate-result.js +16 -0
  33. package/clis/douyin/_shared/tos-upload.js +105 -69
  34. package/clis/douyin/_shared/vod-upload.js +212 -0
  35. package/clis/douyin/_shared/vod-upload.test.js +38 -0
  36. package/clis/douyin/delete.js +137 -4
  37. package/clis/douyin/delete.test.js +90 -1
  38. package/clis/douyin/publish-upload-id.test.js +170 -0
  39. package/clis/douyin/publish.js +88 -42
  40. package/clis/douyin/user-videos.js +9 -2
  41. package/clis/douyin/user-videos.test.js +43 -0
  42. package/clis/flomo/memos.js +228 -0
  43. package/clis/flomo/memos.test.js +144 -0
  44. package/clis/gitee/search.js +2 -2
  45. package/clis/gitee/search.test.js +65 -0
  46. package/clis/jike/post.js +27 -17
  47. package/clis/jike/read.test.js +86 -0
  48. package/clis/jike/topic.js +32 -19
  49. package/clis/jike/user.js +33 -20
  50. package/clis/lesswrong/comments.js +1 -1
  51. package/clis/lesswrong/curated.js +1 -1
  52. package/clis/lesswrong/frontpage.js +1 -1
  53. package/clis/lesswrong/frontpage.test.js +37 -0
  54. package/clis/lesswrong/new.js +1 -1
  55. package/clis/lesswrong/read.js +1 -1
  56. package/clis/lesswrong/sequences.js +1 -1
  57. package/clis/lesswrong/shortform.js +1 -1
  58. package/clis/lesswrong/tag.js +1 -1
  59. package/clis/lesswrong/top-month.js +1 -1
  60. package/clis/lesswrong/top-week.js +1 -1
  61. package/clis/lesswrong/top-year.js +1 -1
  62. package/clis/lesswrong/top.js +1 -1
  63. package/clis/linkedin/connect.js +401 -0
  64. package/clis/linkedin/connect.test.js +213 -0
  65. package/clis/linkedin/inbox.js +234 -0
  66. package/clis/linkedin/inbox.test.js +152 -0
  67. package/clis/linkedin/people-search.js +262 -0
  68. package/clis/linkedin/people-search.test.js +216 -0
  69. package/clis/linkedin/safe-send.js +357 -0
  70. package/clis/linkedin/safe-send.test.js +204 -0
  71. package/clis/linkedin/salesnav-inbox.js +210 -0
  72. package/clis/linkedin/salesnav-inbox.test.js +113 -0
  73. package/clis/linkedin/salesnav-message.js +360 -0
  74. package/clis/linkedin/salesnav-message.test.js +172 -0
  75. package/clis/linkedin/salesnav-search.js +186 -0
  76. package/clis/linkedin/salesnav-search.test.js +76 -0
  77. package/clis/linkedin/salesnav-thread.js +212 -0
  78. package/clis/linkedin/salesnav-thread.test.js +79 -0
  79. package/clis/linkedin/sent-invitations.js +92 -0
  80. package/clis/linkedin/sent-invitations.test.js +62 -0
  81. package/clis/linkedin/thread-snapshot.js +214 -0
  82. package/clis/linkedin/thread-snapshot.test.js +89 -0
  83. package/clis/linkedin-learning/course.js +138 -0
  84. package/clis/linkedin-learning/course.test.js +114 -0
  85. package/clis/linkedin-learning/search.js +155 -0
  86. package/clis/linkedin-learning/search.test.js +144 -0
  87. package/clis/linkedin-learning/trending.js +133 -0
  88. package/clis/linkedin-learning/trending.test.js +123 -0
  89. package/clis/powerchina/search.js +3 -3
  90. package/clis/powerchina/search.test.js +27 -1
  91. package/clis/reddit/extract-media.test.js +149 -0
  92. package/clis/reddit/frontpage.js +47 -9
  93. package/clis/reddit/frontpage.test.js +34 -0
  94. package/clis/reddit/home.js +31 -1
  95. package/clis/reddit/home.test.js +46 -3
  96. package/clis/reddit/hot.js +32 -1
  97. package/clis/reddit/hot.test.js +15 -1
  98. package/clis/reddit/popular.js +39 -1
  99. package/clis/reddit/popular.test.js +26 -0
  100. package/clis/reddit/saved.js +1 -1
  101. package/clis/reddit/search.js +38 -1
  102. package/clis/reddit/search.test.js +26 -0
  103. package/clis/reddit/subreddit.js +52 -7
  104. package/clis/reddit/subreddit.test.js +31 -0
  105. package/clis/reddit/subscribed.js +165 -0
  106. package/clis/reddit/subscribed.test.js +168 -0
  107. package/clis/reddit/upvoted.js +1 -1
  108. package/clis/suno/commands.test.js +188 -0
  109. package/clis/suno/download.js +140 -0
  110. package/clis/suno/download.test.js +151 -0
  111. package/clis/suno/generate.js +226 -0
  112. package/clis/suno/generate.test.js +243 -0
  113. package/clis/suno/list.js +79 -0
  114. package/clis/suno/status.js +62 -0
  115. package/clis/suno/utils.js +540 -0
  116. package/clis/suno/utils.test.js +223 -0
  117. package/clis/twitter/device-follow.js +193 -0
  118. package/clis/twitter/device-follow.test.js +287 -0
  119. package/clis/twitter/download.js +443 -73
  120. package/clis/twitter/download.test.js +457 -0
  121. package/clis/twitter/list-create.js +155 -0
  122. package/clis/twitter/list-create.test.js +169 -0
  123. package/clis/twitter/list-remove.js +12 -5
  124. package/clis/twitter/list-remove.test.js +74 -0
  125. package/clis/twitter/list-tweets.js +6 -2
  126. package/clis/twitter/list-tweets.test.js +41 -1
  127. package/clis/twitter/lists.js +31 -4
  128. package/clis/twitter/lists.test.js +152 -16
  129. package/clis/twitter/search.js +6 -2
  130. package/clis/twitter/search.test.js +6 -0
  131. package/clis/twitter/shared.js +144 -0
  132. package/clis/twitter/shared.test.js +429 -1
  133. package/clis/twitter/thread.js +10 -2
  134. package/clis/twitter/thread.test.js +58 -0
  135. package/clis/twitter/timeline.js +6 -2
  136. package/clis/twitter/timeline.test.js +2 -0
  137. package/clis/twitter/tweets.js +3 -2
  138. package/clis/twitter/tweets.test.js +1 -1
  139. package/clis/weibo/comments.js +3 -4
  140. package/clis/weibo/delete.js +172 -0
  141. package/clis/weibo/delete.test.js +94 -0
  142. package/clis/weibo/envelope.test.js +85 -0
  143. package/clis/weibo/favorites.js +4 -4
  144. package/clis/weibo/feed.js +3 -5
  145. package/clis/weibo/hot.js +3 -4
  146. package/clis/weibo/me.js +3 -5
  147. package/clis/weibo/post.js +3 -4
  148. package/clis/weibo/publish.js +37 -14
  149. package/clis/weibo/publish.test.js +14 -5
  150. package/clis/weibo/search.js +4 -3
  151. package/clis/weibo/user-posts.js +234 -0
  152. package/clis/weibo/user-posts.test.js +92 -0
  153. package/clis/weibo/user.js +3 -4
  154. package/clis/weibo/utils.js +34 -5
  155. package/clis/weibo/utils.test.js +36 -0
  156. package/clis/weread/search-regression.test.js +18 -11
  157. package/clis/weread/search.js +15 -7
  158. package/clis/weread-official/book.js +135 -0
  159. package/clis/weread-official/commands.test.js +385 -0
  160. package/clis/weread-official/discover.js +107 -0
  161. package/clis/weread-official/list-apis.js +95 -0
  162. package/clis/weread-official/notes.js +171 -0
  163. package/clis/weread-official/readdata.js +158 -0
  164. package/clis/weread-official/review.js +93 -0
  165. package/clis/weread-official/search.js +106 -0
  166. package/clis/weread-official/shelf.js +97 -0
  167. package/clis/weread-official/utils.js +293 -0
  168. package/clis/weread-official/utils.test.js +242 -0
  169. package/clis/wikipedia/trending.js +7 -3
  170. package/clis/wikipedia/trending.test.js +57 -0
  171. package/clis/xianyu/chat.js +24 -109
  172. package/clis/xianyu/chat.test.js +5 -0
  173. package/clis/xianyu/im.js +322 -0
  174. package/clis/xianyu/im.test.js +253 -0
  175. package/clis/xianyu/inbox.js +96 -0
  176. package/clis/xianyu/messages.js +91 -0
  177. package/clis/xianyu/reply.js +82 -0
  178. package/clis/xiaohongshu/creator-note-detail.js +2 -1
  179. package/clis/xiaohongshu/creator-note-detail.test.js +11 -0
  180. package/clis/xiaohongshu/creator-notes-summary.js +2 -1
  181. package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
  182. package/clis/xiaohongshu/creator-notes.js +2 -1
  183. package/clis/xiaohongshu/creator-notes.test.js +12 -0
  184. package/clis/xiaohongshu/creator-stats.js +2 -1
  185. package/clis/xiaohongshu/creator-stats.test.js +24 -0
  186. package/clis/xiaohongshu/delete-note.js +260 -0
  187. package/clis/xiaohongshu/delete-note.test.js +172 -0
  188. package/clis/xiaohongshu/publish.js +48 -8
  189. package/clis/xiaohongshu/publish.test.js +65 -10
  190. package/clis/xiaohongshu/user-helpers.test.js +41 -0
  191. package/clis/xiaohongshu/user.js +27 -4
  192. package/clis/xiaoyuzhou/download.js +1 -1
  193. package/clis/xiaoyuzhou/transcript.js +1 -1
  194. package/clis/youdao/note.js +258 -0
  195. package/clis/youdao/note.test.js +99 -0
  196. package/clis/youtube/transcript.js +397 -24
  197. package/clis/youtube/transcript.test.js +196 -6
  198. package/clis/zhihu/answer-comments.js +299 -0
  199. package/clis/zhihu/answer-comments.test.js +287 -0
  200. package/clis/zhihu/answer-detail.js +12 -0
  201. package/clis/zhihu/answer-detail.test.js +8 -0
  202. package/clis/zhihu/collection.js +15 -2
  203. package/clis/zhihu/collection.test.js +46 -0
  204. package/clis/zhihu/download.js +1 -1
  205. package/clis/zhihu/question.js +42 -9
  206. package/clis/zhihu/question.test.js +111 -9
  207. package/clis/zhihu/search.js +206 -43
  208. package/clis/zhihu/search.test.js +198 -0
  209. package/dist/src/browser/errors.js +4 -2
  210. package/dist/src/browser/errors.test.js +6 -0
  211. package/dist/src/browser/page.js +30 -4
  212. package/dist/src/browser/page.test.js +42 -0
  213. package/dist/src/browser/utils.d.ts +1 -1
  214. package/dist/src/cli-argv-preprocess.d.ts +26 -0
  215. package/dist/src/cli-argv-preprocess.js +138 -0
  216. package/dist/src/cli-argv-preprocess.test.js +79 -0
  217. package/dist/src/cli.js +1 -1
  218. package/dist/src/convention-audit.js +15 -8
  219. package/dist/src/convention-audit.test.js +21 -0
  220. package/dist/src/download/media-download.js +15 -2
  221. package/dist/src/download/media-download.test.d.ts +1 -0
  222. package/dist/src/download/media-download.test.js +110 -0
  223. package/dist/src/electron-apps.js +1 -1
  224. package/dist/src/electron-apps.test.js +7 -2
  225. package/dist/src/errors.d.ts +17 -0
  226. package/dist/src/errors.js +22 -0
  227. package/dist/src/external-clis.yaml +20 -0
  228. package/dist/src/external.d.ts +6 -1
  229. package/dist/src/external.test.js +19 -0
  230. package/dist/src/main.js +14 -2
  231. package/dist/src/utils.d.ts +43 -0
  232. package/dist/src/utils.js +97 -0
  233. package/dist/src/utils.test.d.ts +1 -0
  234. package/dist/src/utils.test.js +155 -0
  235. package/package.json +8 -2
  236. package/scripts/silent-column-drop-baseline.json +0 -52
  237. package/scripts/typed-error-lint-baseline.json +28 -380
  238. package/clis/slock/_utils.js +0 -12
@@ -0,0 +1,223 @@
1
+ import * as os from 'node:os';
2
+ import * as path from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
5
+ import {
6
+ DEFAULT_FORMATS,
7
+ SUPPORTED_FORMATS,
8
+ SUNO_MODELS,
9
+ clampSlider,
10
+ normalizeBooleanFlag,
11
+ requireNonNegativeInt,
12
+ parseFormats,
13
+ requirePositiveInt,
14
+ resolveSunoOutputDir,
15
+ sanitizeTitleForFilename,
16
+ unwrapEvaluateResult,
17
+ pollSunoClips,
18
+ ensureSunoSession,
19
+ } from './utils.js';
20
+
21
+ describe('suno utils — parseFormats', () => {
22
+ it('returns the default format set when input is empty or missing', () => {
23
+ expect(parseFormats(undefined)).toEqual(DEFAULT_FORMATS);
24
+ expect(parseFormats(null)).toEqual(DEFAULT_FORMATS);
25
+ expect(parseFormats('')).toEqual(DEFAULT_FORMATS);
26
+ expect(parseFormats(' ')).toEqual(DEFAULT_FORMATS);
27
+ });
28
+
29
+ it('parses comma-separated input and trims whitespace', () => {
30
+ expect(parseFormats('mp3, wav, metadata')).toEqual(['mp3', 'wav', 'metadata']);
31
+ });
32
+
33
+ it('lowercases and deduplicates input', () => {
34
+ expect(parseFormats('MP3,Mp3,mp3,WAV')).toEqual(['mp3', 'wav']);
35
+ });
36
+
37
+ it('accepts array inputs (e.g. when caller passes pre-split values)', () => {
38
+ expect(parseFormats(['mp3', 'metadata'])).toEqual(['mp3', 'metadata']);
39
+ });
40
+
41
+ it('throws ArgumentError on unsupported format and lists the supported set', () => {
42
+ try {
43
+ parseFormats('mp3,flac');
44
+ throw new Error('should have thrown');
45
+ } catch (err) {
46
+ expect(err).toBeInstanceOf(ArgumentError);
47
+ expect(err.message).toContain('flac');
48
+ expect(err.hint).toContain(SUPPORTED_FORMATS.join(', '));
49
+ }
50
+ });
51
+ });
52
+
53
+ describe('suno utils — resolveSunoOutputDir', () => {
54
+ it('falls back to ~/Music/suno when no path is given', () => {
55
+ expect(resolveSunoOutputDir()).toBe(path.join(os.homedir(), 'Music', 'suno'));
56
+ expect(resolveSunoOutputDir('')).toBe(path.join(os.homedir(), 'Music', 'suno'));
57
+ });
58
+
59
+ it('expands ~ and ~/-prefixed relative paths to the home directory', () => {
60
+ expect(resolveSunoOutputDir('~')).toBe(os.homedir());
61
+ expect(resolveSunoOutputDir('~/Music/test')).toBe(path.join(os.homedir(), 'Music', 'test'));
62
+ });
63
+
64
+ it('absolute paths are returned as-is (resolved)', () => {
65
+ expect(resolveSunoOutputDir('/tmp/suno')).toBe('/tmp/suno');
66
+ });
67
+ });
68
+
69
+ describe('suno utils — sanitizeTitleForFilename', () => {
70
+ it('replaces filesystem-hostile characters with hyphens', () => {
71
+ expect(sanitizeTitleForFilename('foo/bar:baz?')).toBe('foo-bar-baz-');
72
+ });
73
+
74
+ it('collapses whitespace and trims', () => {
75
+ expect(sanitizeTitleForFilename(' hello world ')).toBe('hello world');
76
+ });
77
+
78
+ it('caps length at 60 characters', () => {
79
+ const long = 'a'.repeat(120);
80
+ expect(sanitizeTitleForFilename(long).length).toBe(60);
81
+ });
82
+
83
+ it('returns fallback for empty input', () => {
84
+ expect(sanitizeTitleForFilename('', 'untitled')).toBe('untitled');
85
+ expect(sanitizeTitleForFilename(null, 'fallback')).toBe('fallback');
86
+ });
87
+ });
88
+
89
+ describe('suno utils — clampSlider', () => {
90
+ it('returns the default when input is missing', () => {
91
+ expect(clampSlider(undefined, '--weirdness', 0.5)).toBe(0.5);
92
+ expect(clampSlider('', '--weirdness', 0.5)).toBe(0.5);
93
+ expect(clampSlider(null, '--weirdness', 0.5)).toBe(0.5);
94
+ });
95
+
96
+ it('parses numeric strings and accepts 0..1', () => {
97
+ expect(clampSlider('0', '--x', 0.5)).toBe(0);
98
+ expect(clampSlider('0.74', '--x', 0.5)).toBe(0.74);
99
+ expect(clampSlider('1', '--x', 0.5)).toBe(1);
100
+ });
101
+
102
+ it('rejects out-of-range or non-numeric values', () => {
103
+ expect(() => clampSlider('1.5', '--x', 0.5)).toThrowError(ArgumentError);
104
+ expect(() => clampSlider('-0.1', '--x', 0.5)).toThrowError(ArgumentError);
105
+ expect(() => clampSlider('hello', '--x', 0.5)).toThrowError(ArgumentError);
106
+ });
107
+ });
108
+
109
+ describe('suno utils — normalizeBooleanFlag', () => {
110
+ it('treats the canonical true-ish strings as true', () => {
111
+ for (const v of ['true', '1', 'yes', 'on', 'TRUE', 'On']) {
112
+ expect(normalizeBooleanFlag(v)).toBe(true);
113
+ }
114
+ });
115
+
116
+ it('treats unset / empty / unrecognized values as the fallback', () => {
117
+ expect(normalizeBooleanFlag(undefined)).toBe(false);
118
+ expect(normalizeBooleanFlag('', true)).toBe(true);
119
+ expect(normalizeBooleanFlag('maybe')).toBe(false);
120
+ });
121
+
122
+ it('passes through actual booleans', () => {
123
+ expect(normalizeBooleanFlag(true)).toBe(true);
124
+ expect(normalizeBooleanFlag(false)).toBe(false);
125
+ });
126
+ });
127
+
128
+ describe('suno utils — requirePositiveInt', () => {
129
+ it('returns positive integers as numbers', () => {
130
+ expect(requirePositiveInt(5, '--limit')).toBe(5);
131
+ expect(requirePositiveInt('10', '--limit')).toBe(10);
132
+ });
133
+
134
+ it('rejects zero, negative, and non-integer values', () => {
135
+ expect(() => requirePositiveInt(0, '--limit')).toThrowError(ArgumentError);
136
+ expect(() => requirePositiveInt(-3, '--limit')).toThrowError(ArgumentError);
137
+ expect(() => requirePositiveInt(1.5, '--limit')).toThrowError(ArgumentError);
138
+ expect(() => requirePositiveInt('not a number', '--limit')).toThrowError(ArgumentError);
139
+ });
140
+ });
141
+
142
+ describe('suno utils — requireNonNegativeInt', () => {
143
+ it('returns zero and positive integers as numbers', () => {
144
+ expect(requireNonNegativeInt(0, '--page')).toBe(0);
145
+ expect(requireNonNegativeInt('3', '--page')).toBe(3);
146
+ });
147
+
148
+ it('rejects negative or non-integer values', () => {
149
+ expect(() => requireNonNegativeInt(-1, '--page')).toThrowError(ArgumentError);
150
+ expect(() => requireNonNegativeInt(1.5, '--page')).toThrowError(ArgumentError);
151
+ expect(() => requireNonNegativeInt('nope', '--page')).toThrowError(ArgumentError);
152
+ });
153
+ });
154
+
155
+ describe('suno utils — unwrapEvaluateResult', () => {
156
+ it('unwraps Browser Bridge envelopes at evaluate boundaries', () => {
157
+ const payload = { ok: true, clips: [] };
158
+ expect(unwrapEvaluateResult({ session: 'browser:default', data: payload })).toBe(payload);
159
+ expect(unwrapEvaluateResult(payload)).toBe(payload);
160
+ });
161
+ });
162
+
163
+ describe('suno utils — ensureSunoSession typed failures', () => {
164
+ function createSessionPage(sessionCheckResult) {
165
+ const evaluate = async (script) => {
166
+ if (script.includes('querySelectorAll')) return undefined;
167
+ if (script.includes('!!(window.Clerk && window.Clerk.session)')) return true;
168
+ if (script.includes('suno_device_id')) return 'device-id';
169
+ if (script.includes('/api/billing/info/')) return sessionCheckResult;
170
+ throw new Error(`unexpected evaluate script: ${script.slice(0, 80)}`);
171
+ };
172
+ return {
173
+ goto: async () => undefined,
174
+ wait: async () => undefined,
175
+ evaluate,
176
+ };
177
+ }
178
+
179
+ it('maps explicit logged-out session state to AuthRequiredError', async () => {
180
+ await expect(ensureSunoSession(createSessionPage({
181
+ ok: false,
182
+ auth: true,
183
+ error: 'Clerk session unavailable',
184
+ }))).rejects.toThrowError(AuthRequiredError);
185
+ });
186
+
187
+ it('does not classify billing/parser drift as logged out', async () => {
188
+ await expect(ensureSunoSession(createSessionPage({
189
+ ok: false,
190
+ error: 'Malformed billing/info JSON: Unexpected token <',
191
+ }))).rejects.toThrowError(CommandExecutionError);
192
+ });
193
+ });
194
+
195
+ describe('suno utils — model + format exports', () => {
196
+ it('exposes the four shipping models with chirp-fenix first', () => {
197
+ expect(SUNO_MODELS).toContain('chirp-fenix');
198
+ expect(SUNO_MODELS).toContain('chirp-bluejay');
199
+ expect(SUNO_MODELS[0]).toBe('chirp-fenix');
200
+ });
201
+
202
+ it('declares mp3 + metadata as the default download set', () => {
203
+ expect(DEFAULT_FORMATS).toEqual(['mp3', 'metadata']);
204
+ });
205
+ });
206
+
207
+ describe('suno utils — pollSunoClips', () => {
208
+ it('fails typed on malformed feed JSON while polling generation status', async () => {
209
+ const page = {
210
+ evaluate: async () => ({ status: 200, body: null }),
211
+ wait: async () => {},
212
+ };
213
+ await expect(pollSunoClips(page, ['clip-a'], 1, 'device-id', 0)).rejects.toThrowError(CommandExecutionError);
214
+ });
215
+
216
+ it('fails typed on non-auth HTTP feed failures while polling generation status', async () => {
217
+ const page = {
218
+ evaluate: async () => ({ status: 500, body: { clips: [] } }),
219
+ wait: async () => {},
220
+ };
221
+ await expect(pollSunoClips(page, ['clip-a'], 1, 'device-id', 0)).rejects.toThrowError(CommandExecutionError);
222
+ });
223
+ });
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Twitter `/i/timeline` device-follow notification stream, i.e. the
3
+ * curated tweet list aggregated under a bell-icon "new posts from
4
+ * @userA and N others" notification. Direct GET /i/timeline redirects
5
+ * to /home; the data is only reachable via the legacy v1.1 REST
6
+ * endpoint `/i/api/2/notifications/device_follow.json`.
7
+ *
8
+ * Endpoint discovery and field-mapping originally proposed by @traddo
9
+ * in issue #1628.
10
+ */
11
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
12
+ import { cli, Strategy } from '@jackwener/opencli/registry';
13
+ import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
14
+
15
+ const DEVICE_FOLLOW_PATH = '/i/api/2/notifications/device_follow.json';
16
+ const MAX_LIMIT = 200;
17
+
18
+ function parseLimit(value) {
19
+ if (value === undefined || value === null || value === '') return 20;
20
+ const limit = Number(value);
21
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
22
+ throw new ArgumentError(`--limit must be an integer between 1 and ${MAX_LIMIT}`);
23
+ }
24
+ return limit;
25
+ }
26
+
27
+ function buildDeviceFollowUrl(count) {
28
+ const params = new URLSearchParams({
29
+ include_profile_interstitial_type: '1',
30
+ include_blocking: '1',
31
+ include_blocked_by: '1',
32
+ include_followed_by: '1',
33
+ include_want_retweets: '1',
34
+ include_mute_edge: '1',
35
+ include_can_dm: '1',
36
+ include_can_media_tag: '1',
37
+ include_ext_has_nft_avatar: '1',
38
+ include_ext_is_blue_verified: '1',
39
+ include_ext_verified_type: '1',
40
+ skip_status: '1',
41
+ cards_platform: 'Web-12',
42
+ include_cards: '1',
43
+ include_ext_alt_text: 'true',
44
+ include_quote_count: 'true',
45
+ include_reply_count: '1',
46
+ tweet_mode: 'extended',
47
+ include_ext_views: 'true',
48
+ count: String(count),
49
+ });
50
+ return `${DEVICE_FOLLOW_PATH}?${params.toString()}`;
51
+ }
52
+
53
+ function extractEntries(timeline) {
54
+ if (!timeline || !Array.isArray(timeline.instructions)) return null;
55
+ const out = [];
56
+ for (const inst of timeline.instructions) {
57
+ const entries = inst?.addEntries?.entries;
58
+ if (Array.isArray(entries)) out.push(...entries);
59
+ }
60
+ return out;
61
+ }
62
+
63
+ function joinEntryToTweet(entry, tweets, users) {
64
+ const tweetId = entry?.content?.item?.content?.tweet?.id;
65
+ if (!tweetId) return null;
66
+ const tw = tweets?.[tweetId];
67
+ if (!tw) return null;
68
+ const user = users?.[tw.user_id_str] || null;
69
+ if (typeof user?.screen_name !== 'string' || !user.screen_name) return null;
70
+ return { tweetId, tweet: tw, user };
71
+ }
72
+
73
+ function shapeRow({ tweetId, tweet, user }) {
74
+ const screenName = user.screen_name;
75
+ return {
76
+ id: tweetId,
77
+ author: screenName,
78
+ text: tweet?.full_text || tweet?.text || '',
79
+ likes: tweet?.favorite_count ?? 0,
80
+ retweets: tweet?.retweet_count ?? 0,
81
+ replies: tweet?.reply_count ?? 0,
82
+ // The legacy v1.1 endpoint does not return view counts even with
83
+ // include_ext_views=true; surface null rather than a 0 sentinel
84
+ // that would lie about real engagement (typed-errors §3).
85
+ views: null,
86
+ created_at: tweet?.created_at || '',
87
+ url: `https://x.com/${screenName}/status/${tweetId}`,
88
+ };
89
+ }
90
+
91
+ function parseDeviceFollow(payload, seen) {
92
+ if (!payload?.globalObjects || typeof payload.globalObjects !== 'object') return null;
93
+ const tweets = payload?.globalObjects?.tweets || {};
94
+ const users = payload?.globalObjects?.users || {};
95
+ if (typeof tweets !== 'object' || typeof users !== 'object') return null;
96
+ const entries = extractEntries(payload?.timeline);
97
+ if (!entries) return null;
98
+ const rows = [];
99
+ let unmatchedTweetEntries = 0;
100
+ let malformedEntries = 0;
101
+ for (const entry of entries) {
102
+ const hasTweetEntry = Boolean(entry?.content?.item?.content?.tweet?.id);
103
+ if (!hasTweetEntry) {
104
+ malformedEntries++;
105
+ continue;
106
+ }
107
+ const joined = joinEntryToTweet(entry, tweets, users);
108
+ if (!joined) {
109
+ unmatchedTweetEntries++;
110
+ continue;
111
+ }
112
+ if (seen.has(joined.tweetId)) continue;
113
+ seen.add(joined.tweetId);
114
+ rows.push(shapeRow(joined));
115
+ }
116
+ return { rows, entryCount: entries.length, unmatchedTweetEntries, malformedEntries };
117
+ }
118
+
119
+ cli({
120
+ site: 'twitter',
121
+ name: 'device-follow',
122
+ access: 'read',
123
+ description: 'Read the /i/timeline device-follow notification stream (tweets aggregated under a bell-icon "new posts from @userA and N others" notification)',
124
+ domain: 'x.com',
125
+ strategy: Strategy.COOKIE,
126
+ browser: true,
127
+ args: [
128
+ { name: 'limit', type: 'int', default: 20, help: `Maximum number of tweets to return (1-${MAX_LIMIT}, default 20)` },
129
+ { name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank by weighted engagement and return the top N. Default 0 keeps upstream ordering.' },
130
+ ],
131
+ columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'views', 'created_at', 'url'],
132
+ func: async (page, kwargs) => {
133
+ const limit = parseLimit(kwargs.limit);
134
+ const cookies = await page.getCookies({ url: 'https://x.com' });
135
+ const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
136
+ if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
137
+
138
+ const apiUrl = buildDeviceFollowUrl(limit);
139
+ const headers = JSON.stringify({
140
+ Authorization: `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
141
+ 'X-Csrf-Token': ct0,
142
+ 'X-Twitter-Auth-Type': 'OAuth2Session',
143
+ 'X-Twitter-Active-User': 'yes',
144
+ });
145
+ const data = await page.evaluate(`async () => {
146
+ try {
147
+ const r = await fetch("${apiUrl}", { method: "GET", headers: ${headers}, credentials: 'include' });
148
+ if (!r.ok) return { error: r.status };
149
+ try {
150
+ return await r.json();
151
+ } catch (e) {
152
+ return { errorKind: 'non_json', detail: String(e && e.message || e) };
153
+ }
154
+ } catch (e) {
155
+ return { errorKind: 'exception', detail: String(e && e.message || e) };
156
+ }
157
+ }`);
158
+ if (data?.errorKind === 'non_json') {
159
+ throw new CommandExecutionError(`Twitter device-follow returned non-JSON response: ${data.detail || 'unknown parse error'}`);
160
+ }
161
+ if (data?.errorKind === 'exception') {
162
+ throw new CommandExecutionError(`Twitter device-follow fetch failed: ${data.detail || 'unknown error'}`);
163
+ }
164
+ if (data?.error) {
165
+ if (data.error === 401 || data.error === 403) {
166
+ throw new AuthRequiredError('x.com', `Twitter device-follow returned HTTP ${data.error}`);
167
+ }
168
+ throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch device-follow notification stream.`);
169
+ }
170
+ const parsed = parseDeviceFollow(data, new Set());
171
+ if (!parsed) {
172
+ throw new CommandExecutionError('Twitter device-follow response was missing the expected timeline/globalObjects shape.');
173
+ }
174
+ if (parsed.malformedEntries > 0 || parsed.unmatchedTweetEntries > 0) {
175
+ throw new CommandExecutionError('Twitter device-follow entries could not be joined to tweet/user objects.');
176
+ }
177
+ if (parsed.rows.length === 0) {
178
+ throw new EmptyResultError('twitter device-follow', 'No device-follow notification tweets found.');
179
+ }
180
+ const rows = parsed.rows;
181
+ const trimmed = rows.slice(0, limit);
182
+ return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
183
+ },
184
+ });
185
+
186
+ export const __test__ = {
187
+ buildDeviceFollowUrl,
188
+ extractEntries,
189
+ joinEntryToTweet,
190
+ shapeRow,
191
+ parseDeviceFollow,
192
+ parseLimit,
193
+ };