@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
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
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-
|
|
72
|
+
entryId: 'owned-subscribed-list-module-0',
|
|
70
73
|
content: {
|
|
71
|
-
|
|
72
|
-
|
|
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-
|
|
140
|
+
entryId: 'owned-subscribed-list-module-0',
|
|
78
141
|
content: {
|
|
79
|
-
|
|
80
|
-
|
|
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(
|
|
94
|
-
expect(result[0]).toMatchObject({ id: '
|
|
95
|
-
|
|
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
|
|
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: {
|
|
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
|
});
|
|
@@ -72,14 +72,14 @@ cli({
|
|
|
72
72
|
return;
|
|
73
73
|
let item = itemContent?.notification_results?.result || itemContent?.tweet_results?.result || itemContent;
|
|
74
74
|
let actionText = 'Notification';
|
|
75
|
-
let author = '
|
|
75
|
+
let author = '';
|
|
76
76
|
let text = '';
|
|
77
77
|
let urlStr = '';
|
|
78
78
|
if (item.__typename === 'TimelineNotification') {
|
|
79
79
|
text = item.rich_message?.text || item.message?.text || '';
|
|
80
80
|
const fromUser = item.template?.from_users?.[0]?.user_results?.result;
|
|
81
81
|
// Twitter moved screen_name from legacy to core
|
|
82
|
-
author = fromUser?.core?.screen_name || fromUser?.legacy?.screen_name || '
|
|
82
|
+
author = fromUser?.core?.screen_name || fromUser?.legacy?.screen_name || '';
|
|
83
83
|
urlStr = item.notification_url?.url || '';
|
|
84
84
|
actionText = item.notification_icon || 'Activity';
|
|
85
85
|
const targetTweet = item.template?.target_objects?.[0]?.tweet_results?.result;
|
|
@@ -94,14 +94,14 @@ cli({
|
|
|
94
94
|
else if (item.__typename === 'TweetNotification') {
|
|
95
95
|
const tweet = item.tweet_result?.result;
|
|
96
96
|
const tweetUser = tweet?.core?.user_results?.result;
|
|
97
|
-
author = tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || '
|
|
97
|
+
author = tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || '';
|
|
98
98
|
text = tweet?.note_tweet?.note_tweet_results?.result?.text || tweet?.legacy?.full_text || item.message?.text || '';
|
|
99
99
|
actionText = 'Mention/Reply';
|
|
100
100
|
urlStr = `https://x.com/i/status/${tweet?.rest_id}`;
|
|
101
101
|
}
|
|
102
102
|
else if (item.__typename === 'Tweet') {
|
|
103
103
|
const tweetUser = item.core?.user_results?.result;
|
|
104
|
-
author = tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || '
|
|
104
|
+
author = tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || '';
|
|
105
105
|
text = item.note_tweet?.note_tweet_results?.result?.text || item.legacy?.full_text || '';
|
|
106
106
|
actionText = 'Mention';
|
|
107
107
|
urlStr = `https://x.com/i/status/${item.rest_id}`;
|
package/clis/twitter/post.js
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
4
4
|
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
5
|
+
import { isRecoverableFileInputError } from './utils.js';
|
|
5
6
|
|
|
6
7
|
const MAX_IMAGES = 4;
|
|
7
8
|
const UPLOAD_POLL_MS = 500;
|
|
@@ -142,6 +143,55 @@ async function waitForImageUpload(page, expectedCount) {
|
|
|
142
143
|
})()`);
|
|
143
144
|
}
|
|
144
145
|
|
|
146
|
+
async function attachImagesViaDataTransfer(page, absPaths) {
|
|
147
|
+
const files = absPaths.map((absPath) => {
|
|
148
|
+
const ext = path.extname(absPath).toLowerCase();
|
|
149
|
+
const mime = ext === '.png'
|
|
150
|
+
? 'image/png'
|
|
151
|
+
: ext === '.gif'
|
|
152
|
+
? 'image/gif'
|
|
153
|
+
: ext === '.webp'
|
|
154
|
+
? 'image/webp'
|
|
155
|
+
: 'image/jpeg';
|
|
156
|
+
return {
|
|
157
|
+
name: path.basename(absPath),
|
|
158
|
+
mime,
|
|
159
|
+
base64: fs.readFileSync(absPath).toString('base64'),
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
const upload = await page.evaluate(`(() => {
|
|
163
|
+
const input = document.querySelector(${JSON.stringify(FILE_INPUT_SELECTOR)});
|
|
164
|
+
if (!input) return { ok: false, error: 'No file input found' };
|
|
165
|
+
const dt = new DataTransfer();
|
|
166
|
+
for (const file of ${JSON.stringify(files)}) {
|
|
167
|
+
const bin = atob(file.base64);
|
|
168
|
+
const bytes = new Uint8Array(bin.length);
|
|
169
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
170
|
+
dt.items.add(new File([bytes], file.name, { type: file.mime }));
|
|
171
|
+
}
|
|
172
|
+
let assigned = false;
|
|
173
|
+
try {
|
|
174
|
+
Object.defineProperty(input, 'files', { value: dt.files, writable: false, configurable: true });
|
|
175
|
+
assigned = input.files && input.files.length >= ${JSON.stringify(absPaths.length)};
|
|
176
|
+
} catch(e) {
|
|
177
|
+
try {
|
|
178
|
+
const nativeInputFileSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'files');
|
|
179
|
+
if (nativeInputFileSetter && nativeInputFileSetter.set) {
|
|
180
|
+
nativeInputFileSetter.set.call(input, dt.files);
|
|
181
|
+
assigned = input.files && input.files.length >= ${JSON.stringify(absPaths.length)};
|
|
182
|
+
}
|
|
183
|
+
} catch(e2) { /* ignore */ }
|
|
184
|
+
}
|
|
185
|
+
if (!assigned) return { ok: false, error: 'Could not assign files to input' };
|
|
186
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
187
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
188
|
+
return { ok: true };
|
|
189
|
+
})()`);
|
|
190
|
+
if (!upload?.ok) {
|
|
191
|
+
throw new CommandExecutionError(`Image upload failed (base64 fallback): ${upload?.error ?? 'unknown error'}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
145
195
|
async function submitTweet(page, text) {
|
|
146
196
|
const clickResult = await page.evaluate(`(async () => {
|
|
147
197
|
try {
|
|
@@ -224,11 +274,19 @@ cli({
|
|
|
224
274
|
// Attach media before inserting text. Uploading media after Draft.js has
|
|
225
275
|
// text can re-render/reset the editor, causing image-only posts.
|
|
226
276
|
if (absPaths.length > 0) {
|
|
227
|
-
if (!page.setFileInput) {
|
|
228
|
-
throw new CommandExecutionError('Browser extension does not support file upload. Please update the extension.');
|
|
229
|
-
}
|
|
230
277
|
await page.wait({ selector: FILE_INPUT_SELECTOR, timeout: 20 });
|
|
231
|
-
|
|
278
|
+
if (page.setFileInput) {
|
|
279
|
+
try {
|
|
280
|
+
await page.setFileInput(absPaths, FILE_INPUT_SELECTOR);
|
|
281
|
+
} catch (err) {
|
|
282
|
+
if (!isRecoverableFileInputError(err)) {
|
|
283
|
+
throw err;
|
|
284
|
+
}
|
|
285
|
+
await attachImagesViaDataTransfer(page, absPaths);
|
|
286
|
+
}
|
|
287
|
+
} else {
|
|
288
|
+
await attachImagesViaDataTransfer(page, absPaths);
|
|
289
|
+
}
|
|
232
290
|
const uploadState = await waitForImageUpload(page, absPaths.length);
|
|
233
291
|
if (!uploadState?.ok) {
|
|
234
292
|
return [{ status: 'failed', message: uploadState?.message ?? `Image upload timed out (${UPLOAD_TIMEOUT_MS / 1000}s).`, text }];
|
|
@@ -11,6 +11,7 @@ vi.mock('node:fs', async (importOriginal) => {
|
|
|
11
11
|
return undefined;
|
|
12
12
|
return { isFile: () => true };
|
|
13
13
|
}),
|
|
14
|
+
readFileSync: vi.fn(() => Buffer.from([0x89, 0x50, 0x4e, 0x47])),
|
|
14
15
|
};
|
|
15
16
|
});
|
|
16
17
|
|
|
@@ -123,10 +124,41 @@ describe('twitter post command', () => {
|
|
|
123
124
|
await expect(command.func(page, { text: 'hi', images: 'photo.bmp' })).rejects.toThrow('Unsupported image format');
|
|
124
125
|
});
|
|
125
126
|
|
|
126
|
-
it('
|
|
127
|
+
it('falls back to DataTransfer upload when page.setFileInput is not available', async () => {
|
|
127
128
|
const command = getCommand();
|
|
128
|
-
const page = makePage([
|
|
129
|
-
|
|
129
|
+
const page = makePage([
|
|
130
|
+
{ ok: true }, // DataTransfer fallback
|
|
131
|
+
{ ok: true, previewCount: 1 }, // upload polling
|
|
132
|
+
{ ok: true }, // focus composer
|
|
133
|
+
{ ok: true }, // verify native insertText
|
|
134
|
+
{ ok: true }, // click post
|
|
135
|
+
{ ok: true, message: 'Tweet posted successfully.' },
|
|
136
|
+
], { setFileInput: undefined });
|
|
137
|
+
|
|
138
|
+
const result = await command.func(page, { text: 'hi', images: 'a.png' });
|
|
139
|
+
|
|
140
|
+
expect(result).toEqual([{ status: 'success', message: 'Tweet posted successfully.', text: 'hi' }]);
|
|
141
|
+
expect(page.evaluate.mock.calls[0][0]).toContain('new DataTransfer()');
|
|
142
|
+
expect(page.evaluate.mock.calls[0][0]).toContain('Could not assign files to input');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('falls back to DataTransfer upload when CDP rejects file input as not allowed', async () => {
|
|
146
|
+
const command = getCommand();
|
|
147
|
+
const setFileInput = vi.fn().mockRejectedValue(new Error('NotAllowedError: Not allowed'));
|
|
148
|
+
const page = makePage([
|
|
149
|
+
{ ok: true }, // DataTransfer fallback
|
|
150
|
+
{ ok: true, previewCount: 1 }, // upload polling
|
|
151
|
+
{ ok: true }, // focus composer
|
|
152
|
+
{ ok: true }, // verify native insertText
|
|
153
|
+
{ ok: true }, // click post
|
|
154
|
+
{ ok: true, message: 'Tweet posted successfully.' },
|
|
155
|
+
], { setFileInput });
|
|
156
|
+
|
|
157
|
+
const result = await command.func(page, { text: 'with fallback', images: 'a.png' });
|
|
158
|
+
|
|
159
|
+
expect(result).toEqual([{ status: 'success', message: 'Tweet posted successfully.', text: 'with fallback' }]);
|
|
160
|
+
expect(setFileInput).toHaveBeenCalledWith(['/abs/a.png'], 'input[type="file"][data-testid="fileInput"]');
|
|
161
|
+
expect(page.evaluate.mock.calls[0][0]).toContain('new DataTransfer()');
|
|
130
162
|
});
|
|
131
163
|
|
|
132
164
|
it('uploads images before inserting text so media re-renders cannot erase the tweet text', async () => {
|
package/clis/twitter/profile.js
CHANGED
|
@@ -1,8 +1,48 @@
|
|
|
1
|
-
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
1
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
2
2
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
3
|
import { normalizeTwitterScreenName, resolveTwitterQueryId, unwrapBrowserResult } from './shared.js';
|
|
4
4
|
import { TWITTER_BEARER_TOKEN } from './utils.js';
|
|
5
|
-
const USER_BY_SCREEN_NAME_QUERY_ID = '
|
|
5
|
+
const USER_BY_SCREEN_NAME_QUERY_ID = 'IGgvgiOx4QZndDHuD3x9TQ';
|
|
6
|
+
|
|
7
|
+
function isPlainObject(value) {
|
|
8
|
+
return value != null && typeof value === 'object' && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function stringField(value) {
|
|
12
|
+
return typeof value === 'string' ? value : '';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function mapTwitterProfileResult(result, screenName) {
|
|
16
|
+
if (!isPlainObject(result)) {
|
|
17
|
+
throw new CommandExecutionError(`Twitter profile response for @${screenName} is malformed`);
|
|
18
|
+
}
|
|
19
|
+
const hasLegacy = isPlainObject(result.legacy);
|
|
20
|
+
const hasCore = isPlainObject(result.core);
|
|
21
|
+
if (!hasLegacy && !hasCore) {
|
|
22
|
+
throw new CommandExecutionError(`Twitter profile response for @${screenName} is missing profile fields`);
|
|
23
|
+
}
|
|
24
|
+
const legacy = hasLegacy ? result.legacy : {};
|
|
25
|
+
const core = hasCore ? result.core : {};
|
|
26
|
+
if (!stringField(core.screen_name) && !stringField(legacy.screen_name) && !stringField(core.name) && !stringField(legacy.name) && !stringField(core.created_at) && !stringField(legacy.created_at)) {
|
|
27
|
+
throw new CommandExecutionError(`Twitter profile response for @${screenName} is missing profile identity fields`);
|
|
28
|
+
}
|
|
29
|
+
const location = isPlainObject(result.location) ? result.location : {};
|
|
30
|
+
const expandedUrl = legacy.entities?.url?.urls?.[0]?.expanded_url || '';
|
|
31
|
+
return [{
|
|
32
|
+
screen_name: stringField(core.screen_name) || stringField(legacy.screen_name) || screenName,
|
|
33
|
+
name: stringField(core.name) || stringField(legacy.name),
|
|
34
|
+
bio: stringField(legacy.description),
|
|
35
|
+
location: stringField(location.location) || stringField(legacy.location),
|
|
36
|
+
url: stringField(expandedUrl),
|
|
37
|
+
followers: legacy.followers_count || 0,
|
|
38
|
+
following: legacy.friends_count || 0,
|
|
39
|
+
tweets: legacy.statuses_count || 0,
|
|
40
|
+
likes: legacy.favourites_count || 0,
|
|
41
|
+
verified: Boolean(result.is_blue_verified || legacy.verified),
|
|
42
|
+
created_at: stringField(core.created_at) || stringField(legacy.created_at),
|
|
43
|
+
}];
|
|
44
|
+
}
|
|
45
|
+
|
|
6
46
|
cli({
|
|
7
47
|
site: 'twitter',
|
|
8
48
|
name: 'profile',
|
|
@@ -46,7 +86,7 @@ cli({
|
|
|
46
86
|
if (!ct0)
|
|
47
87
|
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
48
88
|
const queryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
|
|
49
|
-
const
|
|
89
|
+
const rawResult = unwrapBrowserResult(await page.evaluate(`
|
|
50
90
|
async () => {
|
|
51
91
|
const screenName = "${username}";
|
|
52
92
|
const ct0 = ${JSON.stringify(ct0)};
|
|
@@ -82,34 +122,47 @@ cli({
|
|
|
82
122
|
+ encodeURIComponent(variables)
|
|
83
123
|
+ '&features=' + encodeURIComponent(features);
|
|
84
124
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
125
|
+
let resp;
|
|
126
|
+
try {
|
|
127
|
+
resp = await fetch(url, {headers, credentials: 'include'});
|
|
128
|
+
} catch (error) {
|
|
129
|
+
return {ok: false, error: 'Twitter profile request failed: ' + String(error && error.message || error)};
|
|
130
|
+
}
|
|
131
|
+
if (!resp.ok) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
auth: resp.status === 401 || resp.status === 403,
|
|
135
|
+
error: 'HTTP ' + resp.status,
|
|
136
|
+
hint: 'User may not exist, auth may be required, or queryId expired'
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
let d;
|
|
140
|
+
try {
|
|
141
|
+
d = await resp.json();
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return {ok: false, error: 'Twitter profile response was not JSON: ' + String(error && error.message || error)};
|
|
144
|
+
}
|
|
88
145
|
|
|
89
146
|
const result = d.data?.user?.result;
|
|
90
|
-
if (!result) return {error: 'User @' + screenName + ' not found'};
|
|
91
|
-
|
|
92
|
-
const legacy = result.legacy || {};
|
|
93
|
-
const expandedUrl = legacy.entities?.url?.urls?.[0]?.expanded_url || '';
|
|
94
|
-
|
|
95
|
-
return [{
|
|
96
|
-
screen_name: legacy.screen_name || screenName,
|
|
97
|
-
name: legacy.name || '',
|
|
98
|
-
bio: legacy.description || '',
|
|
99
|
-
location: legacy.location || '',
|
|
100
|
-
url: expandedUrl,
|
|
101
|
-
followers: legacy.followers_count || 0,
|
|
102
|
-
following: legacy.friends_count || 0,
|
|
103
|
-
tweets: legacy.statuses_count || 0,
|
|
104
|
-
likes: legacy.favourites_count || 0,
|
|
105
|
-
verified: result.is_blue_verified || legacy.verified || false,
|
|
106
|
-
created_at: legacy.created_at || '',
|
|
107
|
-
}];
|
|
147
|
+
if (!result) return {ok: false, notFound: true, error: 'User @' + screenName + ' not found'};
|
|
148
|
+
return {ok: true, result};
|
|
108
149
|
}
|
|
109
|
-
`);
|
|
110
|
-
if (
|
|
111
|
-
throw new CommandExecutionError(
|
|
150
|
+
`));
|
|
151
|
+
if (!isPlainObject(rawResult)) {
|
|
152
|
+
throw new CommandExecutionError('Twitter profile response payload is malformed');
|
|
153
|
+
}
|
|
154
|
+
if (!rawResult.ok) {
|
|
155
|
+
const message = rawResult.error + (rawResult.hint ? ` (${rawResult.hint})` : '');
|
|
156
|
+
if (rawResult.auth) {
|
|
157
|
+
throw new AuthRequiredError('x.com', message);
|
|
158
|
+
}
|
|
159
|
+
if (rawResult.notFound) {
|
|
160
|
+
throw new EmptyResultError('twitter profile', message);
|
|
161
|
+
}
|
|
162
|
+
throw new CommandExecutionError(message);
|
|
112
163
|
}
|
|
113
|
-
return result
|
|
164
|
+
return mapTwitterProfileResult(rawResult.result, username);
|
|
114
165
|
}
|
|
115
166
|
});
|
|
167
|
+
|
|
168
|
+
export const __test__ = { mapTwitterProfileResult };
|
|
@@ -1,9 +1,73 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
-
import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors';
|
|
4
|
-
import './profile.js';
|
|
3
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
4
|
+
import { __test__ } from './profile.js';
|
|
5
5
|
|
|
6
6
|
describe('twitter profile command', () => {
|
|
7
|
+
it('maps current result.core profile fields while preserving legacy fallback fields', () => {
|
|
8
|
+
const rows = __test__.mapTwitterProfileResult({
|
|
9
|
+
core: {
|
|
10
|
+
screen_name: 'AstroHanRay',
|
|
11
|
+
name: 'AstroHan',
|
|
12
|
+
created_at: 'Sun Mar 20 00:00:00 +0000 2011',
|
|
13
|
+
},
|
|
14
|
+
legacy: {
|
|
15
|
+
screen_name: null,
|
|
16
|
+
name: null,
|
|
17
|
+
description: 'bio text',
|
|
18
|
+
location: 'legacy location',
|
|
19
|
+
followers_count: 117,
|
|
20
|
+
friends_count: 12,
|
|
21
|
+
statuses_count: 30,
|
|
22
|
+
favourites_count: 4,
|
|
23
|
+
verified: false,
|
|
24
|
+
entities: { url: { urls: [{ expanded_url: 'https://example.com' }] } },
|
|
25
|
+
},
|
|
26
|
+
location: { location: 'core location' },
|
|
27
|
+
is_blue_verified: true,
|
|
28
|
+
}, 'fallback');
|
|
29
|
+
|
|
30
|
+
expect(rows).toEqual([{
|
|
31
|
+
screen_name: 'AstroHanRay',
|
|
32
|
+
name: 'AstroHan',
|
|
33
|
+
bio: 'bio text',
|
|
34
|
+
location: 'core location',
|
|
35
|
+
url: 'https://example.com',
|
|
36
|
+
followers: 117,
|
|
37
|
+
following: 12,
|
|
38
|
+
tweets: 30,
|
|
39
|
+
likes: 4,
|
|
40
|
+
verified: true,
|
|
41
|
+
created_at: 'Sun Mar 20 00:00:00 +0000 2011',
|
|
42
|
+
}]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('falls back to legacy profile fields for older UserByScreenName responses', () => {
|
|
46
|
+
const rows = __test__.mapTwitterProfileResult({
|
|
47
|
+
legacy: {
|
|
48
|
+
screen_name: 'legacy_user',
|
|
49
|
+
name: 'Legacy Name',
|
|
50
|
+
created_at: 'Wed Jan 01 00:00:00 +0000 2020',
|
|
51
|
+
location: 'legacy location',
|
|
52
|
+
},
|
|
53
|
+
}, 'fallback');
|
|
54
|
+
|
|
55
|
+
expect(rows[0]).toMatchObject({
|
|
56
|
+
screen_name: 'legacy_user',
|
|
57
|
+
name: 'Legacy Name',
|
|
58
|
+
created_at: 'Wed Jan 01 00:00:00 +0000 2020',
|
|
59
|
+
location: 'legacy location',
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('throws typed when the profile result is structurally malformed', () => {
|
|
64
|
+
expect(() => __test__.mapTwitterProfileResult(null, 'jack')).toThrow(CommandExecutionError);
|
|
65
|
+
expect(() => __test__.mapTwitterProfileResult([], 'jack')).toThrow(CommandExecutionError);
|
|
66
|
+
expect(() => __test__.mapTwitterProfileResult({}, 'jack')).toThrow(CommandExecutionError);
|
|
67
|
+
expect(() => __test__.mapTwitterProfileResult({ __typename: 'UserUnavailable' }, 'jack')).toThrow(CommandExecutionError);
|
|
68
|
+
expect(() => __test__.mapTwitterProfileResult({ legacy: {}, core: {} }, 'jack')).toThrow(CommandExecutionError);
|
|
69
|
+
});
|
|
70
|
+
|
|
7
71
|
it('rejects invalid explicit usernames before navigation', async () => {
|
|
8
72
|
const command = getRegistry().get('twitter/profile');
|
|
9
73
|
const page = {
|
|
@@ -36,4 +100,51 @@ describe('twitter profile command', () => {
|
|
|
36
100
|
expect(page.goto).toHaveBeenCalledTimes(1);
|
|
37
101
|
expect(page.getCookies).not.toHaveBeenCalled();
|
|
38
102
|
});
|
|
103
|
+
|
|
104
|
+
it('unwraps Browser Bridge envelopes around UserByScreenName payloads', async () => {
|
|
105
|
+
const command = getRegistry().get('twitter/profile');
|
|
106
|
+
const page = {
|
|
107
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
108
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
109
|
+
getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'csrf' }]),
|
|
110
|
+
evaluate: vi.fn()
|
|
111
|
+
.mockResolvedValueOnce(null)
|
|
112
|
+
.mockResolvedValueOnce({
|
|
113
|
+
session: 'site:twitter',
|
|
114
|
+
data: {
|
|
115
|
+
ok: true,
|
|
116
|
+
result: {
|
|
117
|
+
core: { screen_name: 'core_user', name: 'Core User', created_at: 'now' },
|
|
118
|
+
legacy: { description: 'bio' },
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
}),
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
await expect(command.func(page, { username: 'core_user' })).resolves.toEqual([
|
|
125
|
+
expect.objectContaining({
|
|
126
|
+
screen_name: 'core_user',
|
|
127
|
+
name: 'Core User',
|
|
128
|
+
bio: 'bio',
|
|
129
|
+
created_at: 'now',
|
|
130
|
+
}),
|
|
131
|
+
]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('maps GraphQL auth and not-found envelopes to typed failures', async () => {
|
|
135
|
+
const command = getRegistry().get('twitter/profile');
|
|
136
|
+
const createPage = (payload) => ({
|
|
137
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
138
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
139
|
+
getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'csrf' }]),
|
|
140
|
+
evaluate: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(payload),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await expect(command.func(createPage({ ok: false, auth: true, error: 'HTTP 401' }), { username: 'jack' }))
|
|
144
|
+
.rejects.toBeInstanceOf(AuthRequiredError);
|
|
145
|
+
await expect(command.func(createPage({ ok: false, notFound: true, error: 'User @missing not found' }), { username: 'missing' }))
|
|
146
|
+
.rejects.toBeInstanceOf(EmptyResultError);
|
|
147
|
+
await expect(command.func(createPage({ session: 'site:twitter', data: [] }), { username: 'jack' }))
|
|
148
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
149
|
+
});
|
|
39
150
|
});
|