@jackwener/opencli 1.7.22 → 1.8.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.
- package/README.md +35 -194
- package/README.zh-CN.md +42 -260
- package/cli-manifest.json +8160 -4392
- package/clis/12306/me.js +73 -0
- package/clis/12306/orders.js +96 -0
- package/clis/12306/passengers.js +90 -0
- package/clis/12306/price.js +166 -0
- package/clis/12306/stations.js +66 -0
- package/clis/12306/train.js +91 -0
- package/clis/12306/trains.js +119 -0
- package/clis/12306/utils.js +272 -0
- package/clis/12306/utils.test.js +331 -0
- package/clis/36kr/article.js +6 -3
- package/clis/36kr/article.test.js +46 -0
- package/clis/_atlassian/shared.js +577 -0
- package/clis/_atlassian/shared.test.js +170 -0
- package/clis/apple-podcasts/commands.test.js +20 -0
- package/clis/apple-podcasts/search.js +2 -2
- package/clis/barchart/greeks.js +144 -56
- package/clis/barchart/greeks.test.js +138 -0
- package/clis/bilibili/comment.js +125 -0
- package/clis/bilibili/comment.test.js +153 -0
- package/clis/bilibili/comments.js +116 -21
- package/clis/bilibili/comments.test.js +77 -18
- package/clis/bilibili/subtitle.js +76 -31
- package/clis/bilibili/subtitle.test.js +156 -9
- package/clis/bilibili/summary.js +167 -0
- package/clis/bilibili/summary.test.js +210 -0
- package/clis/bilibili/utils.js +63 -5
- package/clis/bilibili/utils.test.js +45 -1
- package/clis/booking/booking.test.js +356 -0
- package/clis/booking/search.js +351 -0
- package/clis/chatgpt/envelope.test.js +108 -0
- package/clis/chatgpt/image.js +2 -2
- package/clis/chatgpt/image.test.js +6 -0
- package/clis/chatgpt/utils.js +148 -41
- package/clis/chatgpt/utils.test.js +92 -2
- package/clis/chess/analyze.js +35 -0
- package/clis/chess/analyze.test.js +79 -0
- package/clis/chess/game.js +114 -0
- package/clis/chess/game.test.js +178 -0
- package/clis/chess/games.js +67 -0
- package/clis/chess/games.test.js +164 -0
- package/clis/chess/stats.js +32 -0
- package/clis/chess/stats.test.js +79 -0
- package/clis/chess/utils.js +170 -0
- package/clis/chess/utils.test.js +230 -0
- package/clis/confluence/commands.test.js +195 -0
- package/clis/confluence/create.js +39 -0
- package/clis/confluence/page.js +23 -0
- package/clis/confluence/search.js +34 -0
- package/clis/confluence/shared.js +173 -0
- package/clis/confluence/update.js +38 -0
- package/clis/douyin/_shared/browser-fetch.js +44 -20
- package/clis/douyin/_shared/browser-fetch.test.js +22 -1
- package/clis/douyin/_shared/evaluate-result.js +16 -0
- package/clis/douyin/_shared/tos-upload.js +105 -69
- package/clis/douyin/_shared/vod-upload.js +212 -0
- package/clis/douyin/_shared/vod-upload.test.js +38 -0
- package/clis/douyin/delete.js +137 -4
- package/clis/douyin/delete.test.js +90 -1
- package/clis/douyin/hashtag.js +84 -23
- package/clis/douyin/hashtag.test.js +113 -0
- package/clis/douyin/publish-upload-id.test.js +170 -0
- package/clis/douyin/publish.js +88 -42
- package/clis/douyin/user-videos.js +9 -2
- package/clis/douyin/user-videos.test.js +43 -0
- package/clis/flomo/memos.js +228 -0
- package/clis/flomo/memos.test.js +144 -0
- package/clis/geogebra/add-circle.js +46 -0
- package/clis/geogebra/add-line.js +35 -0
- package/clis/geogebra/add-point.js +27 -0
- package/clis/geogebra/add-polygon.js +25 -0
- package/clis/geogebra/eval.js +35 -0
- package/clis/geogebra/geogebra.test.js +175 -0
- package/clis/geogebra/hexagon.js +62 -0
- package/clis/geogebra/info.js +72 -0
- package/clis/geogebra/list.js +35 -0
- package/clis/geogebra/triangle.js +60 -0
- package/clis/geogebra/utils.js +271 -0
- package/clis/gitee/search.js +2 -2
- package/clis/gitee/search.test.js +65 -0
- package/clis/jike/post.js +27 -17
- package/clis/jike/read.test.js +86 -0
- package/clis/jike/topic.js +32 -19
- package/clis/jike/user.js +33 -20
- package/clis/jira/attachments.js +28 -0
- package/clis/jira/commands.test.js +287 -0
- package/clis/jira/comments.js +28 -0
- package/clis/jira/issue.js +28 -0
- package/clis/jira/links.js +28 -0
- package/clis/jira/search.js +47 -0
- package/clis/jira/shared.js +256 -0
- package/clis/lesswrong/comments.js +1 -1
- package/clis/lesswrong/curated.js +1 -1
- package/clis/lesswrong/frontpage.js +1 -1
- package/clis/lesswrong/frontpage.test.js +37 -0
- package/clis/lesswrong/new.js +1 -1
- package/clis/lesswrong/read.js +1 -1
- package/clis/lesswrong/sequences.js +1 -1
- package/clis/lesswrong/shortform.js +1 -1
- package/clis/lesswrong/tag.js +1 -1
- package/clis/lesswrong/top-month.js +1 -1
- package/clis/lesswrong/top-week.js +1 -1
- package/clis/lesswrong/top-year.js +1 -1
- package/clis/lesswrong/top.js +1 -1
- package/clis/linkedin/connect.js +401 -0
- package/clis/linkedin/connect.test.js +213 -0
- package/clis/linkedin/inbox.js +234 -0
- package/clis/linkedin/inbox.test.js +152 -0
- package/clis/linkedin/job-detail.js +167 -0
- package/clis/linkedin/job-detail.test.js +38 -0
- package/clis/linkedin/jobs-preferences.js +113 -0
- package/clis/linkedin/jobs-preferences.test.js +43 -0
- package/clis/linkedin/people-search.js +262 -0
- package/clis/linkedin/people-search.test.js +216 -0
- package/clis/linkedin/post-analytics.js +74 -0
- package/clis/linkedin/post-analytics.test.js +40 -0
- package/clis/linkedin/posts-core.js +241 -0
- package/clis/linkedin/posts.js +22 -0
- package/clis/linkedin/posts.test.js +40 -0
- package/clis/linkedin/profile-analytics.js +104 -0
- package/clis/linkedin/profile-analytics.test.js +67 -0
- package/clis/linkedin/profile-experience.js +671 -0
- package/clis/linkedin/profile-experience.test.js +152 -0
- package/clis/linkedin/profile-projects.js +311 -0
- package/clis/linkedin/profile-projects.test.js +111 -0
- package/clis/linkedin/profile-read.js +148 -0
- package/clis/linkedin/profile-read.test.js +77 -0
- package/clis/linkedin/safe-send.js +357 -0
- package/clis/linkedin/safe-send.test.js +204 -0
- package/clis/linkedin/salesnav-inbox.js +210 -0
- package/clis/linkedin/salesnav-inbox.test.js +113 -0
- package/clis/linkedin/salesnav-message.js +360 -0
- package/clis/linkedin/salesnav-message.test.js +172 -0
- package/clis/linkedin/salesnav-search.js +186 -0
- package/clis/linkedin/salesnav-search.test.js +76 -0
- package/clis/linkedin/salesnav-thread.js +212 -0
- package/clis/linkedin/salesnav-thread.test.js +79 -0
- package/clis/linkedin/sent-invitations.js +92 -0
- package/clis/linkedin/sent-invitations.test.js +62 -0
- package/clis/linkedin/services-read.js +213 -0
- package/clis/linkedin/services-read.test.js +105 -0
- package/clis/linkedin/shared.js +124 -0
- package/clis/linkedin/thread-snapshot.js +214 -0
- package/clis/linkedin/thread-snapshot.test.js +89 -0
- package/clis/linkedin/timeline.js +14 -7
- package/clis/linkedin-learning/course.js +138 -0
- package/clis/linkedin-learning/course.test.js +114 -0
- package/clis/linkedin-learning/search.js +155 -0
- package/clis/linkedin-learning/search.test.js +144 -0
- package/clis/linkedin-learning/trending.js +133 -0
- package/clis/linkedin-learning/trending.test.js +123 -0
- package/clis/notebooklm/add-source.js +269 -0
- package/clis/notebooklm/add-source.test.js +97 -0
- package/clis/notebooklm/create.js +76 -0
- package/clis/notebooklm/create.test.js +58 -0
- package/clis/notebooklm/generate-audio.js +91 -0
- package/clis/notebooklm/generate-audio.test.js +63 -0
- package/clis/notebooklm/generate-slides.js +106 -0
- package/clis/notebooklm/generate-slides.test.js +75 -0
- package/clis/notebooklm/open.test.js +10 -10
- package/clis/notebooklm/rpc.js +20 -6
- package/clis/notebooklm/rpc.test.js +27 -1
- package/clis/notebooklm/utils.js +100 -24
- package/clis/notebooklm/utils.test.js +60 -1
- package/clis/notebooklm/write-note.js +103 -0
- package/clis/notebooklm/write-note.test.js +70 -0
- package/clis/pixiv/detail.js +41 -34
- package/clis/pixiv/detail.test.js +93 -0
- package/clis/pixiv/user.js +36 -31
- package/clis/pixiv/user.test.js +100 -0
- package/clis/pixiv/utils.js +56 -7
- package/clis/powerchina/search.js +3 -3
- package/clis/powerchina/search.test.js +27 -1
- package/clis/reddit/extract-media.test.js +149 -0
- package/clis/reddit/frontpage.js +47 -9
- package/clis/reddit/frontpage.test.js +34 -0
- package/clis/reddit/home.js +31 -1
- package/clis/reddit/home.test.js +46 -3
- package/clis/reddit/hot.js +32 -1
- package/clis/reddit/hot.test.js +15 -1
- package/clis/reddit/popular.js +39 -1
- package/clis/reddit/popular.test.js +26 -0
- package/clis/reddit/saved.js +1 -1
- package/clis/reddit/search.js +38 -1
- package/clis/reddit/search.test.js +26 -0
- package/clis/reddit/subreddit.js +52 -7
- package/clis/reddit/subreddit.test.js +31 -0
- package/clis/reddit/subscribed.js +165 -0
- package/clis/reddit/subscribed.test.js +168 -0
- package/clis/reddit/upvoted.js +1 -1
- package/clis/suno/commands.test.js +188 -0
- package/clis/suno/download.js +140 -0
- package/clis/suno/download.test.js +151 -0
- package/clis/suno/generate.js +231 -0
- package/clis/suno/generate.test.js +252 -0
- package/clis/suno/list.js +79 -0
- package/clis/suno/status.js +63 -0
- package/clis/suno/utils.js +549 -0
- package/clis/suno/utils.test.js +329 -0
- package/clis/twitter/device-follow.js +193 -0
- package/clis/twitter/device-follow.test.js +287 -0
- package/clis/twitter/download.js +443 -73
- package/clis/twitter/download.test.js +457 -0
- package/clis/twitter/followers.js +6 -2
- package/clis/twitter/followers.test.js +19 -1
- package/clis/twitter/following.js +14 -5
- package/clis/twitter/following.test.js +29 -0
- package/clis/twitter/likes.js +12 -4
- package/clis/twitter/likes.test.js +26 -1
- package/clis/twitter/list-add.js +1 -1
- package/clis/twitter/list-create.js +155 -0
- package/clis/twitter/list-create.test.js +169 -0
- package/clis/twitter/list-remove.js +13 -6
- package/clis/twitter/list-remove.test.js +74 -0
- package/clis/twitter/list-tweets.js +6 -2
- package/clis/twitter/list-tweets.test.js +41 -1
- package/clis/twitter/lists.js +31 -4
- package/clis/twitter/lists.test.js +152 -16
- package/clis/twitter/notifications.js +4 -4
- package/clis/twitter/post.js +62 -4
- package/clis/twitter/post.test.js +35 -3
- package/clis/twitter/profile.js +81 -28
- package/clis/twitter/profile.test.js +113 -2
- package/clis/twitter/quote.js +9 -4
- package/clis/twitter/reply.js +13 -10
- package/clis/twitter/reply.test.js +41 -0
- package/clis/twitter/search.js +7 -3
- package/clis/twitter/search.test.js +41 -0
- package/clis/twitter/shared.js +155 -0
- package/clis/twitter/shared.test.js +465 -1
- package/clis/twitter/thread.js +10 -2
- package/clis/twitter/thread.test.js +58 -0
- package/clis/twitter/timeline.js +6 -2
- package/clis/twitter/timeline.test.js +2 -0
- package/clis/twitter/tweets.js +3 -2
- package/clis/twitter/tweets.test.js +1 -1
- package/clis/twitter/utils.js +53 -16
- package/clis/upwork/detail.js +132 -0
- package/clis/upwork/feed.js +109 -0
- package/clis/upwork/search.js +115 -0
- package/clis/upwork/upwork.test.js +566 -0
- package/clis/upwork/utils.js +323 -0
- package/clis/weibo/delete.js +172 -0
- package/clis/weibo/delete.test.js +94 -0
- package/clis/weibo/publish.js +37 -14
- package/clis/weibo/publish.test.js +14 -5
- package/clis/weibo/user-posts.js +234 -0
- package/clis/weibo/user-posts.test.js +92 -0
- package/clis/weread/book-search.js +438 -0
- package/clis/weread/book-search.test.js +242 -0
- package/clis/weread/search-regression.test.js +98 -11
- package/clis/weread/search.js +32 -9
- package/clis/weread-official/book.js +135 -0
- package/clis/weread-official/commands.test.js +385 -0
- package/clis/weread-official/discover.js +107 -0
- package/clis/weread-official/list-apis.js +95 -0
- package/clis/weread-official/notes.js +171 -0
- package/clis/weread-official/readdata.js +158 -0
- package/clis/weread-official/review.js +93 -0
- package/clis/weread-official/search.js +106 -0
- package/clis/weread-official/shelf.js +97 -0
- package/clis/weread-official/utils.js +293 -0
- package/clis/weread-official/utils.test.js +242 -0
- package/clis/wikipedia/trending.js +7 -3
- package/clis/wikipedia/trending.test.js +57 -0
- package/clis/xianyu/chat.js +24 -109
- package/clis/xianyu/chat.test.js +5 -0
- package/clis/xianyu/im.js +322 -0
- package/clis/xianyu/im.test.js +253 -0
- package/clis/xianyu/inbox.js +96 -0
- package/clis/xianyu/messages.js +91 -0
- package/clis/xianyu/reply.js +82 -0
- package/clis/xiaohongshu/creator-note-detail.js +166 -28
- package/clis/xiaohongshu/creator-note-detail.test.js +196 -36
- package/clis/xiaohongshu/creator-notes-summary.js +2 -1
- package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
- package/clis/xiaohongshu/creator-notes.js +252 -2
- package/clis/xiaohongshu/creator-notes.test.js +90 -1
- package/clis/xiaohongshu/creator-stats.js +2 -1
- package/clis/xiaohongshu/creator-stats.test.js +24 -0
- package/clis/xiaohongshu/delete-note.js +260 -0
- package/clis/xiaohongshu/delete-note.test.js +172 -0
- package/clis/xiaohongshu/download.js +97 -39
- package/clis/xiaohongshu/download.test.js +201 -0
- package/clis/xiaohongshu/publish.js +48 -8
- package/clis/xiaohongshu/publish.test.js +65 -10
- package/clis/xiaohongshu/user-helpers.test.js +41 -0
- package/clis/xiaohongshu/user.js +27 -4
- package/clis/xiaoyuzhou/download.js +1 -1
- package/clis/xiaoyuzhou/transcript.js +1 -1
- package/clis/youdao/note.js +258 -0
- package/clis/youdao/note.test.js +99 -0
- package/clis/youtube/transcript.js +397 -24
- package/clis/youtube/transcript.test.js +196 -6
- package/clis/zhihu/answer-comments.js +280 -0
- package/clis/zhihu/answer-comments.test.js +287 -0
- package/clis/zhihu/answer-detail.js +2 -19
- package/clis/zhihu/answer-detail.test.js +8 -0
- package/clis/zhihu/collection.js +17 -16
- package/clis/zhihu/collection.test.js +50 -3
- package/clis/zhihu/download.js +1 -1
- package/clis/zhihu/question.js +42 -17
- package/clis/zhihu/question.test.js +113 -11
- package/clis/zhihu/search.js +195 -43
- package/clis/zhihu/search.test.js +198 -0
- package/clis/zhihu/text.js +29 -0
- package/clis/zhihu/text.test.js +24 -0
- package/dist/src/browser/errors.js +4 -2
- package/dist/src/browser/errors.test.js +6 -0
- package/dist/src/browser/network-cache.js +13 -1
- package/dist/src/browser/network-cache.test.js +17 -0
- package/dist/src/browser/page.js +30 -4
- package/dist/src/browser/page.test.js +42 -0
- package/dist/src/browser/utils.d.ts +1 -1
- package/dist/src/cli-argv-preprocess.d.ts +26 -0
- package/dist/src/cli-argv-preprocess.js +138 -0
- package/dist/src/cli-argv-preprocess.test.js +79 -0
- package/dist/src/convention-audit.js +15 -8
- package/dist/src/convention-audit.test.js +21 -0
- package/dist/src/download/index.js +13 -1
- package/dist/src/download/index.test.js +23 -1
- package/dist/src/download/media-download.js +15 -2
- package/dist/src/download/media-download.test.d.ts +1 -0
- package/dist/src/download/media-download.test.js +112 -0
- package/dist/src/download/progress.js +2 -2
- package/dist/src/download/progress.test.js +12 -1
- package/dist/src/electron-apps.js +1 -1
- package/dist/src/electron-apps.test.js +7 -2
- package/dist/src/errors.d.ts +17 -0
- package/dist/src/errors.js +22 -0
- package/dist/src/external-clis.yaml +8 -0
- package/dist/src/main.js +14 -2
- package/dist/src/output.js +11 -1
- package/dist/src/output.test.js +6 -0
- package/dist/src/registry.js +1 -0
- package/dist/src/registry.test.js +11 -0
- package/dist/src/utils.d.ts +43 -0
- package/dist/src/utils.js +97 -0
- package/dist/src/utils.test.d.ts +1 -0
- package/dist/src/utils.test.js +155 -0
- package/package.json +8 -2
- package/scripts/silent-column-drop-baseline.json +0 -52
- package/scripts/typed-error-lint-baseline.json +28 -380
- package/clis/slock/_utils.js +0 -12
|
@@ -0,0 +1,329 @@
|
|
|
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
|
+
parseSunoBillingInfo,
|
|
20
|
+
} from './utils.js';
|
|
21
|
+
|
|
22
|
+
describe('suno utils — parseFormats', () => {
|
|
23
|
+
it('returns the default format set when input is empty or missing', () => {
|
|
24
|
+
expect(parseFormats(undefined)).toEqual(DEFAULT_FORMATS);
|
|
25
|
+
expect(parseFormats(null)).toEqual(DEFAULT_FORMATS);
|
|
26
|
+
expect(parseFormats('')).toEqual(DEFAULT_FORMATS);
|
|
27
|
+
expect(parseFormats(' ')).toEqual(DEFAULT_FORMATS);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('parses comma-separated input and trims whitespace', () => {
|
|
31
|
+
expect(parseFormats('mp3, wav, metadata')).toEqual(['mp3', 'wav', 'metadata']);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('lowercases and deduplicates input', () => {
|
|
35
|
+
expect(parseFormats('MP3,Mp3,mp3,WAV')).toEqual(['mp3', 'wav']);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('accepts array inputs (e.g. when caller passes pre-split values)', () => {
|
|
39
|
+
expect(parseFormats(['mp3', 'metadata'])).toEqual(['mp3', 'metadata']);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('throws ArgumentError on unsupported format and lists the supported set', () => {
|
|
43
|
+
try {
|
|
44
|
+
parseFormats('mp3,flac');
|
|
45
|
+
throw new Error('should have thrown');
|
|
46
|
+
} catch (err) {
|
|
47
|
+
expect(err).toBeInstanceOf(ArgumentError);
|
|
48
|
+
expect(err.message).toContain('flac');
|
|
49
|
+
expect(err.hint).toContain(SUPPORTED_FORMATS.join(', '));
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('suno utils — resolveSunoOutputDir', () => {
|
|
55
|
+
it('falls back to ~/Music/suno when no path is given', () => {
|
|
56
|
+
expect(resolveSunoOutputDir()).toBe(path.join(os.homedir(), 'Music', 'suno'));
|
|
57
|
+
expect(resolveSunoOutputDir('')).toBe(path.join(os.homedir(), 'Music', 'suno'));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('expands ~ and ~/-prefixed relative paths to the home directory', () => {
|
|
61
|
+
expect(resolveSunoOutputDir('~')).toBe(os.homedir());
|
|
62
|
+
expect(resolveSunoOutputDir('~/Music/test')).toBe(path.join(os.homedir(), 'Music', 'test'));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('absolute paths are returned as-is (resolved)', () => {
|
|
66
|
+
expect(resolveSunoOutputDir('/tmp/suno')).toBe('/tmp/suno');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('suno utils — sanitizeTitleForFilename', () => {
|
|
71
|
+
it('replaces filesystem-hostile characters with hyphens', () => {
|
|
72
|
+
expect(sanitizeTitleForFilename('foo/bar:baz?')).toBe('foo-bar-baz-');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('collapses whitespace and trims', () => {
|
|
76
|
+
expect(sanitizeTitleForFilename(' hello world ')).toBe('hello world');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('caps length at 60 characters', () => {
|
|
80
|
+
const long = 'a'.repeat(120);
|
|
81
|
+
expect(sanitizeTitleForFilename(long).length).toBe(60);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('returns fallback for empty input', () => {
|
|
85
|
+
expect(sanitizeTitleForFilename('', 'untitled')).toBe('untitled');
|
|
86
|
+
expect(sanitizeTitleForFilename(null, 'fallback')).toBe('fallback');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('suno utils — clampSlider', () => {
|
|
91
|
+
it('returns the default when input is missing', () => {
|
|
92
|
+
expect(clampSlider(undefined, '--weirdness', 0.5)).toBe(0.5);
|
|
93
|
+
expect(clampSlider('', '--weirdness', 0.5)).toBe(0.5);
|
|
94
|
+
expect(clampSlider(null, '--weirdness', 0.5)).toBe(0.5);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('parses numeric strings and accepts 0..1', () => {
|
|
98
|
+
expect(clampSlider('0', '--x', 0.5)).toBe(0);
|
|
99
|
+
expect(clampSlider('0.74', '--x', 0.5)).toBe(0.74);
|
|
100
|
+
expect(clampSlider('1', '--x', 0.5)).toBe(1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('rejects out-of-range or non-numeric values', () => {
|
|
104
|
+
expect(() => clampSlider('1.5', '--x', 0.5)).toThrowError(ArgumentError);
|
|
105
|
+
expect(() => clampSlider('-0.1', '--x', 0.5)).toThrowError(ArgumentError);
|
|
106
|
+
expect(() => clampSlider('hello', '--x', 0.5)).toThrowError(ArgumentError);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('suno utils — normalizeBooleanFlag', () => {
|
|
111
|
+
it('treats the canonical true-ish strings as true', () => {
|
|
112
|
+
for (const v of ['true', '1', 'yes', 'on', 'TRUE', 'On']) {
|
|
113
|
+
expect(normalizeBooleanFlag(v)).toBe(true);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('treats unset / empty / unrecognized values as the fallback', () => {
|
|
118
|
+
expect(normalizeBooleanFlag(undefined)).toBe(false);
|
|
119
|
+
expect(normalizeBooleanFlag('', true)).toBe(true);
|
|
120
|
+
expect(normalizeBooleanFlag('maybe')).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('passes through actual booleans', () => {
|
|
124
|
+
expect(normalizeBooleanFlag(true)).toBe(true);
|
|
125
|
+
expect(normalizeBooleanFlag(false)).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('suno utils — requirePositiveInt', () => {
|
|
130
|
+
it('returns positive integers as numbers', () => {
|
|
131
|
+
expect(requirePositiveInt(5, '--limit')).toBe(5);
|
|
132
|
+
expect(requirePositiveInt('10', '--limit')).toBe(10);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('rejects zero, negative, and non-integer values', () => {
|
|
136
|
+
expect(() => requirePositiveInt(0, '--limit')).toThrowError(ArgumentError);
|
|
137
|
+
expect(() => requirePositiveInt(-3, '--limit')).toThrowError(ArgumentError);
|
|
138
|
+
expect(() => requirePositiveInt(1.5, '--limit')).toThrowError(ArgumentError);
|
|
139
|
+
expect(() => requirePositiveInt('not a number', '--limit')).toThrowError(ArgumentError);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('suno utils — requireNonNegativeInt', () => {
|
|
144
|
+
it('returns zero and positive integers as numbers', () => {
|
|
145
|
+
expect(requireNonNegativeInt(0, '--page')).toBe(0);
|
|
146
|
+
expect(requireNonNegativeInt('3', '--page')).toBe(3);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('rejects negative or non-integer values', () => {
|
|
150
|
+
expect(() => requireNonNegativeInt(-1, '--page')).toThrowError(ArgumentError);
|
|
151
|
+
expect(() => requireNonNegativeInt(1.5, '--page')).toThrowError(ArgumentError);
|
|
152
|
+
expect(() => requireNonNegativeInt('nope', '--page')).toThrowError(ArgumentError);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('suno utils — unwrapEvaluateResult', () => {
|
|
157
|
+
it('unwraps Browser Bridge envelopes at evaluate boundaries', () => {
|
|
158
|
+
const payload = { ok: true, clips: [] };
|
|
159
|
+
expect(unwrapEvaluateResult({ session: 'browser:default', data: payload })).toBe(payload);
|
|
160
|
+
expect(unwrapEvaluateResult(payload)).toBe(payload);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('suno utils — parseSunoBillingInfo', () => {
|
|
165
|
+
it('resolves the free-tier plan when subscription_type is false (#1704)', () => {
|
|
166
|
+
const parsed = parseSunoBillingInfo({
|
|
167
|
+
subscription_type: false,
|
|
168
|
+
credits: 0,
|
|
169
|
+
monthly_limit: 50,
|
|
170
|
+
monthly_usage: 10,
|
|
171
|
+
total_credits_left: 40,
|
|
172
|
+
credit_packs: [],
|
|
173
|
+
plans: [
|
|
174
|
+
{ id: '4497580c-f4eb-4f86-9f0e-960eb7c48d7d', name: 'Free Plan', plan_key: 'free', level: 0 },
|
|
175
|
+
{ id: '3eaebef3-ef46-446a-931c-3d50cd1514f1', name: 'Pro Plan', plan_key: 'pro', level: 10 },
|
|
176
|
+
],
|
|
177
|
+
});
|
|
178
|
+
expect(parsed.planId).toBe('4497580c-f4eb-4f86-9f0e-960eb7c48d7d');
|
|
179
|
+
expect(parsed.planKey).toBe('free');
|
|
180
|
+
expect(parsed.totalCreditsAvailable).toBe(40);
|
|
181
|
+
expect(parsed.breakdown.monthlyRemaining).toBe(40);
|
|
182
|
+
expect(parsed.breakdown.monthlyLimit).toBe(50);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('resolves the paid-tier plan when subscription_type matches a plan_key', () => {
|
|
186
|
+
const parsed = parseSunoBillingInfo({
|
|
187
|
+
subscription_type: 'pro',
|
|
188
|
+
credits: 0,
|
|
189
|
+
monthly_limit: 2500,
|
|
190
|
+
monthly_usage: 100,
|
|
191
|
+
total_credits_left: 2400,
|
|
192
|
+
credit_packs: [{ id: 'pack-1', amount: 500 }],
|
|
193
|
+
plans: [
|
|
194
|
+
{ id: 'free-uuid', name: 'Free Plan', plan_key: 'free', level: 0 },
|
|
195
|
+
{ id: 'pro-uuid', name: 'Pro Plan', plan_key: 'pro', level: 10 },
|
|
196
|
+
],
|
|
197
|
+
});
|
|
198
|
+
expect(parsed.planId).toBe('pro-uuid');
|
|
199
|
+
expect(parsed.planKey).toBe('pro');
|
|
200
|
+
expect(parsed.totalCreditsAvailable).toBe(2400);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('falls back to subscription_type as planKey when plans[] lookup misses', () => {
|
|
204
|
+
const parsed = parseSunoBillingInfo({
|
|
205
|
+
subscription_type: 'enterprise',
|
|
206
|
+
plans: [{ plan_key: 'pro' }, { plan_key: 'premier' }],
|
|
207
|
+
});
|
|
208
|
+
expect(parsed.planId).toBeNull();
|
|
209
|
+
expect(parsed.planKey).toBe('enterprise');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('returns planId null when plans[] is missing and there is no legacy plan field', () => {
|
|
213
|
+
const parsed = parseSunoBillingInfo({ subscription_type: false });
|
|
214
|
+
expect(parsed.planId).toBeNull();
|
|
215
|
+
expect(parsed.planKey).toBe('free');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('honours the legacy data.plan field when plans[] does not surface a match', () => {
|
|
219
|
+
const parsed = parseSunoBillingInfo({
|
|
220
|
+
subscription_type: false,
|
|
221
|
+
plan: { id: 'legacy-uuid', plan_key: 'legacy' },
|
|
222
|
+
});
|
|
223
|
+
expect(parsed.planId).toBe('legacy-uuid');
|
|
224
|
+
expect(parsed.planKey).toBe('legacy');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('sums credit_packs by amount and falls back to legacy credits field', () => {
|
|
228
|
+
const parsed = parseSunoBillingInfo({
|
|
229
|
+
subscription_type: false,
|
|
230
|
+
credits: 5,
|
|
231
|
+
monthly_limit: 0,
|
|
232
|
+
monthly_usage: 0,
|
|
233
|
+
credit_packs: [{ amount: 100 }, { credits: 50 }],
|
|
234
|
+
plans: [{ plan_key: 'free' }],
|
|
235
|
+
});
|
|
236
|
+
expect(parsed.breakdown.pack).toBe(5);
|
|
237
|
+
expect(parsed.breakdown.purchasedPacks).toBe(150);
|
|
238
|
+
expect(parsed.totalCreditsAvailable).toBe(155);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe('suno utils — ensureSunoSession typed failures', () => {
|
|
243
|
+
function createSessionPage(sessionCheckResult) {
|
|
244
|
+
const evaluate = async (script) => {
|
|
245
|
+
if (script.includes('querySelectorAll')) return undefined;
|
|
246
|
+
if (script.includes('!!(window.Clerk && window.Clerk.session)')) return true;
|
|
247
|
+
if (script.includes('suno_device_id')) return 'device-id';
|
|
248
|
+
if (script.includes('/api/billing/info/')) return sessionCheckResult;
|
|
249
|
+
throw new Error(`unexpected evaluate script: ${script.slice(0, 80)}`);
|
|
250
|
+
};
|
|
251
|
+
return {
|
|
252
|
+
goto: async () => undefined,
|
|
253
|
+
wait: async () => undefined,
|
|
254
|
+
evaluate,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
it('maps explicit logged-out session state to AuthRequiredError', async () => {
|
|
259
|
+
await expect(ensureSunoSession(createSessionPage({
|
|
260
|
+
ok: false,
|
|
261
|
+
auth: true,
|
|
262
|
+
error: 'Clerk session unavailable',
|
|
263
|
+
}))).rejects.toThrowError(AuthRequiredError);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('does not classify billing/parser drift as logged out', async () => {
|
|
267
|
+
await expect(ensureSunoSession(createSessionPage({
|
|
268
|
+
ok: false,
|
|
269
|
+
error: 'Malformed billing/info JSON: Unexpected token <',
|
|
270
|
+
}))).rejects.toThrowError(CommandExecutionError);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('resolves a free-tier session without throwing when subscription_type is false (#1704)', async () => {
|
|
274
|
+
const session = await ensureSunoSession(createSessionPage({
|
|
275
|
+
ok: true,
|
|
276
|
+
planId: '4497580c-f4eb-4f86-9f0e-960eb7c48d7d',
|
|
277
|
+
planKey: 'free',
|
|
278
|
+
planName: 'Free Plan',
|
|
279
|
+
totalCreditsAvailable: 40,
|
|
280
|
+
breakdown: { pack: 0, purchasedPacks: 0, monthlyRemaining: 40, monthlyLimit: 50, monthlyUsed: 10 },
|
|
281
|
+
}));
|
|
282
|
+
expect(session.planKey).toBe('free');
|
|
283
|
+
expect(session.planId).toBe('4497580c-f4eb-4f86-9f0e-960eb7c48d7d');
|
|
284
|
+
expect(session.totalCreditsAvailable).toBe(40);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('still resolves when planId is null so read commands work even on unparseable plan shapes', async () => {
|
|
288
|
+
const session = await ensureSunoSession(createSessionPage({
|
|
289
|
+
ok: true,
|
|
290
|
+
planId: null,
|
|
291
|
+
planKey: 'free',
|
|
292
|
+
planName: null,
|
|
293
|
+
totalCreditsAvailable: 0,
|
|
294
|
+
breakdown: { pack: 0, purchasedPacks: 0, monthlyRemaining: 0, monthlyLimit: 0, monthlyUsed: 0 },
|
|
295
|
+
}));
|
|
296
|
+
expect(session.planId).toBeNull();
|
|
297
|
+
expect(session.planKey).toBe('free');
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe('suno utils — model + format exports', () => {
|
|
302
|
+
it('exposes the four shipping models with chirp-fenix first', () => {
|
|
303
|
+
expect(SUNO_MODELS).toContain('chirp-fenix');
|
|
304
|
+
expect(SUNO_MODELS).toContain('chirp-bluejay');
|
|
305
|
+
expect(SUNO_MODELS[0]).toBe('chirp-fenix');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('declares mp3 + metadata as the default download set', () => {
|
|
309
|
+
expect(DEFAULT_FORMATS).toEqual(['mp3', 'metadata']);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe('suno utils — pollSunoClips', () => {
|
|
314
|
+
it('fails typed on malformed feed JSON while polling generation status', async () => {
|
|
315
|
+
const page = {
|
|
316
|
+
evaluate: async () => ({ status: 200, body: null }),
|
|
317
|
+
wait: async () => {},
|
|
318
|
+
};
|
|
319
|
+
await expect(pollSunoClips(page, ['clip-a'], 1, 'device-id', 0)).rejects.toThrowError(CommandExecutionError);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('fails typed on non-auth HTTP feed failures while polling generation status', async () => {
|
|
323
|
+
const page = {
|
|
324
|
+
evaluate: async () => ({ status: 500, body: { clips: [] } }),
|
|
325
|
+
wait: async () => {},
|
|
326
|
+
};
|
|
327
|
+
await expect(pollSunoClips(page, ['clip-a'], 1, 'device-id', 0)).rejects.toThrowError(CommandExecutionError);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
@@ -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
|
+
};
|