@jackwener/opencli 1.7.21 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -148
- package/README.zh-CN.md +38 -211
- package/cli-manifest.json +6423 -4260
- 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/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/summary.js +167 -0
- package/clis/bilibili/summary.test.js +210 -0
- package/clis/booking/booking.test.js +356 -0
- package/clis/booking/search.js +351 -0
- package/clis/boss/utils.js +17 -1
- package/clis/boss/utils.test.js +34 -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/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/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/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/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/people-search.js +262 -0
- package/clis/linkedin/people-search.test.js +216 -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/thread-snapshot.js +214 -0
- package/clis/linkedin/thread-snapshot.test.js +89 -0
- 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/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 +226 -0
- package/clis/suno/generate.test.js +243 -0
- package/clis/suno/list.js +79 -0
- package/clis/suno/status.js +62 -0
- package/clis/suno/utils.js +540 -0
- package/clis/suno/utils.test.js +223 -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/list-create.js +155 -0
- package/clis/twitter/list-create.test.js +169 -0
- package/clis/twitter/list-remove.js +12 -5
- 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/search.js +6 -2
- package/clis/twitter/search.test.js +6 -0
- package/clis/twitter/shared.js +144 -0
- package/clis/twitter/shared.test.js +429 -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/weibo/comments.js +3 -4
- package/clis/weibo/delete.js +172 -0
- package/clis/weibo/delete.test.js +94 -0
- package/clis/weibo/envelope.test.js +85 -0
- package/clis/weibo/favorites.js +4 -4
- package/clis/weibo/feed.js +3 -5
- package/clis/weibo/hot.js +3 -4
- package/clis/weibo/me.js +3 -5
- package/clis/weibo/post.js +3 -4
- package/clis/weibo/publish.js +37 -14
- package/clis/weibo/publish.test.js +14 -5
- package/clis/weibo/search.js +4 -3
- package/clis/weibo/user-posts.js +234 -0
- package/clis/weibo/user-posts.test.js +92 -0
- package/clis/weibo/user.js +3 -4
- package/clis/weibo/utils.js +34 -5
- package/clis/weibo/utils.test.js +36 -0
- package/clis/weread/search-regression.test.js +18 -11
- package/clis/weread/search.js +15 -7
- 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 +2 -1
- package/clis/xiaohongshu/creator-note-detail.test.js +11 -0
- 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 +2 -1
- package/clis/xiaohongshu/creator-notes.test.js +12 -0
- 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/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 +299 -0
- package/clis/zhihu/answer-comments.test.js +287 -0
- package/clis/zhihu/answer-detail.js +12 -0
- package/clis/zhihu/answer-detail.test.js +8 -0
- package/clis/zhihu/collection.js +15 -2
- package/clis/zhihu/collection.test.js +46 -0
- package/clis/zhihu/download.js +1 -1
- package/clis/zhihu/question.js +42 -9
- package/clis/zhihu/question.test.js +111 -9
- package/clis/zhihu/search.js +206 -43
- package/clis/zhihu/search.test.js +198 -0
- package/dist/src/browser/errors.js +4 -2
- package/dist/src/browser/errors.test.js +6 -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/cli.js +1 -1
- package/dist/src/convention-audit.js +15 -8
- package/dist/src/convention-audit.test.js +21 -0
- 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 +110 -0
- 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 +20 -0
- package/dist/src/external.d.ts +6 -1
- package/dist/src/external.test.js +19 -0
- package/dist/src/main.js +14 -2
- 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
package/clis/twitter/thread.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
2
|
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
-
import { extractMedia } from './shared.js';
|
|
3
|
+
import { extractMedia, extractCard, extractQuotedTweet } from './shared.js';
|
|
4
4
|
import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
|
|
5
5
|
// ── Twitter GraphQL constants ──────────────────────────────────────────
|
|
6
6
|
const TWEET_DETAIL_QUERY_ID = 'nBS-WpgA6ZG0CyNHD517JQ';
|
|
@@ -46,9 +46,11 @@ function extractTweet(r, seen) {
|
|
|
46
46
|
const u = tw.core?.user_results?.result;
|
|
47
47
|
const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
|
|
48
48
|
const screenName = u?.legacy?.screen_name || u?.core?.screen_name || 'unknown';
|
|
49
|
+
const bio = u?.legacy?.description || '';
|
|
49
50
|
return {
|
|
50
51
|
id: tw.rest_id,
|
|
51
52
|
author: screenName,
|
|
53
|
+
bio,
|
|
52
54
|
text: noteText || l.full_text || '',
|
|
53
55
|
likes: l.favorite_count || 0,
|
|
54
56
|
retweets: l.retweet_count || 0,
|
|
@@ -56,6 +58,8 @@ function extractTweet(r, seen) {
|
|
|
56
58
|
created_at: l.created_at,
|
|
57
59
|
url: `https://x.com/${screenName}/status/${tw.rest_id}`,
|
|
58
60
|
...extractMedia(l),
|
|
61
|
+
card: extractCard(tw),
|
|
62
|
+
quoted_tweet: extractQuotedTweet(tw),
|
|
59
63
|
};
|
|
60
64
|
}
|
|
61
65
|
function parseTweetDetail(data, seen) {
|
|
@@ -91,6 +95,10 @@ function parseTweetDetail(data, seen) {
|
|
|
91
95
|
}
|
|
92
96
|
return { tweets, nextCursor };
|
|
93
97
|
}
|
|
98
|
+
|
|
99
|
+
export const __test__ = {
|
|
100
|
+
parseTweetDetail,
|
|
101
|
+
};
|
|
94
102
|
// ── CLI definition ────────────────────────────────────────────────────
|
|
95
103
|
cli({
|
|
96
104
|
site: 'twitter',
|
|
@@ -105,7 +113,7 @@ cli({
|
|
|
105
113
|
{ name: 'limit', type: 'int', default: 50 },
|
|
106
114
|
{ name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the thread by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the conversation\'s structural ordering.' },
|
|
107
115
|
],
|
|
108
|
-
columns: ['id', 'author', 'text', 'likes', 'retweets', 'url', 'has_media', 'media_urls'],
|
|
116
|
+
columns: ['id', 'author', 'bio', 'text', 'likes', 'retweets', 'url', 'has_media', 'media_urls', 'card', 'quoted_tweet'],
|
|
109
117
|
func: async (page, kwargs) => {
|
|
110
118
|
let tweetId = kwargs['tweet-id'];
|
|
111
119
|
const urlMatch = tweetId.match(/\/status\/(\d+)/);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { __test__ } from './thread.js';
|
|
4
|
+
|
|
5
|
+
describe('twitter thread parser', () => {
|
|
6
|
+
it('extracts author bio from tweet user entity', () => {
|
|
7
|
+
const command = getRegistry().get('twitter/thread');
|
|
8
|
+
expect(command?.columns).toEqual(['id', 'author', 'bio', 'text', 'likes', 'retweets', 'url', 'has_media', 'media_urls', 'card', 'quoted_tweet']);
|
|
9
|
+
const result = __test__.parseTweetDetail({
|
|
10
|
+
data: {
|
|
11
|
+
threaded_conversation_with_injections_v2: {
|
|
12
|
+
instructions: [
|
|
13
|
+
{
|
|
14
|
+
entries: [
|
|
15
|
+
{
|
|
16
|
+
content: {
|
|
17
|
+
itemContent: {
|
|
18
|
+
tweet_results: {
|
|
19
|
+
result: {
|
|
20
|
+
rest_id: '1',
|
|
21
|
+
legacy: {
|
|
22
|
+
full_text: 'thread tweet',
|
|
23
|
+
favorite_count: 3,
|
|
24
|
+
retweet_count: 2,
|
|
25
|
+
},
|
|
26
|
+
core: {
|
|
27
|
+
user_results: {
|
|
28
|
+
result: {
|
|
29
|
+
legacy: {
|
|
30
|
+
screen_name: 'alice',
|
|
31
|
+
description: 'Thread author bio',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
}, new Set());
|
|
47
|
+
expect(result.tweets).toHaveLength(1);
|
|
48
|
+
expect(result.tweets[0]).toMatchObject({
|
|
49
|
+
id: '1',
|
|
50
|
+
author: 'alice',
|
|
51
|
+
bio: 'Thread author bio',
|
|
52
|
+
text: 'thread tweet',
|
|
53
|
+
likes: 3,
|
|
54
|
+
retweets: 2,
|
|
55
|
+
url: 'https://x.com/alice/status/1',
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
});
|
package/clis/twitter/timeline.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
2
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
-
import { resolveTwitterQueryId, extractMedia } from './shared.js';
|
|
3
|
+
import { resolveTwitterQueryId, extractMedia, extractCard, extractQuotedTweet } from './shared.js';
|
|
4
4
|
import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
|
|
5
5
|
// ── Twitter GraphQL constants ──────────────────────────────────────────
|
|
6
6
|
const HOME_TIMELINE_QUERY_ID = 'c-CzHF1LboFilMpsx4ZCrQ';
|
|
@@ -74,11 +74,13 @@ function extractTweet(result, seen) {
|
|
|
74
74
|
seen.add(tw.rest_id);
|
|
75
75
|
const u = tw.core?.user_results?.result;
|
|
76
76
|
const screenName = u?.legacy?.screen_name || u?.core?.screen_name || 'unknown';
|
|
77
|
+
const bio = u?.legacy?.description || '';
|
|
77
78
|
const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
|
|
78
79
|
const views = tw.views?.count ? parseInt(tw.views.count, 10) : 0;
|
|
79
80
|
return {
|
|
80
81
|
id: tw.rest_id,
|
|
81
82
|
author: screenName,
|
|
83
|
+
bio,
|
|
82
84
|
text: noteText || l.full_text || '',
|
|
83
85
|
likes: l.favorite_count || 0,
|
|
84
86
|
retweets: l.retweet_count || 0,
|
|
@@ -87,6 +89,8 @@ function extractTweet(result, seen) {
|
|
|
87
89
|
created_at: l.created_at || '',
|
|
88
90
|
url: `https://x.com/${screenName}/status/${tw.rest_id}`,
|
|
89
91
|
...extractMedia(l),
|
|
92
|
+
card: extractCard(tw),
|
|
93
|
+
quoted_tweet: extractQuotedTweet(tw),
|
|
90
94
|
};
|
|
91
95
|
}
|
|
92
96
|
function parseHomeTimeline(data, seen) {
|
|
@@ -152,7 +156,7 @@ cli({
|
|
|
152
156
|
{ name: 'limit', type: 'int', default: 20, help: 'Maximum number of tweets to return (default 20).' },
|
|
153
157
|
{ name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the timeline by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps X\'s native ordering.' },
|
|
154
158
|
],
|
|
155
|
-
columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'views', 'created_at', 'url', 'has_media', 'media_urls'],
|
|
159
|
+
columns: ['id', 'author', 'bio', 'text', 'likes', 'retweets', 'replies', 'views', 'created_at', 'url', 'has_media', 'media_urls', 'card', 'quoted_tweet'],
|
|
156
160
|
func: async (page, kwargs) => {
|
|
157
161
|
const limit = kwargs.limit || 20;
|
|
158
162
|
const timelineType = kwargs.type === 'following' ? 'following' : 'for-you';
|
|
@@ -57,6 +57,7 @@ describe('twitter timeline helpers', () => {
|
|
|
57
57
|
result: {
|
|
58
58
|
legacy: {
|
|
59
59
|
screen_name: 'alice',
|
|
60
|
+
description: 'Timeline author bio',
|
|
60
61
|
},
|
|
61
62
|
},
|
|
62
63
|
},
|
|
@@ -90,6 +91,7 @@ describe('twitter timeline helpers', () => {
|
|
|
90
91
|
expect(result.tweets[0]).toMatchObject({
|
|
91
92
|
id: '1',
|
|
92
93
|
author: 'alice',
|
|
94
|
+
bio: 'Timeline author bio',
|
|
93
95
|
text: 'hello',
|
|
94
96
|
likes: 3,
|
|
95
97
|
retweets: 2,
|
package/clis/twitter/tweets.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
2
|
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
-
import { resolveTwitterOperationMetadata, sanitizeQueryId, extractMedia, normalizeTwitterGraphqlPayload, unwrapBrowserResult } from './shared.js';
|
|
3
|
+
import { resolveTwitterOperationMetadata, sanitizeQueryId, extractMedia, extractQuotedTweet, normalizeTwitterGraphqlPayload, unwrapBrowserResult } from './shared.js';
|
|
4
4
|
import { normalizeTwitterScreenName } from './shared.js';
|
|
5
5
|
import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
|
|
6
6
|
|
|
@@ -175,6 +175,7 @@ function extractTweet(result, seen) {
|
|
|
175
175
|
created_at: legacy.created_at || '',
|
|
176
176
|
url: `https://x.com/${screenName}/status/${tw.rest_id}`,
|
|
177
177
|
...extractMedia(legacy),
|
|
178
|
+
quoted_tweet: extractQuotedTweet(tw),
|
|
178
179
|
};
|
|
179
180
|
}
|
|
180
181
|
|
|
@@ -226,7 +227,7 @@ cli({
|
|
|
226
227
|
{ name: 'limit', type: 'int', default: 20, help: 'Max tweets to return' },
|
|
227
228
|
{ name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the tweets by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the chronological ordering.' },
|
|
228
229
|
],
|
|
229
|
-
columns: ['id', 'author', 'created_at', 'is_retweet', 'text', 'likes', 'retweets', 'replies', 'views', 'url', 'has_media', 'media_urls'],
|
|
230
|
+
columns: ['id', 'author', 'created_at', 'is_retweet', 'text', 'likes', 'retweets', 'replies', 'views', 'url', 'has_media', 'media_urls', 'quoted_tweet'],
|
|
230
231
|
func: async (page, kwargs) => {
|
|
231
232
|
const limit = Math.max(1, Math.min(200, kwargs.limit || 20));
|
|
232
233
|
const rawUsername = String(kwargs.username ?? '').trim();
|
|
@@ -6,7 +6,7 @@ import { __test__ } from './tweets.js';
|
|
|
6
6
|
describe('twitter tweets helpers', () => {
|
|
7
7
|
it('registers id and is_retweet in the default columns', () => {
|
|
8
8
|
const cmd = getRegistry().get('twitter/tweets');
|
|
9
|
-
expect(cmd?.columns).toEqual(['id', 'author', 'created_at', 'is_retweet', 'text', 'likes', 'retweets', 'replies', 'views', 'url', 'has_media', 'media_urls']);
|
|
9
|
+
expect(cmd?.columns).toEqual(['id', 'author', 'created_at', 'is_retweet', 'text', 'likes', 'retweets', 'replies', 'views', 'url', 'has_media', 'media_urls', 'quoted_tweet']);
|
|
10
10
|
});
|
|
11
11
|
|
|
12
12
|
it('makes the username argument optional so it can default to the logged-in user', () => {
|
package/clis/weibo/comments.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Weibo comments — get comments on a post.
|
|
3
3
|
*/
|
|
4
4
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
5
|
+
import { requireArrayEvaluateResult, unwrapEvaluateResult } from './utils.js';
|
|
5
6
|
cli({
|
|
6
7
|
site: 'weibo',
|
|
7
8
|
name: 'comments',
|
|
@@ -19,7 +20,7 @@ cli({
|
|
|
19
20
|
await page.goto('https://weibo.com');
|
|
20
21
|
await page.wait(2);
|
|
21
22
|
const id = String(kwargs.id);
|
|
22
|
-
const data = await page.evaluate(`
|
|
23
|
+
const data = requireArrayEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
|
|
23
24
|
(async () => {
|
|
24
25
|
const id = ${JSON.stringify(id)};
|
|
25
26
|
const count = ${count};
|
|
@@ -46,9 +47,7 @@ cli({
|
|
|
46
47
|
return item;
|
|
47
48
|
});
|
|
48
49
|
})()
|
|
49
|
-
`);
|
|
50
|
-
if (!Array.isArray(data))
|
|
51
|
-
return [];
|
|
50
|
+
`)), 'weibo comments');
|
|
52
51
|
return data;
|
|
53
52
|
},
|
|
54
53
|
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Weibo delete — remove a single post owned by the logged-in user.
|
|
3
|
+
*/
|
|
4
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
5
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
6
|
+
import { requireObjectEvaluateResult, unwrapEvaluateResult } from './utils.js';
|
|
7
|
+
|
|
8
|
+
const WEIBO_HOST_RE = /(^|\.)weibo\.(com|cn)$/i;
|
|
9
|
+
const POST_ID_RE = /^[A-Za-z0-9]{4,32}$/;
|
|
10
|
+
|
|
11
|
+
function normalizePostId(raw) {
|
|
12
|
+
const input = String(raw ?? '').trim();
|
|
13
|
+
if (!input) {
|
|
14
|
+
throw new ArgumentError('weibo delete: id cannot be empty');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let candidate = input;
|
|
18
|
+
try {
|
|
19
|
+
const url = new URL(input);
|
|
20
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
21
|
+
throw new ArgumentError('weibo delete: URL must use http or https');
|
|
22
|
+
}
|
|
23
|
+
if (!WEIBO_HOST_RE.test(url.hostname)) {
|
|
24
|
+
throw new ArgumentError('weibo delete: URL must be a weibo.com or weibo.cn post URL');
|
|
25
|
+
}
|
|
26
|
+
const parts = url.pathname.split('/').filter(Boolean);
|
|
27
|
+
if (url.hostname.toLowerCase().endsWith('weibo.cn') && parts[0] === 'status') {
|
|
28
|
+
candidate = parts[1] ?? '';
|
|
29
|
+
} else {
|
|
30
|
+
candidate = parts.at(-1) ?? '';
|
|
31
|
+
}
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error instanceof ArgumentError) throw error;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
candidate = String(candidate ?? '').trim();
|
|
37
|
+
if (!POST_ID_RE.test(candidate)) {
|
|
38
|
+
throw new ArgumentError('weibo delete: id must be a numeric idstr, mblogid, or Weibo post URL');
|
|
39
|
+
}
|
|
40
|
+
return candidate;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
cli({
|
|
44
|
+
site: 'weibo',
|
|
45
|
+
name: 'delete',
|
|
46
|
+
access: 'write',
|
|
47
|
+
description: 'Delete one of my Weibo posts by id',
|
|
48
|
+
domain: 'weibo.com',
|
|
49
|
+
strategy: Strategy.COOKIE,
|
|
50
|
+
args: [
|
|
51
|
+
{
|
|
52
|
+
name: 'id',
|
|
53
|
+
required: true,
|
|
54
|
+
positional: true,
|
|
55
|
+
help: 'Post ID (numeric idstr or mblogid from URL / weibo me / weibo post output)',
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
columns: ['status', 'id', 'mblogid'],
|
|
59
|
+
func: async (page, kwargs) => {
|
|
60
|
+
if (!page) {
|
|
61
|
+
throw new CommandExecutionError('Browser session required for weibo delete');
|
|
62
|
+
}
|
|
63
|
+
const raw = String(kwargs.id ?? '').trim();
|
|
64
|
+
const id = normalizePostId(raw);
|
|
65
|
+
await page.goto('https://weibo.com');
|
|
66
|
+
await page.wait(2);
|
|
67
|
+
const result = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
|
|
68
|
+
(async () => {
|
|
69
|
+
const input = ${JSON.stringify(id)};
|
|
70
|
+
const readCookie = (name) => {
|
|
71
|
+
const pair = document.cookie.split(';').map(s => s.trim()).find(s => s.startsWith(name + '='));
|
|
72
|
+
return pair ? decodeURIComponent(pair.slice(name.length + 1)) : '';
|
|
73
|
+
};
|
|
74
|
+
// Step 1: resolve mblogid / idstr to canonical idstr via /show.
|
|
75
|
+
const showResp = await fetch('/ajax/statuses/show?id=' + encodeURIComponent(input), { credentials: 'include' });
|
|
76
|
+
if (showResp.status === 401 || showResp.status === 403) {
|
|
77
|
+
return { ok: false, error: 'auth', status: showResp.status };
|
|
78
|
+
}
|
|
79
|
+
// 404 from /show means the post does not exist (deleted, wrong id, or
|
|
80
|
+
// not owned by the logged-in user); map to the same path as a 2xx
|
|
81
|
+
// response with no idstr so the caller throws EmptyResultError
|
|
82
|
+
// instead of a generic CommandExecutionError("HTTP 404").
|
|
83
|
+
if (showResp.status === 404) {
|
|
84
|
+
return { ok: false, error: 'not_found', input };
|
|
85
|
+
}
|
|
86
|
+
if (!showResp.ok) {
|
|
87
|
+
return { ok: false, error: 'show_http', status: showResp.status };
|
|
88
|
+
}
|
|
89
|
+
const showBody = await showResp.json();
|
|
90
|
+
if (!showBody || !showBody.idstr) {
|
|
91
|
+
return { ok: false, error: 'not_found', input };
|
|
92
|
+
}
|
|
93
|
+
const idstr = String(showBody.idstr);
|
|
94
|
+
const mblogid = showBody.mblogid || '';
|
|
95
|
+
// Step 2: destroy. Weibo requires X-Xsrf-Token (double-submit CSRF token).
|
|
96
|
+
const token = readCookie('XSRF-TOKEN');
|
|
97
|
+
const destroyResp = await fetch('/ajax/statuses/destroy', {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
credentials: 'include',
|
|
100
|
+
headers: {
|
|
101
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
102
|
+
'X-Xsrf-Token': token,
|
|
103
|
+
},
|
|
104
|
+
body: 'id=' + encodeURIComponent(idstr),
|
|
105
|
+
});
|
|
106
|
+
if (destroyResp.status === 401 || destroyResp.status === 403) {
|
|
107
|
+
return { ok: false, error: 'auth', status: destroyResp.status };
|
|
108
|
+
}
|
|
109
|
+
if (!destroyResp.ok) {
|
|
110
|
+
return { ok: false, error: 'destroy_http', status: destroyResp.status };
|
|
111
|
+
}
|
|
112
|
+
const destroyBody = await destroyResp.json();
|
|
113
|
+
// Require an explicit success signal from the API: { ok: 1 }. A
|
|
114
|
+
// missing / falsy body must not be silently treated as success.
|
|
115
|
+
if (!destroyBody || typeof destroyBody !== 'object') {
|
|
116
|
+
return { ok: false, error: 'api', msg: 'destroy returned malformed response', id: idstr };
|
|
117
|
+
}
|
|
118
|
+
if (destroyBody.ok !== 1) {
|
|
119
|
+
return { ok: false, error: 'api', msg: destroyBody.msg || destroyBody.message || 'destroy returned non-ok', id: idstr };
|
|
120
|
+
}
|
|
121
|
+
// Step 3: postcondition evidence. A write command cannot report success
|
|
122
|
+
// until the target no longer resolves after the delete API returns ok.
|
|
123
|
+
const verifyResp = await fetch('/ajax/statuses/show?id=' + encodeURIComponent(idstr), { credentials: 'include' });
|
|
124
|
+
if (verifyResp.status === 401 || verifyResp.status === 403) {
|
|
125
|
+
return { ok: false, error: 'auth', status: verifyResp.status };
|
|
126
|
+
}
|
|
127
|
+
if (verifyResp.status === 404) {
|
|
128
|
+
return { ok: true, id: idstr, mblogid };
|
|
129
|
+
}
|
|
130
|
+
if (!verifyResp.ok) {
|
|
131
|
+
return { ok: false, error: 'verify_http', status: verifyResp.status, id: idstr };
|
|
132
|
+
}
|
|
133
|
+
let verifyBody = null;
|
|
134
|
+
try {
|
|
135
|
+
verifyBody = await verifyResp.json();
|
|
136
|
+
} catch {
|
|
137
|
+
return { ok: false, error: 'verify_malformed', msg: 'verify returned non-JSON response', id: idstr };
|
|
138
|
+
}
|
|
139
|
+
if (!verifyBody || typeof verifyBody !== 'object') {
|
|
140
|
+
return { ok: false, error: 'verify_malformed', msg: 'verify returned malformed response', id: idstr };
|
|
141
|
+
}
|
|
142
|
+
if (String(verifyBody.idstr || '') === idstr) {
|
|
143
|
+
return { ok: false, error: 'still_exists', id: idstr, mblogid: verifyBody.mblogid || mblogid };
|
|
144
|
+
}
|
|
145
|
+
if (!verifyBody.idstr || verifyBody.ok === 0) {
|
|
146
|
+
return { ok: true, id: idstr, mblogid };
|
|
147
|
+
}
|
|
148
|
+
return { ok: false, error: 'verify_mismatch', msg: 'verify returned a different post id', id: idstr };
|
|
149
|
+
})()
|
|
150
|
+
`)), 'weibo delete');
|
|
151
|
+
if (result.error === 'auth') {
|
|
152
|
+
throw new AuthRequiredError('weibo.com', 'Cookie 已过期!请在当前 Chrome 浏览器中重新登录 Weibo。');
|
|
153
|
+
}
|
|
154
|
+
if (result.error === 'not_found') {
|
|
155
|
+
throw new EmptyResultError('weibo delete', `Post not found for id "${String(result.input ?? raw)}". Verify the post still exists and belongs to the logged-in account.`);
|
|
156
|
+
}
|
|
157
|
+
if (result.error === 'show_http' || result.error === 'destroy_http' || result.error === 'verify_http') {
|
|
158
|
+
throw new CommandExecutionError(`weibo delete: HTTP ${result.status}`);
|
|
159
|
+
}
|
|
160
|
+
if (result.error === 'api' || result.error === 'verify_malformed' || result.error === 'verify_mismatch' || result.error === 'still_exists') {
|
|
161
|
+
throw new CommandExecutionError(`weibo delete: ${String(result.msg ?? result.error)}`);
|
|
162
|
+
}
|
|
163
|
+
if (!result.ok) {
|
|
164
|
+
throw new CommandExecutionError('weibo delete returned an unexpected response');
|
|
165
|
+
}
|
|
166
|
+
return [{ status: 'deleted', id: String(result.id ?? ''), mblogid: String(result.mblogid ?? '') }];
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
export const __test__ = {
|
|
171
|
+
normalizePostId,
|
|
172
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
|
|
5
|
+
import './delete.js';
|
|
6
|
+
|
|
7
|
+
function makePage(evaluateResult) {
|
|
8
|
+
return {
|
|
9
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
10
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
11
|
+
evaluate: vi.fn().mockResolvedValue(evaluateResult),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('weibo delete command', () => {
|
|
16
|
+
const getCommand = () => getRegistry().get('weibo/delete');
|
|
17
|
+
|
|
18
|
+
it('returns deleted status when the API reports success', async () => {
|
|
19
|
+
const page = makePage({ ok: true, id: '5197123456789012', mblogid: 'Px2yQfXYZ' });
|
|
20
|
+
const result = await getCommand().func(page, { id: 'Px2yQfXYZ' });
|
|
21
|
+
expect(result).toEqual([
|
|
22
|
+
{ status: 'deleted', id: '5197123456789012', mblogid: 'Px2yQfXYZ' },
|
|
23
|
+
]);
|
|
24
|
+
expect(page.goto).toHaveBeenCalledWith('https://weibo.com');
|
|
25
|
+
const script = page.evaluate.mock.calls[0][0];
|
|
26
|
+
expect(script.match(/\/ajax\/statuses\/show/g)).toHaveLength(2);
|
|
27
|
+
expect(script).toContain('/ajax/statuses/destroy');
|
|
28
|
+
expect(script).toContain('still_exists');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('normalizes supported Weibo post URLs before evaluating delete flow', async () => {
|
|
32
|
+
const page = makePage({ ok: true, id: '5197123456789012', mblogid: 'Px2yQfXYZ' });
|
|
33
|
+
const result = await getCommand().func(page, { id: 'https://weibo.com/1234567890/Px2yQfXYZ?refer_flag=1001030103_' });
|
|
34
|
+
|
|
35
|
+
expect(result).toEqual([
|
|
36
|
+
{ status: 'deleted', id: '5197123456789012', mblogid: 'Px2yQfXYZ' },
|
|
37
|
+
]);
|
|
38
|
+
expect(page.evaluate.mock.calls[0][0]).toContain('const input = "Px2yQfXYZ"');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('throws ArgumentError when id is empty or whitespace', async () => {
|
|
42
|
+
const page = makePage({ ok: true, id: '0' });
|
|
43
|
+
await expect(getCommand().func(page, { id: ' ' })).rejects.toBeInstanceOf(ArgumentError);
|
|
44
|
+
await expect(getCommand().func(page, { id: '' })).rejects.toBeInstanceOf(ArgumentError);
|
|
45
|
+
await expect(getCommand().func(page, { id: 'https://example.com/123/Px2yQfXYZ' })).rejects.toBeInstanceOf(ArgumentError);
|
|
46
|
+
await expect(getCommand().func(page, { id: 'javascript:alert(1)' })).rejects.toBeInstanceOf(ArgumentError);
|
|
47
|
+
await expect(getCommand().func(page, { id: '../not-a-post' })).rejects.toBeInstanceOf(ArgumentError);
|
|
48
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('maps 401 / 403 from the show endpoint to AuthRequiredError', async () => {
|
|
52
|
+
const page = makePage({ error: 'auth', status: 401 });
|
|
53
|
+
await expect(getCommand().func(page, { id: 'Px2yQfXYZ' })).rejects.toBeInstanceOf(AuthRequiredError);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('throws EmptyResultError when the post cannot be resolved', async () => {
|
|
57
|
+
const page = makePage({ error: 'not_found', input: 'Px2yQfXYZ' });
|
|
58
|
+
await expect(getCommand().func(page, { id: 'Px2yQfXYZ' })).rejects.toBeInstanceOf(EmptyResultError);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('throws CommandExecutionError on non-2xx show response', async () => {
|
|
62
|
+
const page = makePage({ error: 'show_http', status: 500 });
|
|
63
|
+
await expect(getCommand().func(page, { id: '5197123456789012' })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('throws CommandExecutionError on non-2xx destroy response', async () => {
|
|
67
|
+
const page = makePage({ error: 'destroy_http', status: 502 });
|
|
68
|
+
await expect(getCommand().func(page, { id: '5197123456789012' })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('surfaces API-level errors from destroy as CommandExecutionError with msg', async () => {
|
|
72
|
+
const page = makePage({ error: 'api', msg: '无权限删除', id: '5197123456789012' });
|
|
73
|
+
await expect(getCommand().func(page, { id: '5197123456789012' })).rejects.toThrowError(/无权限删除/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('throws CommandExecutionError when postcondition verification still sees the target', async () => {
|
|
77
|
+
const page = makePage({ error: 'still_exists', id: '5197123456789012', mblogid: 'Px2yQfXYZ' });
|
|
78
|
+
await expect(getCommand().func(page, { id: '5197123456789012' })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('throws CommandExecutionError when postcondition verification is malformed', async () => {
|
|
82
|
+
const page = makePage({ error: 'verify_malformed', msg: 'verify returned malformed response', id: '5197123456789012' });
|
|
83
|
+
await expect(getCommand().func(page, { id: '5197123456789012' })).rejects.toThrowError(/verify returned malformed response/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('unwraps the browser-bridge { session, data } envelope', async () => {
|
|
87
|
+
const page = makePage({
|
|
88
|
+
session: 'site:weibo:abc',
|
|
89
|
+
data: { ok: true, id: '42', mblogid: 'M420' },
|
|
90
|
+
});
|
|
91
|
+
const result = await getCommand().func(page, { id: 'M420' });
|
|
92
|
+
expect(result).toEqual([{ status: 'deleted', id: '42', mblogid: 'M420' }]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
4
|
+
import './comments.js';
|
|
5
|
+
import './favorites.js';
|
|
6
|
+
import './feed.js';
|
|
7
|
+
import './hot.js';
|
|
8
|
+
import './me.js';
|
|
9
|
+
import './post.js';
|
|
10
|
+
import './search.js';
|
|
11
|
+
import './user.js';
|
|
12
|
+
|
|
13
|
+
function envelope(data) {
|
|
14
|
+
return { session: 'site:weibo:test', data };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function makePage(evaluateResults = []) {
|
|
18
|
+
const queue = [...evaluateResults];
|
|
19
|
+
return {
|
|
20
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
21
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
22
|
+
evaluate: vi.fn(async (script) => {
|
|
23
|
+
if (String(script).includes('window.scrollBy')) return undefined;
|
|
24
|
+
return queue.length ? queue.shift() : undefined;
|
|
25
|
+
}),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('weibo read adapters Browser Bridge envelopes', () => {
|
|
30
|
+
it('unwraps comments, feed, hot, search, and favorites array payloads', async () => {
|
|
31
|
+
await expect(getRegistry().get('weibo/comments').func(
|
|
32
|
+
makePage([envelope([{ rank: 1, author: 'a', text: 't', likes: 0, replies: 0, time: '' }])]),
|
|
33
|
+
{ id: '123', limit: 1 },
|
|
34
|
+
)).resolves.toHaveLength(1);
|
|
35
|
+
|
|
36
|
+
await expect(getRegistry().get('weibo/feed').func(
|
|
37
|
+
makePage([envelope('123456'), envelope([{ id: 'm1', author: 'a', text: 't', reposts: 0, comments: 0, likes: 0, time: '', url: 'https://weibo.com/1/m1' }])]),
|
|
38
|
+
{ type: 'for-you', limit: 1 },
|
|
39
|
+
)).resolves.toHaveLength(1);
|
|
40
|
+
|
|
41
|
+
await expect(getRegistry().get('weibo/hot').func(
|
|
42
|
+
makePage([envelope([{ rank: 1, word: 'opencli', hot_value: 1, category: '', label: '', url: 'https://s.weibo.com/weibo?q=opencli' }])]),
|
|
43
|
+
{ limit: 1 },
|
|
44
|
+
)).resolves.toHaveLength(1);
|
|
45
|
+
|
|
46
|
+
await expect(getRegistry().get('weibo/search').func(
|
|
47
|
+
makePage([envelope([{ id: 'm1', title: 'OpenCLI', author: 'a', time: '', url: 'https://weibo.com/1/m1' }])]),
|
|
48
|
+
{ keyword: 'opencli', limit: 1 },
|
|
49
|
+
)).resolves.toEqual([{ rank: 1, id: 'm1', title: 'OpenCLI', author: 'a', time: '', url: 'https://weibo.com/1/m1' }]);
|
|
50
|
+
|
|
51
|
+
await expect(getRegistry().get('weibo/favorites').func(
|
|
52
|
+
makePage([envelope('123456'), envelope([{ text: '作者A\n这是一条收藏微博', url: 'https://weibo.com/123/AbCd1' }])]),
|
|
53
|
+
{ limit: 1 },
|
|
54
|
+
)).resolves.toHaveLength(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('unwraps me, post, and user object payloads', async () => {
|
|
58
|
+
await expect(getRegistry().get('weibo/me').func(
|
|
59
|
+
makePage([envelope('123456'), envelope({ screen_name: 'me', uid: '123456' })]),
|
|
60
|
+
{},
|
|
61
|
+
)).resolves.toMatchObject({ screen_name: 'me', uid: '123456' });
|
|
62
|
+
|
|
63
|
+
await expect(getRegistry().get('weibo/post').func(
|
|
64
|
+
makePage([envelope({ id: '1', text: 'post' })]),
|
|
65
|
+
{ id: '1' },
|
|
66
|
+
)).resolves.toContainEqual({ field: 'text', value: 'post' });
|
|
67
|
+
|
|
68
|
+
await expect(getRegistry().get('weibo/user').func(
|
|
69
|
+
makePage([envelope({ screen_name: 'alice', uid: '42' })]),
|
|
70
|
+
{ id: '42' },
|
|
71
|
+
)).resolves.toMatchObject({ screen_name: 'alice', uid: '42' });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('fails typed instead of returning empty rows for malformed post-unwrap payloads', async () => {
|
|
75
|
+
await expect(getRegistry().get('weibo/hot').func(
|
|
76
|
+
makePage([envelope({ error: 'API error' })]),
|
|
77
|
+
{ limit: 1 },
|
|
78
|
+
)).rejects.toBeInstanceOf(CommandExecutionError);
|
|
79
|
+
|
|
80
|
+
await expect(getRegistry().get('weibo/user').func(
|
|
81
|
+
makePage([envelope([{ screen_name: 'wrong shape' }])]),
|
|
82
|
+
{ id: '42' },
|
|
83
|
+
)).rejects.toBeInstanceOf(CommandExecutionError);
|
|
84
|
+
});
|
|
85
|
+
});
|
package/clis/weibo/favorites.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
2
|
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
-
import { getSelfUid } from './utils.js';
|
|
3
|
+
import { getSelfUid, requireArrayEvaluateResult, unwrapEvaluateResult } from './utils.js';
|
|
4
4
|
|
|
5
5
|
const DEFAULT_LIMIT = 20;
|
|
6
6
|
const MAX_LIMIT = 50;
|
|
@@ -123,7 +123,7 @@ cli({
|
|
|
123
123
|
await page.wait(1);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
const rawData = await page.evaluate(`
|
|
126
|
+
const rawData = requireArrayEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
|
|
127
127
|
(() => {
|
|
128
128
|
const scrollers = document.querySelectorAll('.wbpro-scroller-item, .vue-recycle-scroller__item-view');
|
|
129
129
|
const out = [];
|
|
@@ -145,9 +145,9 @@ cli({
|
|
|
145
145
|
}
|
|
146
146
|
return out;
|
|
147
147
|
})()
|
|
148
|
-
`);
|
|
148
|
+
`)), 'weibo favorites');
|
|
149
149
|
|
|
150
|
-
if (
|
|
150
|
+
if (rawData.length === 0) {
|
|
151
151
|
throw new EmptyResultError('weibo favorites', 'No favorites were visible on the favorites page');
|
|
152
152
|
}
|
|
153
153
|
|
package/clis/weibo/feed.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Weibo feed — for-you or following timeline.
|
|
3
3
|
*/
|
|
4
4
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
5
|
-
import { getSelfUid } from './utils.js';
|
|
5
|
+
import { getSelfUid, requireArrayEvaluateResult, unwrapEvaluateResult } from './utils.js';
|
|
6
6
|
const TIMELINE_ENDPOINTS = {
|
|
7
7
|
'for-you': 'unreadfriendstimeline',
|
|
8
8
|
following: 'friendstimeline',
|
|
@@ -31,7 +31,7 @@ cli({
|
|
|
31
31
|
await page.goto('https://weibo.com');
|
|
32
32
|
await page.wait(2);
|
|
33
33
|
const uid = await getSelfUid(page);
|
|
34
|
-
const data = await page.evaluate(`
|
|
34
|
+
const data = requireArrayEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
|
|
35
35
|
(async () => {
|
|
36
36
|
const uid = ${JSON.stringify(uid)};
|
|
37
37
|
const count = ${count};
|
|
@@ -63,9 +63,7 @@ cli({
|
|
|
63
63
|
return item;
|
|
64
64
|
});
|
|
65
65
|
})()
|
|
66
|
-
`);
|
|
67
|
-
if (!Array.isArray(data))
|
|
68
|
-
return [];
|
|
66
|
+
`)), 'weibo feed');
|
|
69
67
|
return data;
|
|
70
68
|
},
|
|
71
69
|
});
|