@jackwener/opencli 1.7.8 → 1.7.10
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 +49 -14
- package/README.zh-CN.md +30 -10
- package/cli-manifest.json +646 -30
- package/clis/36kr/news.js +1 -1
- package/clis/apple-podcasts/commands.test.js +4 -4
- package/clis/apple-podcasts/episodes.js +1 -1
- package/clis/apple-podcasts/search.js +1 -1
- package/clis/apple-podcasts/top.js +1 -1
- package/clis/arxiv/paper.js +1 -1
- package/clis/arxiv/search.js +1 -1
- package/clis/band/mentions.js +3 -3
- package/clis/bbc/news.js +1 -1
- package/clis/bilibili/subtitle.js +2 -2
- package/clis/bloomberg/businessweek.js +1 -1
- package/clis/bloomberg/economics.js +1 -1
- package/clis/bloomberg/industries.js +1 -1
- package/clis/bloomberg/main.js +1 -1
- package/clis/bloomberg/markets.js +1 -1
- package/clis/bloomberg/opinions.js +1 -1
- package/clis/bloomberg/politics.js +1 -1
- package/clis/bloomberg/tech.js +1 -1
- package/clis/boss/search.js +49 -8
- package/clis/boss/search.test.js +78 -0
- package/clis/boss/send.js +3 -3
- package/clis/chatgpt/image.js +37 -8
- package/clis/chatgpt/image.test.js +92 -0
- package/clis/chatgpt/utils.js +39 -6
- package/clis/chatgpt/utils.test.js +63 -0
- package/clis/chatgpt-app/ask.js +1 -1
- package/clis/chatgpt-app/ax.js +4 -2
- package/clis/chatgpt-app/ax.test.js +12 -0
- package/clis/chatgpt-app/model.js +1 -1
- package/clis/chatgpt-app/new.js +1 -1
- package/clis/chatgpt-app/read.js +1 -1
- package/clis/chatgpt-app/send.js +1 -1
- package/clis/chatgpt-app/status.js +1 -1
- package/clis/chatwise/ask.js +2 -2
- package/clis/chatwise/model.js +2 -2
- package/clis/chatwise/send.js +2 -2
- package/clis/claude/ask.js +128 -0
- package/clis/claude/ask.test.js +338 -0
- package/clis/claude/commands.test.js +118 -0
- package/clis/claude/detail.js +29 -0
- package/clis/claude/history.js +31 -0
- package/clis/claude/new.js +21 -0
- package/clis/claude/read.js +24 -0
- package/clis/claude/send.js +41 -0
- package/clis/claude/status.js +24 -0
- package/clis/claude/utils.js +440 -0
- package/clis/claude/utils.test.js +148 -0
- package/clis/codex/ask.js +2 -2
- package/clis/codex/send.js +2 -2
- package/clis/ctrip/search.js +1 -1
- package/clis/ctrip/search.test.js +4 -4
- package/clis/cursor/ask.js +2 -2
- package/clis/cursor/composer.js +2 -2
- package/clis/cursor/send.js +2 -2
- package/clis/deepseek/ask.js +17 -4
- package/clis/deepseek/ask.test.js +46 -0
- package/clis/deepseek/utils.js +55 -16
- package/clis/deepseek/utils.test.js +124 -5
- package/clis/doubao/utils.js +53 -11
- package/clis/doubao/utils.test.js +22 -2
- package/clis/eastmoney/announcement.js +1 -1
- package/clis/eastmoney/convertible.js +1 -1
- package/clis/eastmoney/etf.js +1 -1
- package/clis/eastmoney/holders.js +1 -1
- package/clis/eastmoney/index-board.js +1 -1
- package/clis/eastmoney/kline.js +1 -1
- package/clis/eastmoney/kuaixun.js +1 -1
- package/clis/eastmoney/longhu.js +1 -1
- package/clis/eastmoney/money-flow.js +1 -1
- package/clis/eastmoney/northbound.js +1 -1
- package/clis/eastmoney/quote.js +1 -1
- package/clis/eastmoney/rank.js +1 -1
- package/clis/eastmoney/sectors.js +1 -1
- package/clis/facebook/marketplace-inbox.js +83 -0
- package/clis/facebook/marketplace-listings.js +83 -0
- package/clis/facebook/marketplace.test.js +91 -0
- package/clis/google/news.js +1 -1
- package/clis/google/suggest.js +1 -1
- package/clis/google/trends.js +1 -1
- package/clis/google-scholar/cite.js +74 -0
- package/clis/google-scholar/cite.test.js +47 -0
- package/clis/google-scholar/profile.js +92 -0
- package/clis/google-scholar/profile.test.js +49 -0
- package/clis/google-scholar/search.js +1 -1
- package/clis/google-scholar/search.test.js +15 -0
- package/clis/hf/top.js +1 -1
- package/clis/instagram/collection-create.js +57 -0
- package/clis/instagram/saved.js +21 -7
- package/clis/jd/item.js +679 -47
- package/clis/jd/item.test.js +318 -7
- package/clis/jd/item.test.ts +517 -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/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/tags.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/lesswrong/user-posts.js +1 -1
- package/clis/lesswrong/user.js +1 -1
- package/clis/paperreview/commands.test.js +6 -6
- package/clis/paperreview/feedback.js +1 -1
- package/clis/paperreview/review.js +1 -1
- package/clis/paperreview/submit.js +1 -1
- package/clis/producthunt/posts.js +1 -1
- package/clis/producthunt/today.js +1 -1
- package/clis/sinablog/search.js +1 -1
- package/clis/sinafinance/news.js +1 -1
- package/clis/sinafinance/stock.js +1 -1
- package/clis/sinafinance/stock.test.js +2 -2
- package/clis/spotify/spotify.js +6 -6
- package/clis/substack/search.js +1 -1
- package/clis/toutiao/articles.js +5 -6
- package/clis/toutiao/articles.test.js +22 -15
- package/clis/twitter/followers.js +2 -2
- package/clis/twitter/following.js +224 -73
- package/clis/twitter/following.test.js +277 -0
- package/clis/twitter/post.js +184 -47
- package/clis/twitter/post.test.js +114 -34
- package/clis/uiverse/_shared.js +63 -4
- package/clis/uiverse/_shared.test.js +7 -0
- package/clis/uiverse/code.js +1 -0
- package/clis/uiverse/navigation.test.js +12 -0
- package/clis/uiverse/preview.js +1 -0
- package/clis/web/read.js +319 -81
- package/clis/web/read.test.js +221 -5
- package/clis/weibo/favorites.js +169 -0
- package/clis/weibo/favorites.test.js +114 -0
- package/clis/weibo/publish.js +282 -0
- package/clis/weibo/publish.test.js +183 -0
- package/clis/weread/ranking.js +1 -1
- package/clis/weread/search-regression.test.js +8 -8
- package/clis/weread/search.js +1 -1
- package/clis/wikipedia/random.js +1 -1
- package/clis/wikipedia/search.js +1 -1
- package/clis/wikipedia/summary.js +1 -1
- package/clis/wikipedia/trending.js +1 -1
- package/clis/xianyu/chat.js +3 -3
- package/clis/xianyu/item.js +2 -2
- package/clis/xianyu/item.test.js +3 -3
- package/clis/xiaohongshu/search.js +17 -2
- package/clis/xiaohongshu/search.test.js +37 -1
- package/clis/xiaoyuzhou/download.js +1 -1
- package/clis/xiaoyuzhou/download.test.js +3 -3
- package/clis/xiaoyuzhou/episode.js +1 -1
- package/clis/xiaoyuzhou/podcast-episodes.js +1 -1
- package/clis/xiaoyuzhou/podcast-episodes.test.js +2 -2
- package/clis/xiaoyuzhou/podcast.js +1 -1
- package/clis/xiaoyuzhou/transcript.js +1 -1
- package/clis/xiaoyuzhou/transcript.test.js +5 -5
- package/clis/yollomi/models.js +1 -1
- package/clis/youtube/channel.js +24 -1
- package/clis/youtube/channel.test.js +59 -0
- package/clis/zhihu/answer.js +21 -162
- package/clis/zhihu/answer.test.js +26 -53
- package/clis/zhihu/collection.js +197 -0
- package/clis/zhihu/collection.test.js +290 -0
- package/clis/zhihu/collections.js +127 -0
- package/clis/zhihu/collections.test.js +182 -0
- package/clis/zhihu/comment.js +24 -305
- package/clis/zhihu/comment.test.js +31 -35
- package/clis/zhihu/favorite.js +44 -182
- package/clis/zhihu/favorite.test.js +30 -167
- package/clis/zhihu/follow.js +25 -56
- package/clis/zhihu/follow.test.js +20 -23
- package/clis/zhihu/like.js +22 -67
- package/clis/zhihu/like.test.js +19 -42
- package/clis/zhihu/search.js +3 -2
- package/clis/zhihu/write-shared.js +8 -1
- package/clis/zhihu/write-shared.test.js +1 -0
- package/clis/zlibrary/commands.test.js +75 -0
- package/clis/zlibrary/info.js +47 -0
- package/clis/zlibrary/search.js +46 -0
- package/clis/zlibrary/utils.js +136 -0
- package/dist/src/adapter-source.d.ts +11 -0
- package/dist/src/adapter-source.js +24 -0
- package/dist/src/adapter-source.test.js +29 -0
- package/dist/src/browser/base-page.d.ts +3 -1
- package/dist/src/browser/base-page.js +76 -1
- package/dist/src/browser/base-page.test.d.ts +1 -0
- package/dist/src/browser/base-page.test.js +74 -0
- package/dist/src/browser/bridge.d.ts +1 -2
- package/dist/src/browser/bridge.js +40 -41
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/cdp.js +3 -3
- package/dist/src/browser/daemon-client.d.ts +38 -4
- package/dist/src/browser/daemon-client.js +24 -7
- package/dist/src/browser/daemon-client.test.js +49 -0
- package/dist/src/browser/daemon-lifecycle.d.ts +23 -0
- package/dist/src/browser/daemon-lifecycle.js +67 -0
- package/dist/src/browser/daemon-version.d.ts +4 -0
- package/dist/src/browser/daemon-version.js +12 -0
- package/dist/src/browser/errors.js +3 -0
- package/dist/src/browser/errors.test.js +3 -0
- package/dist/src/browser/network-cache.d.ts +1 -0
- package/dist/src/browser/page.d.ts +3 -1
- package/dist/src/browser/page.js +10 -2
- package/dist/src/browser/profile.d.ts +14 -0
- package/dist/src/browser/profile.js +85 -0
- package/dist/src/build-manifest.d.ts +2 -0
- package/dist/src/build-manifest.js +13 -3
- package/dist/src/build-manifest.test.js +20 -2
- package/dist/src/cli.d.ts +6 -0
- package/dist/src/cli.js +477 -35
- package/dist/src/cli.test.js +303 -2
- package/dist/src/commanderAdapter.js +17 -9
- package/dist/src/commanderAdapter.test.js +67 -2
- package/dist/src/commands/daemon.d.ts +2 -0
- package/dist/src/commands/daemon.js +42 -1
- package/dist/src/commands/daemon.test.js +103 -2
- package/dist/src/completion-shared.js +1 -2
- package/dist/src/completion.test.js +3 -2
- package/dist/src/daemon.js +125 -41
- package/dist/src/doctor.d.ts +5 -6
- package/dist/src/doctor.js +77 -19
- package/dist/src/doctor.test.js +117 -0
- package/dist/src/engine.test.js +6 -5
- package/dist/src/errors.d.ts +14 -8
- package/dist/src/errors.js +36 -30
- package/dist/src/errors.test.js +5 -5
- package/dist/src/execution.d.ts +4 -0
- package/dist/src/execution.js +173 -25
- package/dist/src/execution.test.js +171 -1
- package/dist/src/main.js +10 -0
- package/dist/src/observation/artifact.d.ts +16 -0
- package/dist/src/observation/artifact.js +260 -0
- package/dist/src/observation/artifact.test.d.ts +1 -0
- package/dist/src/observation/artifact.test.js +121 -0
- package/dist/src/observation/events.d.ts +89 -0
- package/dist/src/observation/events.js +1 -0
- package/dist/src/observation/index.d.ts +7 -0
- package/dist/src/observation/index.js +7 -0
- package/dist/src/observation/manager.d.ts +9 -0
- package/dist/src/observation/manager.js +27 -0
- package/dist/src/observation/manager.test.d.ts +1 -0
- package/dist/src/observation/manager.test.js +13 -0
- package/dist/src/observation/redaction.d.ts +11 -0
- package/dist/src/observation/redaction.js +81 -0
- package/dist/src/observation/redaction.test.d.ts +1 -0
- package/dist/src/observation/redaction.test.js +32 -0
- package/dist/src/observation/retention.d.ts +32 -0
- package/dist/src/observation/retention.js +160 -0
- package/dist/src/observation/retention.test.d.ts +1 -0
- package/dist/src/observation/retention.test.js +118 -0
- package/dist/src/observation/ring-buffer.d.ts +22 -0
- package/dist/src/observation/ring-buffer.js +45 -0
- package/dist/src/observation/ring-buffer.test.d.ts +1 -0
- package/dist/src/observation/ring-buffer.test.js +22 -0
- package/dist/src/observation/session.d.ts +25 -0
- package/dist/src/observation/session.js +50 -0
- package/dist/src/pipeline/executor.test.js +1 -0
- package/dist/src/pipeline/steps/download.test.js +1 -0
- package/dist/src/pipeline/steps/fetch.js +1 -21
- package/dist/src/pipeline/steps/fetch.test.js +6 -12
- package/dist/src/plugin-scaffold.js +1 -1
- package/dist/src/plugin-scaffold.test.js +1 -1
- package/dist/src/registry.d.ts +40 -9
- package/dist/src/registry.js +3 -1
- package/dist/src/runtime-detect.d.ts +10 -0
- package/dist/src/runtime-detect.js +19 -0
- package/dist/src/runtime-detect.test.js +12 -1
- package/dist/src/runtime.d.ts +2 -0
- package/dist/src/runtime.js +1 -0
- package/dist/src/types.d.ts +22 -0
- package/dist/src/update-check.d.ts +31 -1
- package/dist/src/update-check.js +62 -16
- package/dist/src/update-check.test.js +86 -1
- package/package.json +1 -1
- package/dist/src/diagnostic.d.ts +0 -63
- package/dist/src/diagnostic.js +0 -292
- package/dist/src/diagnostic.test.js +0 -302
- /package/dist/src/{diagnostic.test.d.ts → adapter-source.test.d.ts} +0 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
4
|
+
import { __test__ } from './following.js';
|
|
5
|
+
|
|
6
|
+
describe('twitter following helpers', () => {
|
|
7
|
+
it('falls back when queryId contains unsafe characters', () => {
|
|
8
|
+
expect(__test__.sanitizeQueryId('safe_Query-123', 'fallback')).toBe('safe_Query-123');
|
|
9
|
+
expect(__test__.sanitizeQueryId('bad"id', 'fallback')).toBe('fallback');
|
|
10
|
+
expect(__test__.sanitizeQueryId('bad/id', 'fallback')).toBe('fallback');
|
|
11
|
+
expect(__test__.sanitizeQueryId(null, 'fallback')).toBe('fallback');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('builds following url with cursor', () => {
|
|
15
|
+
const url = __test__.buildFollowingUrl('query123', '42', 20, 'cursor-1');
|
|
16
|
+
expect(url).toContain('/i/api/graphql/query123/Following');
|
|
17
|
+
expect(decodeURIComponent(url)).toContain('"userId":"42"');
|
|
18
|
+
expect(decodeURIComponent(url)).toContain('"count":20');
|
|
19
|
+
expect(decodeURIComponent(url)).toContain('"cursor":"cursor-1"');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('builds following url without cursor', () => {
|
|
23
|
+
const url = __test__.buildFollowingUrl('query123', '42', 20);
|
|
24
|
+
expect(url).toContain('/i/api/graphql/query123/Following');
|
|
25
|
+
expect(decodeURIComponent(url)).not.toContain('"cursor"');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('extracts user from result', () => {
|
|
29
|
+
const user = __test__.extractUser({
|
|
30
|
+
__typename: 'User',
|
|
31
|
+
core: { screen_name: 'alice', name: 'Alice' },
|
|
32
|
+
legacy: { description: 'bio text', followers_count: 100 },
|
|
33
|
+
});
|
|
34
|
+
expect(user).toMatchObject({
|
|
35
|
+
screen_name: 'alice',
|
|
36
|
+
name: 'Alice',
|
|
37
|
+
bio: 'bio text',
|
|
38
|
+
followers: 100,
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns null for non-User typename', () => {
|
|
43
|
+
expect(__test__.extractUser({ __typename: 'Tweet' })).toBeNull();
|
|
44
|
+
expect(__test__.extractUser(null)).toBeNull();
|
|
45
|
+
expect(__test__.extractUser(undefined)).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('falls back to legacy screen_name if core is missing', () => {
|
|
49
|
+
const user = __test__.extractUser({
|
|
50
|
+
__typename: 'User',
|
|
51
|
+
legacy: { screen_name: 'bob', name: 'Bob', description: '', followers_count: 0 },
|
|
52
|
+
});
|
|
53
|
+
expect(user?.screen_name).toBe('bob');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('parses following timeline with users and cursor', () => {
|
|
57
|
+
const payload = {
|
|
58
|
+
data: {
|
|
59
|
+
user: {
|
|
60
|
+
result: {
|
|
61
|
+
timeline_v2: {
|
|
62
|
+
timeline: {
|
|
63
|
+
instructions: [{
|
|
64
|
+
entries: [
|
|
65
|
+
{
|
|
66
|
+
entryId: 'user-1',
|
|
67
|
+
content: {
|
|
68
|
+
itemContent: {
|
|
69
|
+
user_results: {
|
|
70
|
+
result: {
|
|
71
|
+
__typename: 'User',
|
|
72
|
+
core: { screen_name: 'bob', name: 'Bob' },
|
|
73
|
+
legacy: { description: 'hello', followers_count: 50 },
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
entryId: 'user-2',
|
|
81
|
+
content: {
|
|
82
|
+
itemContent: {
|
|
83
|
+
user_results: {
|
|
84
|
+
result: {
|
|
85
|
+
__typename: 'User',
|
|
86
|
+
core: { screen_name: 'carol', name: 'Carol' },
|
|
87
|
+
legacy: { description: 'world', followers_count: 200 },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
entryId: 'cursor-bottom-1',
|
|
95
|
+
content: {
|
|
96
|
+
entryType: 'TimelineTimelineCursor',
|
|
97
|
+
cursorType: 'Bottom',
|
|
98
|
+
value: 'next-cursor',
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
}],
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
const result = __test__.parseFollowing(payload);
|
|
110
|
+
expect(result.users).toHaveLength(2);
|
|
111
|
+
expect(result.users[0]).toMatchObject({ screen_name: 'bob', name: 'Bob', followers: 50 });
|
|
112
|
+
expect(result.users[1]).toMatchObject({ screen_name: 'carol', name: 'Carol', followers: 200 });
|
|
113
|
+
expect(result.nextCursor).toBe('next-cursor');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('handles cursor-bottom entryId pattern', () => {
|
|
117
|
+
const payload = {
|
|
118
|
+
data: {
|
|
119
|
+
user: {
|
|
120
|
+
result: {
|
|
121
|
+
timeline: {
|
|
122
|
+
timeline: {
|
|
123
|
+
instructions: [{
|
|
124
|
+
entries: [
|
|
125
|
+
{
|
|
126
|
+
entryId: 'cursor-bottom-0',
|
|
127
|
+
content: {
|
|
128
|
+
itemContent: { value: 'cursor-val' },
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
}],
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
const result = __test__.parseFollowing(payload);
|
|
140
|
+
expect(result.nextCursor).toBe('cursor-val');
|
|
141
|
+
expect(result.users).toHaveLength(0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('returns empty users and null cursor for missing instructions', () => {
|
|
145
|
+
const result = __test__.parseFollowing({ data: { user: { result: {} } } });
|
|
146
|
+
expect(result.users).toHaveLength(0);
|
|
147
|
+
expect(result.nextCursor).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('returns empty for completely empty payload', () => {
|
|
151
|
+
const result = __test__.parseFollowing({});
|
|
152
|
+
expect(result.users).toHaveLength(0);
|
|
153
|
+
expect(result.nextCursor).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('normalizes screen names for CLI and profile-link inputs', () => {
|
|
157
|
+
expect(__test__.normalizeScreenName('@elonmusk')).toBe('elonmusk');
|
|
158
|
+
expect(__test__.normalizeScreenName('/elonmusk')).toBe('elonmusk');
|
|
159
|
+
expect(__test__.normalizeScreenName(' @@alice ')).toBe('alice');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
function followingPayload(users, cursor) {
|
|
164
|
+
return {
|
|
165
|
+
data: {
|
|
166
|
+
user: {
|
|
167
|
+
result: {
|
|
168
|
+
timeline_v2: {
|
|
169
|
+
timeline: {
|
|
170
|
+
instructions: [{
|
|
171
|
+
entries: [
|
|
172
|
+
...users.map((name) => ({
|
|
173
|
+
entryId: `user-${name}`,
|
|
174
|
+
content: {
|
|
175
|
+
itemContent: {
|
|
176
|
+
user_results: {
|
|
177
|
+
result: {
|
|
178
|
+
__typename: 'User',
|
|
179
|
+
core: { screen_name: name, name: name.toUpperCase() },
|
|
180
|
+
legacy: { description: `${name} bio`, followers_count: 10 },
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
})),
|
|
186
|
+
...(cursor ? [{
|
|
187
|
+
entryId: `cursor-bottom-${cursor}`,
|
|
188
|
+
content: {
|
|
189
|
+
entryType: 'TimelineTimelineCursor',
|
|
190
|
+
cursorType: 'Bottom',
|
|
191
|
+
value: cursor,
|
|
192
|
+
},
|
|
193
|
+
}] : []),
|
|
194
|
+
],
|
|
195
|
+
}],
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function createFollowingPage(followingResponses, { ct0 = 'token', userLookup = { userId: '42' } } = {}) {
|
|
205
|
+
const page = {
|
|
206
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
207
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
208
|
+
evaluate: vi.fn(async (script) => {
|
|
209
|
+
if (script.includes('document.cookie')) return ct0;
|
|
210
|
+
if (script.includes('operationName')) return null;
|
|
211
|
+
if (script.includes('/UserByScreenName')) return userLookup;
|
|
212
|
+
if (script.includes('/Following')) return followingResponses.shift() || followingPayload([], null);
|
|
213
|
+
if (script.includes('AppTabBar_Profile_Link')) return '/viewer';
|
|
214
|
+
throw new Error(`Unexpected evaluate script: ${script.slice(0, 80)}`);
|
|
215
|
+
}),
|
|
216
|
+
};
|
|
217
|
+
return page;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
describe('twitter following command', () => {
|
|
221
|
+
it('paginates with cursor, deduplicates users, strips @, and respects limit', async () => {
|
|
222
|
+
const command = getRegistry().get('twitter/following');
|
|
223
|
+
const page = createFollowingPage([
|
|
224
|
+
followingPayload(['alice', 'bob'], 'cursor-1'),
|
|
225
|
+
followingPayload(['bob', 'carol', 'dave'], null),
|
|
226
|
+
]);
|
|
227
|
+
|
|
228
|
+
const rows = await command.func(page, { user: '@elonmusk', limit: 3 });
|
|
229
|
+
|
|
230
|
+
expect(rows.map((row) => row.screen_name)).toEqual(['alice', 'bob', 'carol']);
|
|
231
|
+
const userLookupScript = page.evaluate.mock.calls.find(([script]) => script.includes('/UserByScreenName'))?.[0] || '';
|
|
232
|
+
expect(decodeURIComponent(userLookupScript)).toContain('"screen_name":"elonmusk"');
|
|
233
|
+
expect(decodeURIComponent(userLookupScript)).not.toContain('"screen_name":"@elonmusk"');
|
|
234
|
+
const followingCalls = page.evaluate.mock.calls.filter(([script]) => script.includes('/Following'));
|
|
235
|
+
expect(followingCalls).toHaveLength(2);
|
|
236
|
+
expect(decodeURIComponent(followingCalls[1][0])).toContain('"cursor":"cursor-1"');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('rejects invalid limits before navigating', async () => {
|
|
240
|
+
const command = getRegistry().get('twitter/following');
|
|
241
|
+
const page = createFollowingPage([]);
|
|
242
|
+
|
|
243
|
+
await expect(command.func(page, { user: 'elonmusk', limit: 0 })).rejects.toBeInstanceOf(ArgumentError);
|
|
244
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('maps first-page auth failures to AuthRequiredError', async () => {
|
|
248
|
+
const command = getRegistry().get('twitter/following');
|
|
249
|
+
const page = createFollowingPage([{ error: 401 }]);
|
|
250
|
+
|
|
251
|
+
await expect(command.func(page, { user: 'elonmusk', limit: 10 })).rejects.toBeInstanceOf(AuthRequiredError);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('does not silently return partial rows when a later page fails', async () => {
|
|
255
|
+
const command = getRegistry().get('twitter/following');
|
|
256
|
+
const page = createFollowingPage([
|
|
257
|
+
followingPayload(['alice'], 'cursor-1'),
|
|
258
|
+
{ error: 429 },
|
|
259
|
+
]);
|
|
260
|
+
|
|
261
|
+
await expect(command.func(page, { user: 'elonmusk', limit: 10 })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('maps user lookup auth failures to AuthRequiredError', async () => {
|
|
265
|
+
const command = getRegistry().get('twitter/following');
|
|
266
|
+
const page = createFollowingPage([], { userLookup: { error: 403 } });
|
|
267
|
+
|
|
268
|
+
await expect(command.func(page, { user: 'elonmusk', limit: 10 })).rejects.toBeInstanceOf(AuthRequiredError);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('fails fast when the following timeline is empty', async () => {
|
|
272
|
+
const command = getRegistry().get('twitter/following');
|
|
273
|
+
const page = createFollowingPage([followingPayload([], null)]);
|
|
274
|
+
|
|
275
|
+
await expect(command.func(page, { user: 'elonmusk', limit: 10 })).rejects.toBeInstanceOf(EmptyResultError);
|
|
276
|
+
});
|
|
277
|
+
});
|
package/clis/twitter/post.js
CHANGED
|
@@ -2,10 +2,18 @@ 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
|
+
|
|
5
6
|
const MAX_IMAGES = 4;
|
|
6
7
|
const UPLOAD_POLL_MS = 500;
|
|
7
8
|
const UPLOAD_TIMEOUT_MS = 30_000;
|
|
9
|
+
const COMPOSER_POLL_MS = 250;
|
|
10
|
+
const COMPOSER_TIMEOUT_MS = 10_000;
|
|
11
|
+
const SUBMIT_POLL_MS = 500;
|
|
12
|
+
const SUBMIT_TIMEOUT_MS = 15_000;
|
|
13
|
+
const COMPOSE_URL = 'https://x.com/compose/post';
|
|
14
|
+
const FILE_INPUT_SELECTOR = 'input[type="file"][data-testid="fileInput"]';
|
|
8
15
|
const SUPPORTED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
|
|
16
|
+
|
|
9
17
|
function validateImagePaths(raw) {
|
|
10
18
|
const paths = raw.split(',').map(s => s.trim()).filter(Boolean);
|
|
11
19
|
if (paths.length > MAX_IMAGES) {
|
|
@@ -24,6 +32,156 @@ function validateImagePaths(raw) {
|
|
|
24
32
|
return absPath;
|
|
25
33
|
});
|
|
26
34
|
}
|
|
35
|
+
|
|
36
|
+
function isUnsupportedInsertTextError(err) {
|
|
37
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
38
|
+
const lower = msg.toLowerCase();
|
|
39
|
+
return lower.includes('unknown action') || lower.includes('not supported') || lower.includes('inserttext returned no inserted flag');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function focusComposer(page) {
|
|
43
|
+
return page.evaluate(`(() => {
|
|
44
|
+
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
|
|
45
|
+
const boxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]'));
|
|
46
|
+
const box = boxes.find(visible) || boxes[0];
|
|
47
|
+
if (!box) return { ok: false, message: 'Could not find the tweet composer text area. Are you logged in?' };
|
|
48
|
+
box.focus();
|
|
49
|
+
return { ok: true };
|
|
50
|
+
})()`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function verifyComposerText(page, text) {
|
|
54
|
+
const iterations = Math.ceil(COMPOSER_TIMEOUT_MS / COMPOSER_POLL_MS);
|
|
55
|
+
return page.evaluate(`(async () => {
|
|
56
|
+
const expected = ${JSON.stringify(text)};
|
|
57
|
+
const normalize = s => String(s || '').replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim();
|
|
58
|
+
const normalizedExpected = normalize(expected);
|
|
59
|
+
for (let i = 0; i < ${JSON.stringify(iterations)}; i++) {
|
|
60
|
+
const box = document.querySelector('[data-testid="tweetTextarea_0"]');
|
|
61
|
+
const actual = box ? (box.innerText || box.textContent || '') : '';
|
|
62
|
+
if (box && normalize(actual).includes(normalizedExpected)) return { ok: true };
|
|
63
|
+
await new Promise(r => setTimeout(r, ${JSON.stringify(COMPOSER_POLL_MS)}));
|
|
64
|
+
}
|
|
65
|
+
const box = document.querySelector('[data-testid="tweetTextarea_0"]');
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
message: 'Could not verify tweet text in the composer after typing.',
|
|
69
|
+
actualText: box ? (box.innerText || box.textContent || '') : ''
|
|
70
|
+
};
|
|
71
|
+
})()`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function insertComposerText(page, text) {
|
|
75
|
+
const focusResult = await focusComposer(page);
|
|
76
|
+
if (!focusResult?.ok) return focusResult;
|
|
77
|
+
|
|
78
|
+
const nativeInserters = [
|
|
79
|
+
page.nativeType?.bind(page),
|
|
80
|
+
page.insertText?.bind(page),
|
|
81
|
+
].filter(Boolean);
|
|
82
|
+
|
|
83
|
+
for (const insert of nativeInserters) {
|
|
84
|
+
try {
|
|
85
|
+
// Native CDP Input.insertText updates Twitter/X's Draft.js editor much more
|
|
86
|
+
// reliably than synthetic paste/input events. Prefer the Page CDP helper
|
|
87
|
+
// when available because older Browser Bridge insert-text can report
|
|
88
|
+
// inserted while the editor state does not change after media upload.
|
|
89
|
+
await insert(text);
|
|
90
|
+
const verified = await verifyComposerText(page, text);
|
|
91
|
+
if (verified?.ok) return verified;
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
if (!isUnsupportedInsertTextError(err)) throw err;
|
|
95
|
+
// Older Browser Bridge versions do not expose this insertion path; try the next one.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return page.evaluate(`(async () => {
|
|
100
|
+
try {
|
|
101
|
+
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
|
|
102
|
+
const boxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]'));
|
|
103
|
+
const box = boxes.find(visible) || boxes[0];
|
|
104
|
+
if (!box) return { ok: false, message: 'Could not find the tweet composer text area. Are you logged in?' };
|
|
105
|
+
const textToInsert = ${JSON.stringify(text)};
|
|
106
|
+
const normalize = s => String(s || '').replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim();
|
|
107
|
+
box.focus();
|
|
108
|
+
if (!document.execCommand('insertText', false, textToInsert)) {
|
|
109
|
+
const dt = new DataTransfer();
|
|
110
|
+
dt.setData('text/plain', textToInsert);
|
|
111
|
+
box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
|
|
112
|
+
}
|
|
113
|
+
await new Promise(r => setTimeout(r, 500));
|
|
114
|
+
const actual = box.innerText || box.textContent || '';
|
|
115
|
+
if (normalize(actual).includes(normalize(textToInsert))) return { ok: true };
|
|
116
|
+
return { ok: false, message: 'Could not verify tweet text in the composer after typing.', actualText: actual };
|
|
117
|
+
} catch (e) { return { ok: false, message: String(e) }; }
|
|
118
|
+
})()`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function waitForImageUpload(page, expectedCount) {
|
|
122
|
+
const iterations = Math.ceil(UPLOAD_TIMEOUT_MS / UPLOAD_POLL_MS);
|
|
123
|
+
return page.evaluate(`(async () => {
|
|
124
|
+
const expected = ${JSON.stringify(expectedCount)};
|
|
125
|
+
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
|
|
126
|
+
for (let i = 0; i < ${JSON.stringify(iterations)}; i++) {
|
|
127
|
+
await new Promise(r => setTimeout(r, ${JSON.stringify(UPLOAD_POLL_MS)}));
|
|
128
|
+
const attachments = document.querySelector('[data-testid="attachments"]');
|
|
129
|
+
const previewCount = Math.max(
|
|
130
|
+
attachments ? attachments.querySelectorAll('[role="group"], img, video').length : 0,
|
|
131
|
+
document.querySelectorAll('[data-testid="tweetPhoto"], img[src^="blob:"], video[src^="blob:"]').length,
|
|
132
|
+
Array.from(document.querySelectorAll('button,[role="button"]')).filter((el) =>
|
|
133
|
+
/remove media|remove image|remove/i.test((el.getAttribute('aria-label') || '') + ' ' + (el.textContent || ''))
|
|
134
|
+
).length
|
|
135
|
+
);
|
|
136
|
+
const button = Array.from(document.querySelectorAll('[data-testid="tweetButtonInline"], [data-testid="tweetButton"]'))
|
|
137
|
+
.find((el) => visible(el));
|
|
138
|
+
const buttonReady = !!button && !button.disabled && button.getAttribute('aria-disabled') !== 'true';
|
|
139
|
+
if (previewCount >= expected && buttonReady) return { ok: true, previewCount };
|
|
140
|
+
}
|
|
141
|
+
return { ok: false, message: 'Image upload timed out (${UPLOAD_TIMEOUT_MS / 1000}s).' };
|
|
142
|
+
})()`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function submitTweet(page, text) {
|
|
146
|
+
const clickResult = await page.evaluate(`(async () => {
|
|
147
|
+
try {
|
|
148
|
+
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
|
|
149
|
+
const buttons = Array.from(document.querySelectorAll('[data-testid="tweetButtonInline"], [data-testid="tweetButton"]'));
|
|
150
|
+
const btn = buttons.find((el) => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true');
|
|
151
|
+
if (!btn) return { ok: false, message: 'Tweet button is disabled or not found.' };
|
|
152
|
+
btn.click();
|
|
153
|
+
return { ok: true };
|
|
154
|
+
} catch (e) { return { ok: false, message: String(e) }; }
|
|
155
|
+
})()`);
|
|
156
|
+
if (!clickResult?.ok) return clickResult;
|
|
157
|
+
|
|
158
|
+
const iterations = Math.ceil(SUBMIT_TIMEOUT_MS / SUBMIT_POLL_MS);
|
|
159
|
+
return page.evaluate(`(async () => {
|
|
160
|
+
const expected = ${JSON.stringify(text)};
|
|
161
|
+
const normalize = s => String(s || '').replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim();
|
|
162
|
+
const expectedText = normalize(expected);
|
|
163
|
+
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
|
|
164
|
+
for (let i = 0; i < ${JSON.stringify(iterations)}; i++) {
|
|
165
|
+
await new Promise(r => setTimeout(r, ${JSON.stringify(SUBMIT_POLL_MS)}));
|
|
166
|
+
const toasts = Array.from(document.querySelectorAll('[role="alert"], [data-testid="toast"]'))
|
|
167
|
+
.filter((el) => visible(el));
|
|
168
|
+
const successToast = toasts.find((el) => /sent|posted|your post was sent|your tweet was sent/i.test(el.textContent || ''));
|
|
169
|
+
if (successToast) return { ok: true, message: 'Tweet posted successfully.' };
|
|
170
|
+
const alert = toasts.find((el) => /failed|error|try again|not sent|could not/i.test(el.textContent || ''));
|
|
171
|
+
if (alert) return { ok: false, message: (alert.textContent || 'Tweet failed to post.').trim() };
|
|
172
|
+
|
|
173
|
+
const boxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]')).filter(visible);
|
|
174
|
+
const composerStillHasText = boxes.some((box) => normalize(box.innerText || box.textContent || '').includes(expectedText));
|
|
175
|
+
const hasMedia = !!document.querySelector('[data-testid="attachments"], [data-testid="tweetPhoto"]')
|
|
176
|
+
|| document.querySelectorAll('img[src^="blob:"], video[src^="blob:"]').length > 0;
|
|
177
|
+
if (!composerStillHasText && !hasMedia) {
|
|
178
|
+
return { ok: true, message: 'Tweet posted successfully.' };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { ok: false, message: 'Tweet submission did not complete before timeout.' };
|
|
182
|
+
})()`);
|
|
183
|
+
}
|
|
184
|
+
|
|
27
185
|
cli({
|
|
28
186
|
site: 'twitter',
|
|
29
187
|
name: 'post',
|
|
@@ -39,60 +197,39 @@ cli({
|
|
|
39
197
|
func: async (page, kwargs) => {
|
|
40
198
|
if (!page)
|
|
41
199
|
throw new CommandExecutionError('Browser session required for twitter post');
|
|
42
|
-
|
|
200
|
+
|
|
201
|
+
// Validate images upfront before any browser interaction.
|
|
43
202
|
const absPaths = kwargs.images ? validateImagePaths(String(kwargs.images)) : [];
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const dt = new DataTransfer();
|
|
54
|
-
dt.setData('text/plain', ${JSON.stringify(kwargs.text)});
|
|
55
|
-
box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
|
|
56
|
-
return { ok: true };
|
|
57
|
-
} catch (e) { return { ok: false, message: String(e) }; }
|
|
58
|
-
})()`);
|
|
59
|
-
if (!typeResult.ok) {
|
|
60
|
-
return [{ status: 'failed', message: typeResult.message, text: kwargs.text }];
|
|
61
|
-
}
|
|
62
|
-
// 3. Attach images if provided
|
|
203
|
+
const text = String(kwargs.text ?? '');
|
|
204
|
+
|
|
205
|
+
// The current X standalone composer is /compose/post. It keeps a single,
|
|
206
|
+
// visible composer and is the same route used by the reply command.
|
|
207
|
+
await page.goto(COMPOSE_URL, { waitUntil: 'load', settleMs: 2500 });
|
|
208
|
+
await page.wait({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
|
|
209
|
+
|
|
210
|
+
// Attach media before inserting text. Uploading media after Draft.js has
|
|
211
|
+
// text can re-render/reset the editor, causing image-only posts.
|
|
63
212
|
if (absPaths.length > 0) {
|
|
64
213
|
if (!page.setFileInput) {
|
|
65
214
|
throw new CommandExecutionError('Browser extension does not support file upload. Please update the extension.');
|
|
66
215
|
}
|
|
67
|
-
await page.
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
await new Promise(r => setTimeout(r, ${JSON.stringify(UPLOAD_POLL_MS)}));
|
|
73
|
-
const container = document.querySelector('[data-testid="attachments"]');
|
|
74
|
-
if (!container) continue;
|
|
75
|
-
if (container.querySelectorAll('[role="group"]').length !== ${JSON.stringify(absPaths.length)}) continue;
|
|
76
|
-
const btn = document.querySelector('[data-testid="tweetButton"]') || document.querySelector('[data-testid="tweetButtonInline"]');
|
|
77
|
-
if (btn && !btn.disabled) return true;
|
|
78
|
-
}
|
|
79
|
-
return false;
|
|
80
|
-
})()`);
|
|
81
|
-
if (!uploaded) {
|
|
82
|
-
return [{ status: 'failed', message: `Image upload timed out (${UPLOAD_TIMEOUT_MS / 1000}s).`, text: kwargs.text }];
|
|
216
|
+
await page.wait({ selector: FILE_INPUT_SELECTOR, timeout: 20 });
|
|
217
|
+
await page.setFileInput(absPaths, FILE_INPUT_SELECTOR);
|
|
218
|
+
const uploadState = await waitForImageUpload(page, absPaths.length);
|
|
219
|
+
if (!uploadState?.ok) {
|
|
220
|
+
return [{ status: 'failed', message: uploadState?.message ?? `Image upload timed out (${UPLOAD_TIMEOUT_MS / 1000}s).`, text }];
|
|
83
221
|
}
|
|
84
222
|
}
|
|
85
|
-
|
|
223
|
+
|
|
224
|
+
// Insert and verify the text after media upload so text + images are in
|
|
225
|
+
// the final Draft.js composer state immediately before clicking Post.
|
|
226
|
+
const typeResult = await insertComposerText(page, text);
|
|
227
|
+
if (!typeResult?.ok) {
|
|
228
|
+
return [{ status: 'failed', message: typeResult?.message ?? 'Could not type tweet text.', text }];
|
|
229
|
+
}
|
|
230
|
+
|
|
86
231
|
await page.wait(1);
|
|
87
|
-
const result = await page
|
|
88
|
-
|
|
89
|
-
const btn = document.querySelector('[data-testid="tweetButton"]') || document.querySelector('[data-testid="tweetButtonInline"]');
|
|
90
|
-
if (btn && !btn.disabled) { btn.click(); return { ok: true, message: 'Tweet posted successfully.' }; }
|
|
91
|
-
return { ok: false, message: 'Tweet button is disabled or not found.' };
|
|
92
|
-
} catch (e) { return { ok: false, message: String(e) }; }
|
|
93
|
-
})()`);
|
|
94
|
-
if (result.ok)
|
|
95
|
-
await page.wait(3);
|
|
96
|
-
return [{ status: result.ok ? 'success' : 'failed', message: result.message, text: kwargs.text }];
|
|
232
|
+
const result = await submitTweet(page, text);
|
|
233
|
+
return [{ status: result?.ok ? 'success' : 'failed', message: result?.message ?? 'Tweet failed to post.', text }];
|
|
97
234
|
}
|
|
98
235
|
});
|