@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/reddit/search.js
CHANGED
|
@@ -29,10 +29,36 @@ cli({
|
|
|
29
29
|
},
|
|
30
30
|
{ name: 'limit', type: 'int', default: 15 },
|
|
31
31
|
],
|
|
32
|
-
columns: ['title', 'subreddit', 'author', 'score', 'comments', 'url'],
|
|
32
|
+
columns: ['id', 'title', 'subreddit', 'author', 'score', 'comments', 'url', 'created_utc', 'selftext', 'post_hint', 'url_overridden_by_dest', 'preview_image_url', 'gallery_urls'],
|
|
33
33
|
pipeline: [
|
|
34
34
|
{ navigate: 'https://www.reddit.com' },
|
|
35
35
|
{ evaluate: `(async () => {
|
|
36
|
+
function decodeHtml(s) {
|
|
37
|
+
if (typeof s !== 'string' || !s) return '';
|
|
38
|
+
return s
|
|
39
|
+
.replace(/&/g, '&')
|
|
40
|
+
.replace(/</g, '<')
|
|
41
|
+
.replace(/>/g, '>')
|
|
42
|
+
.replace(/"/g, '"')
|
|
43
|
+
.replace(/'/gi, "'")
|
|
44
|
+
.replace(/'/g, "'");
|
|
45
|
+
}
|
|
46
|
+
function extractRedditMedia(d) {
|
|
47
|
+
const post_hint = d?.post_hint || '';
|
|
48
|
+
const url_overridden_by_dest = d?.url_overridden_by_dest || '';
|
|
49
|
+
const preview_image_url = decodeHtml(d?.preview?.images?.[0]?.source?.url || '');
|
|
50
|
+
const gallery_urls = [];
|
|
51
|
+
const items = d?.gallery_data?.items;
|
|
52
|
+
const meta = d?.media_metadata;
|
|
53
|
+
if (Array.isArray(items) && meta) {
|
|
54
|
+
for (const it of items) {
|
|
55
|
+
const m = it && meta[it.media_id];
|
|
56
|
+
const u = m?.s?.u;
|
|
57
|
+
if (u) gallery_urls.push(decodeHtml(u));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { post_hint, url_overridden_by_dest, preview_image_url, gallery_urls };
|
|
61
|
+
}
|
|
36
62
|
const q = encodeURIComponent(\${{ args.query | json }});
|
|
37
63
|
const sub = \${{ args.subreddit | json }};
|
|
38
64
|
const sort = \${{ args.sort | json }};
|
|
@@ -44,22 +70,33 @@ cli({
|
|
|
44
70
|
const res = await fetch(basePath + '?' + params, { credentials: 'include' });
|
|
45
71
|
const d = await res.json();
|
|
46
72
|
return (d?.data?.children || []).map(c => ({
|
|
73
|
+
id: c.data.id,
|
|
47
74
|
title: c.data.title,
|
|
48
75
|
subreddit: c.data.subreddit_name_prefixed,
|
|
49
76
|
author: c.data.author,
|
|
50
77
|
score: c.data.score,
|
|
51
78
|
comments: c.data.num_comments,
|
|
52
79
|
url: 'https://www.reddit.com' + c.data.permalink,
|
|
80
|
+
created_utc: c.data.created_utc,
|
|
81
|
+
selftext: c.data.selftext || '',
|
|
82
|
+
...extractRedditMedia(c.data),
|
|
53
83
|
}));
|
|
54
84
|
})()
|
|
55
85
|
` },
|
|
56
86
|
{ map: {
|
|
87
|
+
id: '${{ item.id }}',
|
|
57
88
|
title: '${{ item.title }}',
|
|
58
89
|
subreddit: '${{ item.subreddit }}',
|
|
59
90
|
author: '${{ item.author }}',
|
|
60
91
|
score: '${{ item.score }}',
|
|
61
92
|
comments: '${{ item.comments }}',
|
|
62
93
|
url: '${{ item.url }}',
|
|
94
|
+
created_utc: '${{ item.created_utc }}',
|
|
95
|
+
selftext: '${{ item.selftext }}',
|
|
96
|
+
post_hint: '${{ item.post_hint }}',
|
|
97
|
+
url_overridden_by_dest: '${{ item.url_overridden_by_dest }}',
|
|
98
|
+
preview_image_url: '${{ item.preview_image_url }}',
|
|
99
|
+
gallery_urls: '${{ item.gallery_urls }}',
|
|
63
100
|
} },
|
|
64
101
|
{ limit: '${{ args.limit }}' },
|
|
65
102
|
],
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import './search.js';
|
|
4
|
+
|
|
5
|
+
describe('reddit search adapter', () => {
|
|
6
|
+
const command = getRegistry().get('reddit/search');
|
|
7
|
+
|
|
8
|
+
it('exposes the full search-result shape including the 4 media columns', () => {
|
|
9
|
+
expect(command?.columns).toEqual([
|
|
10
|
+
'id', 'title', 'subreddit', 'author', 'score', 'comments', 'url',
|
|
11
|
+
'created_utc', 'selftext',
|
|
12
|
+
'post_hint', 'url_overridden_by_dest', 'preview_image_url', 'gallery_urls',
|
|
13
|
+
]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('surfaces media via extractRedditMedia in evaluate + map', () => {
|
|
17
|
+
expect(command?.pipeline?.[1]?.evaluate).toContain('function extractRedditMedia');
|
|
18
|
+
expect(command?.pipeline?.[1]?.evaluate).toContain('...extractRedditMedia(c.data)');
|
|
19
|
+
expect(command?.pipeline?.[2]?.map).toMatchObject({
|
|
20
|
+
post_hint: '${{ item.post_hint }}',
|
|
21
|
+
url_overridden_by_dest: '${{ item.url_overridden_by_dest }}',
|
|
22
|
+
preview_image_url: '${{ item.preview_image_url }}',
|
|
23
|
+
gallery_urls: '${{ item.gallery_urls }}',
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
});
|
package/clis/reddit/subreddit.js
CHANGED
|
@@ -23,10 +23,36 @@ cli({
|
|
|
23
23
|
},
|
|
24
24
|
{ name: 'limit', type: 'int', default: 15 },
|
|
25
25
|
],
|
|
26
|
-
columns: ['title', 'author', 'upvotes', 'comments', 'url'],
|
|
26
|
+
columns: ['id', 'title', 'subreddit', 'author', 'upvotes', 'comments', 'url', 'created_utc', 'selftext', 'post_hint', 'url_overridden_by_dest', 'preview_image_url', 'gallery_urls'],
|
|
27
27
|
pipeline: [
|
|
28
28
|
{ navigate: 'https://www.reddit.com' },
|
|
29
29
|
{ evaluate: `(async () => {
|
|
30
|
+
function decodeHtml(s) {
|
|
31
|
+
if (typeof s !== 'string' || !s) return '';
|
|
32
|
+
return s
|
|
33
|
+
.replace(/&/g, '&')
|
|
34
|
+
.replace(/</g, '<')
|
|
35
|
+
.replace(/>/g, '>')
|
|
36
|
+
.replace(/"/g, '"')
|
|
37
|
+
.replace(/'/gi, "'")
|
|
38
|
+
.replace(/'/g, "'");
|
|
39
|
+
}
|
|
40
|
+
function extractRedditMedia(d) {
|
|
41
|
+
const post_hint = d?.post_hint || '';
|
|
42
|
+
const url_overridden_by_dest = d?.url_overridden_by_dest || '';
|
|
43
|
+
const preview_image_url = decodeHtml(d?.preview?.images?.[0]?.source?.url || '');
|
|
44
|
+
const gallery_urls = [];
|
|
45
|
+
const items = d?.gallery_data?.items;
|
|
46
|
+
const meta = d?.media_metadata;
|
|
47
|
+
if (Array.isArray(items) && meta) {
|
|
48
|
+
for (const it of items) {
|
|
49
|
+
const m = it && meta[it.media_id];
|
|
50
|
+
const u = m?.s?.u;
|
|
51
|
+
if (u) gallery_urls.push(decodeHtml(u));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { post_hint, url_overridden_by_dest, preview_image_url, gallery_urls };
|
|
55
|
+
}
|
|
30
56
|
let sub = \${{ args.name | json }};
|
|
31
57
|
if (sub.startsWith('r/')) sub = sub.slice(2);
|
|
32
58
|
const sort = \${{ args.sort | json }};
|
|
@@ -38,15 +64,34 @@ cli({
|
|
|
38
64
|
}
|
|
39
65
|
const res = await fetch(url, { credentials: 'include' });
|
|
40
66
|
const j = await res.json();
|
|
41
|
-
return j?.data?.children || []
|
|
67
|
+
return (j?.data?.children || []).map(c => ({
|
|
68
|
+
id: c.data.id,
|
|
69
|
+
title: c.data.title,
|
|
70
|
+
subreddit: c.data.subreddit_name_prefixed,
|
|
71
|
+
author: c.data.author,
|
|
72
|
+
upvotes: c.data.score,
|
|
73
|
+
comments: c.data.num_comments,
|
|
74
|
+
url: 'https://www.reddit.com' + c.data.permalink,
|
|
75
|
+
created_utc: c.data.created_utc,
|
|
76
|
+
selftext: c.data.selftext || '',
|
|
77
|
+
...extractRedditMedia(c.data),
|
|
78
|
+
}));
|
|
42
79
|
})()
|
|
43
80
|
` },
|
|
44
81
|
{ map: {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
82
|
+
id: '${{ item.id }}',
|
|
83
|
+
title: '${{ item.title }}',
|
|
84
|
+
subreddit: '${{ item.subreddit }}',
|
|
85
|
+
author: '${{ item.author }}',
|
|
86
|
+
upvotes: '${{ item.upvotes }}',
|
|
87
|
+
comments: '${{ item.comments }}',
|
|
88
|
+
url: '${{ item.url }}',
|
|
89
|
+
created_utc: '${{ item.created_utc }}',
|
|
90
|
+
selftext: '${{ item.selftext }}',
|
|
91
|
+
post_hint: '${{ item.post_hint }}',
|
|
92
|
+
url_overridden_by_dest: '${{ item.url_overridden_by_dest }}',
|
|
93
|
+
preview_image_url: '${{ item.preview_image_url }}',
|
|
94
|
+
gallery_urls: '${{ item.gallery_urls }}',
|
|
50
95
|
} },
|
|
51
96
|
{ limit: '${{ args.limit }}' },
|
|
52
97
|
],
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import './subreddit.js';
|
|
4
|
+
|
|
5
|
+
describe('reddit subreddit adapter', () => {
|
|
6
|
+
const command = getRegistry().get('reddit/subreddit');
|
|
7
|
+
|
|
8
|
+
it('exposes the full subreddit-list shape including the 4 media columns', () => {
|
|
9
|
+
expect(command?.columns).toEqual([
|
|
10
|
+
'id', 'title', 'subreddit', 'author', 'upvotes', 'comments', 'url',
|
|
11
|
+
'created_utc', 'selftext',
|
|
12
|
+
'post_hint', 'url_overridden_by_dest', 'preview_image_url', 'gallery_urls',
|
|
13
|
+
]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('shapes children into the intermediate-object pattern with media spread in', () => {
|
|
17
|
+
expect(command?.pipeline?.[1]?.evaluate).toContain('function extractRedditMedia');
|
|
18
|
+
expect(command?.pipeline?.[1]?.evaluate).toContain('...extractRedditMedia(c.data)');
|
|
19
|
+
expect(command?.pipeline?.[2]?.map).toMatchObject({
|
|
20
|
+
title: '${{ item.title }}',
|
|
21
|
+
author: '${{ item.author }}',
|
|
22
|
+
upvotes: '${{ item.upvotes }}',
|
|
23
|
+
comments: '${{ item.comments }}',
|
|
24
|
+
url: '${{ item.url }}',
|
|
25
|
+
post_hint: '${{ item.post_hint }}',
|
|
26
|
+
url_overridden_by_dest: '${{ item.url_overridden_by_dest }}',
|
|
27
|
+
preview_image_url: '${{ item.preview_image_url }}',
|
|
28
|
+
gallery_urls: '${{ item.gallery_urls }}',
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
2
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
import { BROWSER_JSON_SNIFF_FN, throwIfLoginWall } from '@jackwener/opencli/utils';
|
|
4
|
+
|
|
5
|
+
export const REDDIT_SUBSCRIBED_MAX_LIMIT = 1000;
|
|
6
|
+
|
|
7
|
+
export function parseRedditSubscribedLimit(raw) {
|
|
8
|
+
if (raw === undefined || raw === null || raw === '') return 100;
|
|
9
|
+
const n = Number(raw);
|
|
10
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1 || n > REDDIT_SUBSCRIBED_MAX_LIMIT) {
|
|
11
|
+
throw new ArgumentError(
|
|
12
|
+
`limit must be an integer in [1, ${REDDIT_SUBSCRIBED_MAX_LIMIT}].`,
|
|
13
|
+
`Got: ${raw}`,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
return n;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function unwrapEvaluateResult(payload) {
|
|
20
|
+
if (payload && typeof payload === 'object' && !Array.isArray(payload) && 'session' in payload && 'data' in payload) {
|
|
21
|
+
return payload.data;
|
|
22
|
+
}
|
|
23
|
+
return payload;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function mapSubredditRow(entry, index) {
|
|
27
|
+
const data = entry?.data;
|
|
28
|
+
if (!data || typeof data !== 'object') {
|
|
29
|
+
throw new CommandExecutionError(`Reddit subscriptions row ${index + 1} was missing data.`);
|
|
30
|
+
}
|
|
31
|
+
const fullname = typeof data.name === 'string' ? data.name : '';
|
|
32
|
+
const id = fullname.startsWith('t5_')
|
|
33
|
+
? fullname
|
|
34
|
+
: (entry?.kind === 't5' && typeof data.id === 'string' && data.id ? `t5_${data.id}` : '');
|
|
35
|
+
const displayName = typeof data.display_name === 'string' && data.display_name
|
|
36
|
+
? data.display_name
|
|
37
|
+
: '';
|
|
38
|
+
const subreddit = typeof data.display_name_prefixed === 'string' && data.display_name_prefixed
|
|
39
|
+
? data.display_name_prefixed
|
|
40
|
+
: (displayName ? `r/${displayName}` : '');
|
|
41
|
+
const path = typeof data.url === 'string' && data.url.startsWith('/r/') ? data.url : '';
|
|
42
|
+
if (!id || !displayName || !subreddit || !path) {
|
|
43
|
+
throw new CommandExecutionError(`Reddit subscriptions row ${index + 1} was missing subreddit identity.`);
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
id,
|
|
47
|
+
subreddit,
|
|
48
|
+
title: typeof data.title === 'string' ? data.title : '',
|
|
49
|
+
subscribers: typeof data.subscribers === 'number' ? data.subscribers : null,
|
|
50
|
+
description: typeof data.public_description === 'string' ? data.public_description.slice(0, 200) : '',
|
|
51
|
+
url: 'https://www.reddit.com' + path,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
cli({
|
|
56
|
+
site: 'reddit',
|
|
57
|
+
name: 'subscribed',
|
|
58
|
+
description: 'List subreddits you are subscribed to',
|
|
59
|
+
access: 'read',
|
|
60
|
+
domain: 'reddit.com',
|
|
61
|
+
strategy: Strategy.COOKIE,
|
|
62
|
+
browser: true,
|
|
63
|
+
args: [
|
|
64
|
+
{ name: 'limit', type: 'int', default: 100, help: `Max subreddits to return (1-${REDDIT_SUBSCRIBED_MAX_LIMIT}, auto-paginates)` },
|
|
65
|
+
],
|
|
66
|
+
columns: ['id', 'subreddit', 'title', 'subscribers', 'description', 'url'],
|
|
67
|
+
func: async (page, kwargs) => {
|
|
68
|
+
const limit = parseRedditSubscribedLimit(kwargs.limit);
|
|
69
|
+
if (!page)
|
|
70
|
+
throw new CommandExecutionError('Browser session required');
|
|
71
|
+
await page.goto('https://www.reddit.com');
|
|
72
|
+
const result = unwrapEvaluateResult(await page.evaluate(`(async () => {
|
|
73
|
+
${BROWSER_JSON_SNIFF_FN}
|
|
74
|
+
try {
|
|
75
|
+
// fetchJsonOrLoginWall sniffs HTML responses (login wall / WAF / rate-limit
|
|
76
|
+
// page) and returns a structured { __loginWall, status, url, ... } sentinel
|
|
77
|
+
// instead of letting JSON.parse blow up with "Unexpected token '<'".
|
|
78
|
+
const me = await fetchJsonOrLoginWall('/api/me.json?raw_json=1', { credentials: 'include' });
|
|
79
|
+
if (me && me.__loginWall) {
|
|
80
|
+
return { kind: 'login-wall', sentinel: me, where: '/api/me.json' };
|
|
81
|
+
}
|
|
82
|
+
if (me && me.error === 401 || me && me.error === 403) {
|
|
83
|
+
return { kind: 'auth', detail: 'Reddit /api/me.json returned HTTP ' + me.error };
|
|
84
|
+
}
|
|
85
|
+
if (me && me.error) {
|
|
86
|
+
return { kind: 'http', httpStatus: me.error, where: '/api/me.json' };
|
|
87
|
+
}
|
|
88
|
+
const username = me?.data?.name || me?.name;
|
|
89
|
+
if (!username) return { kind: 'auth', detail: 'Not logged in to reddit.com (no identity in /api/me.json)' };
|
|
90
|
+
|
|
91
|
+
const target = ${JSON.stringify(limit)};
|
|
92
|
+
const PAGE_SIZE = 100;
|
|
93
|
+
const out = [];
|
|
94
|
+
let after = null;
|
|
95
|
+
const seenCursors = new Set();
|
|
96
|
+
for (let pageIndex = 0; pageIndex < 20 && out.length < target; pageIndex++) {
|
|
97
|
+
const remaining = target - out.length;
|
|
98
|
+
const pageLimit = Math.min(PAGE_SIZE, remaining);
|
|
99
|
+
const url = '/subreddits/mine/subscriptions.json?limit=' + pageLimit
|
|
100
|
+
+ '&raw_json=1'
|
|
101
|
+
+ (after ? '&after=' + encodeURIComponent(after) : '');
|
|
102
|
+
const d = await fetchJsonOrLoginWall(url, { credentials: 'include' });
|
|
103
|
+
if (d && d.__loginWall) {
|
|
104
|
+
return { kind: 'login-wall', sentinel: d, where: url };
|
|
105
|
+
}
|
|
106
|
+
if (d && (d.error === 401 || d.error === 403)) {
|
|
107
|
+
return { kind: 'auth', detail: 'Reddit subscriptions endpoint returned HTTP ' + d.error };
|
|
108
|
+
}
|
|
109
|
+
if (d && d.error) return { kind: 'http', httpStatus: d.error, where: url };
|
|
110
|
+
const children = d?.data?.children;
|
|
111
|
+
if (!Array.isArray(children)) {
|
|
112
|
+
return { kind: 'malformed', detail: 'Reddit subscriptions payload was missing data.children.' };
|
|
113
|
+
}
|
|
114
|
+
for (const child of children) {
|
|
115
|
+
if (out.length >= target) break;
|
|
116
|
+
out.push(child);
|
|
117
|
+
}
|
|
118
|
+
const next = d?.data?.after ?? null;
|
|
119
|
+
if (next !== null && typeof next !== 'string') {
|
|
120
|
+
return { kind: 'malformed', detail: 'Reddit subscriptions payload had a malformed after cursor.' };
|
|
121
|
+
}
|
|
122
|
+
if (out.length >= target || !next) break;
|
|
123
|
+
if (children.length === 0) {
|
|
124
|
+
return { kind: 'malformed', detail: 'Reddit subscriptions page was empty but returned an after cursor.' };
|
|
125
|
+
}
|
|
126
|
+
if (seenCursors.has(next)) {
|
|
127
|
+
return { kind: 'malformed', detail: 'Reddit subscriptions repeated pagination cursor ' + next + '.' };
|
|
128
|
+
}
|
|
129
|
+
seenCursors.add(next);
|
|
130
|
+
after = next;
|
|
131
|
+
}
|
|
132
|
+
if (out.length < target && after) {
|
|
133
|
+
return { kind: 'malformed', detail: 'Reddit subscriptions pagination exceeded the safety cap before satisfying the requested limit.' };
|
|
134
|
+
}
|
|
135
|
+
return { kind: 'ok', entries: out };
|
|
136
|
+
} catch (e) {
|
|
137
|
+
return { kind: 'exception', detail: String(e && e.message || e) };
|
|
138
|
+
}
|
|
139
|
+
})()`));
|
|
140
|
+
if (result?.kind === 'login-wall') {
|
|
141
|
+
// Convert the browser-side sentinel into a typed LoginWallError on the Node side.
|
|
142
|
+
throwIfLoginWall(result.sentinel, { url: result.where });
|
|
143
|
+
}
|
|
144
|
+
if (result?.kind === 'auth') {
|
|
145
|
+
throw new AuthRequiredError('reddit.com', result.detail);
|
|
146
|
+
}
|
|
147
|
+
if (result?.kind === 'http') {
|
|
148
|
+
throw new CommandExecutionError(`HTTP ${result.httpStatus} from ${result.where}`);
|
|
149
|
+
}
|
|
150
|
+
if (result?.kind === 'malformed') {
|
|
151
|
+
throw new CommandExecutionError(result.detail);
|
|
152
|
+
}
|
|
153
|
+
if (result?.kind === 'exception') {
|
|
154
|
+
throw new CommandExecutionError(`subscribed failed: ${result.detail}`);
|
|
155
|
+
}
|
|
156
|
+
if (result?.kind !== 'ok' || !Array.isArray(result.entries)) {
|
|
157
|
+
throw new CommandExecutionError(`Unexpected result from reddit subscribed: ${JSON.stringify(result)}`);
|
|
158
|
+
}
|
|
159
|
+
const rows = result.entries.slice(0, limit).map((entry, index) => mapSubredditRow(entry, index));
|
|
160
|
+
if (rows.length === 0) {
|
|
161
|
+
throw new EmptyResultError('Reddit returned no subscribed subreddits for the logged-in account.');
|
|
162
|
+
}
|
|
163
|
+
return rows;
|
|
164
|
+
}
|
|
165
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError, LoginWallError } from '@jackwener/opencli/errors';
|
|
4
|
+
import { parseRedditSubscribedLimit, unwrapEvaluateResult } from './subscribed.js';
|
|
5
|
+
import './subscribed.js';
|
|
6
|
+
|
|
7
|
+
function subredditThing(id, overrides = {}) {
|
|
8
|
+
const displayName = `sub${id}`;
|
|
9
|
+
return {
|
|
10
|
+
kind: 't5',
|
|
11
|
+
data: {
|
|
12
|
+
id,
|
|
13
|
+
name: `t5_${id}`,
|
|
14
|
+
display_name: displayName,
|
|
15
|
+
display_name_prefixed: `r/${displayName}`,
|
|
16
|
+
title: `Sub ${id}`,
|
|
17
|
+
subscribers: 1000,
|
|
18
|
+
public_description: `Description ${id}`,
|
|
19
|
+
url: `/r/${displayName}/`,
|
|
20
|
+
...overrides,
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makePage(result) {
|
|
26
|
+
return {
|
|
27
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
28
|
+
evaluate: vi.fn().mockResolvedValue(result),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('reddit subscribed adapter', () => {
|
|
33
|
+
const command = getRegistry().get('reddit/subscribed');
|
|
34
|
+
|
|
35
|
+
it('registers with id-bearing output columns', () => {
|
|
36
|
+
expect(command).toBeDefined();
|
|
37
|
+
expect(command.columns).toEqual(['id', 'subreddit', 'title', 'subscribers', 'description', 'url']);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('parseRedditSubscribedLimit rejects out-of-range values without silent clamp', () => {
|
|
41
|
+
expect(parseRedditSubscribedLimit(undefined)).toBe(100);
|
|
42
|
+
expect(parseRedditSubscribedLimit(null)).toBe(100);
|
|
43
|
+
expect(parseRedditSubscribedLimit('')).toBe(100);
|
|
44
|
+
expect(parseRedditSubscribedLimit(1)).toBe(1);
|
|
45
|
+
expect(parseRedditSubscribedLimit(1000)).toBe(1000);
|
|
46
|
+
for (const bad of [0, -1, 1001, 1.5, NaN, 'abc']) {
|
|
47
|
+
expect(() => parseRedditSubscribedLimit(bad)).toThrow(ArgumentError);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('rejects bad limit before navigation', async () => {
|
|
52
|
+
const page = makePage({ kind: 'ok', entries: [] });
|
|
53
|
+
await expect(command.func(page, { limit: 1001 })).rejects.toBeInstanceOf(ArgumentError);
|
|
54
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
55
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('unwraps Browser Bridge envelopes', () => {
|
|
59
|
+
const inner = { kind: 'ok', entries: [] };
|
|
60
|
+
expect(unwrapEvaluateResult({ session: 'browser:default', data: inner })).toBe(inner);
|
|
61
|
+
expect(unwrapEvaluateResult(inner)).toBe(inner);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns subscribed subreddits from the browser-evaluated payload', async () => {
|
|
65
|
+
const page = makePage({
|
|
66
|
+
kind: 'ok',
|
|
67
|
+
entries: [
|
|
68
|
+
subredditThing('abc', {
|
|
69
|
+
display_name: 'programming',
|
|
70
|
+
display_name_prefixed: 'r/programming',
|
|
71
|
+
title: 'Programming',
|
|
72
|
+
subscribers: 6000000,
|
|
73
|
+
public_description: 'All things code',
|
|
74
|
+
url: '/r/programming/',
|
|
75
|
+
}),
|
|
76
|
+
subredditThing('def', {
|
|
77
|
+
display_name: 'MachineLearning',
|
|
78
|
+
display_name_prefixed: 'r/MachineLearning',
|
|
79
|
+
title: 'Machine Learning',
|
|
80
|
+
subscribers: 3000000,
|
|
81
|
+
public_description: 'ML research',
|
|
82
|
+
url: '/r/MachineLearning/',
|
|
83
|
+
}),
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
const result = await command.func(page, { limit: 100 });
|
|
87
|
+
expect(page.goto).toHaveBeenCalledWith('https://www.reddit.com');
|
|
88
|
+
expect(result).toEqual([
|
|
89
|
+
{ id: 't5_abc', subreddit: 'r/programming', title: 'Programming', subscribers: 6000000, description: 'All things code', url: 'https://www.reddit.com/r/programming/' },
|
|
90
|
+
{ id: 't5_def', subreddit: 'r/MachineLearning', title: 'Machine Learning', subscribers: 3000000, description: 'ML research', url: 'https://www.reddit.com/r/MachineLearning/' },
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('throws AuthRequiredError when not logged in', async () => {
|
|
95
|
+
await expect(command.func(makePage({ kind: 'auth', detail: 'login required' }), { limit: 100 }))
|
|
96
|
+
.rejects.toBeInstanceOf(AuthRequiredError);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('surfaces HTTP, malformed, exception, and unexpected envelopes as CommandExecutionError', async () => {
|
|
100
|
+
await expect(command.func(makePage({ kind: 'http', httpStatus: 429, where: '/subreddits/mine/subscriptions.json?limit=100' }), { limit: 100 }))
|
|
101
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
102
|
+
await expect(command.func(makePage({ kind: 'malformed', detail: 'missing data.children' }), { limit: 100 }))
|
|
103
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
104
|
+
await expect(command.func(makePage({ kind: 'exception', detail: 'network' }), { limit: 100 }))
|
|
105
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
106
|
+
await expect(command.func(makePage({ ok: true }), { limit: 100 }))
|
|
107
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('converts a browser-side login-wall sentinel into a typed LoginWallError', async () => {
|
|
111
|
+
const sentinel = {
|
|
112
|
+
__loginWall: true,
|
|
113
|
+
status: 200,
|
|
114
|
+
url: 'https://www.reddit.com/api/me.json?raw_json=1',
|
|
115
|
+
contentType: 'text/html; charset=utf-8',
|
|
116
|
+
bodyPreview: '<!DOCTYPE html><html><head><title>reddit.com: over 18?</title>',
|
|
117
|
+
};
|
|
118
|
+
const page = makePage({ kind: 'login-wall', sentinel, where: '/api/me.json' });
|
|
119
|
+
try {
|
|
120
|
+
await command.func(page, { limit: 100 });
|
|
121
|
+
throw new Error('expected LoginWallError, got success');
|
|
122
|
+
} catch (err) {
|
|
123
|
+
expect(err).toBeInstanceOf(LoginWallError);
|
|
124
|
+
expect(err.status).toBe(200);
|
|
125
|
+
expect(err.url).toBe('/api/me.json');
|
|
126
|
+
expect(err.bodyPreview).toContain('reddit.com: over 18');
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('throws EmptyResultError for a valid empty subscriptions list', async () => {
|
|
131
|
+
await expect(command.func(makePage({ kind: 'ok', entries: [] }), { limit: 100 }))
|
|
132
|
+
.rejects.toBeInstanceOf(EmptyResultError);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('throws CommandExecutionError when rows lack subreddit identity', async () => {
|
|
136
|
+
await expect(command.func(makePage({ kind: 'ok', entries: [subredditThing('abc', { name: '', id: '', display_name: '', display_name_prefixed: '', url: '' })] }), { limit: 100 }))
|
|
137
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('does not synthesize subreddit identity from a non-t5 listing item', async () => {
|
|
141
|
+
await expect(command.func(makePage({
|
|
142
|
+
kind: 'ok',
|
|
143
|
+
entries: [{
|
|
144
|
+
kind: 't3',
|
|
145
|
+
data: {
|
|
146
|
+
id: 'abc',
|
|
147
|
+
display_name: 'notasub',
|
|
148
|
+
display_name_prefixed: 'r/notasub',
|
|
149
|
+
url: '/r/notasub/',
|
|
150
|
+
},
|
|
151
|
+
}],
|
|
152
|
+
}), { limit: 100 })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('respects --limit by slicing the final result', async () => {
|
|
156
|
+
const page = makePage({ kind: 'ok', entries: Array.from({ length: 5 }, (_, i) => subredditThing(String(i))) });
|
|
157
|
+
const result = await command.func(page, { limit: 3 });
|
|
158
|
+
expect(result).toHaveLength(3);
|
|
159
|
+
expect(result[0].subreddit).toBe('r/sub0');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('embeds the validated limit literally in the browser script', async () => {
|
|
163
|
+
const page = makePage({ kind: 'ok', entries: [subredditThing('x')] });
|
|
164
|
+
await command.func(page, { limit: 7 });
|
|
165
|
+
const script = page.evaluate.mock.calls[0][0];
|
|
166
|
+
expect(script).toContain('const target = 7');
|
|
167
|
+
});
|
|
168
|
+
});
|
package/clis/reddit/upvoted.js
CHANGED
|
@@ -30,7 +30,7 @@ cli({
|
|
|
30
30
|
});
|
|
31
31
|
const d = await res.json();
|
|
32
32
|
return (d?.data?.children || []).map(c => ({
|
|
33
|
-
title: c.data.title || '
|
|
33
|
+
title: c.data.title || '',
|
|
34
34
|
subreddit: c.data.subreddit_name_prefixed || 'r/' + (c.data.subreddit || '?'),
|
|
35
35
|
score: c.data.score || 0,
|
|
36
36
|
comments: c.data.num_comments || 0,
|