@jackwener/opencli 0.6.3 → 0.7.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.
Files changed (63) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/SKILL.md +7 -2
  4. package/dist/build-manifest.js +2 -0
  5. package/dist/cli-manifest.json +604 -24
  6. package/dist/clis/reddit/comment.d.ts +1 -0
  7. package/dist/clis/reddit/comment.js +57 -0
  8. package/dist/clis/reddit/popular.yaml +40 -0
  9. package/dist/clis/reddit/read.yaml +76 -0
  10. package/dist/clis/reddit/save.d.ts +1 -0
  11. package/dist/clis/reddit/save.js +51 -0
  12. package/dist/clis/reddit/saved.d.ts +1 -0
  13. package/dist/clis/reddit/saved.js +46 -0
  14. package/dist/clis/reddit/search.yaml +37 -11
  15. package/dist/clis/reddit/subreddit.yaml +14 -4
  16. package/dist/clis/reddit/subscribe.d.ts +1 -0
  17. package/dist/clis/reddit/subscribe.js +50 -0
  18. package/dist/clis/reddit/upvote.d.ts +1 -0
  19. package/dist/clis/reddit/upvote.js +64 -0
  20. package/dist/clis/reddit/upvoted.d.ts +1 -0
  21. package/dist/clis/reddit/upvoted.js +46 -0
  22. package/dist/clis/reddit/user-comments.yaml +45 -0
  23. package/dist/clis/reddit/user-posts.yaml +43 -0
  24. package/dist/clis/reddit/user.yaml +39 -0
  25. package/dist/clis/twitter/article.d.ts +1 -0
  26. package/dist/clis/twitter/article.js +157 -0
  27. package/dist/clis/twitter/bookmark.d.ts +1 -0
  28. package/dist/clis/twitter/bookmark.js +63 -0
  29. package/dist/clis/twitter/follow.d.ts +1 -0
  30. package/dist/clis/twitter/follow.js +65 -0
  31. package/dist/clis/twitter/profile.js +110 -42
  32. package/dist/clis/twitter/thread.d.ts +1 -0
  33. package/dist/clis/twitter/thread.js +150 -0
  34. package/dist/clis/twitter/unbookmark.d.ts +1 -0
  35. package/dist/clis/twitter/unbookmark.js +62 -0
  36. package/dist/clis/twitter/unfollow.d.ts +1 -0
  37. package/dist/clis/twitter/unfollow.js +71 -0
  38. package/dist/main.js +31 -8
  39. package/dist/registry.d.ts +1 -0
  40. package/package.json +1 -1
  41. package/src/build-manifest.ts +3 -0
  42. package/src/clis/reddit/comment.ts +60 -0
  43. package/src/clis/reddit/popular.yaml +40 -0
  44. package/src/clis/reddit/read.yaml +76 -0
  45. package/src/clis/reddit/save.ts +54 -0
  46. package/src/clis/reddit/saved.ts +48 -0
  47. package/src/clis/reddit/search.yaml +37 -11
  48. package/src/clis/reddit/subreddit.yaml +14 -4
  49. package/src/clis/reddit/subscribe.ts +53 -0
  50. package/src/clis/reddit/upvote.ts +67 -0
  51. package/src/clis/reddit/upvoted.ts +48 -0
  52. package/src/clis/reddit/user-comments.yaml +45 -0
  53. package/src/clis/reddit/user-posts.yaml +43 -0
  54. package/src/clis/reddit/user.yaml +39 -0
  55. package/src/clis/twitter/article.ts +161 -0
  56. package/src/clis/twitter/bookmark.ts +67 -0
  57. package/src/clis/twitter/follow.ts +69 -0
  58. package/src/clis/twitter/profile.ts +113 -45
  59. package/src/clis/twitter/thread.ts +181 -0
  60. package/src/clis/twitter/unbookmark.ts +66 -0
  61. package/src/clis/twitter/unfollow.ts +75 -0
  62. package/src/main.ts +24 -5
  63. package/src/registry.ts +1 -0
@@ -0,0 +1,62 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ cli({
3
+ site: 'twitter',
4
+ name: 'unbookmark',
5
+ description: 'Remove a tweet from bookmarks',
6
+ domain: 'x.com',
7
+ strategy: Strategy.UI,
8
+ browser: true,
9
+ args: [
10
+ { name: 'url', type: 'string', positional: true, required: true, help: 'Tweet URL to unbookmark' },
11
+ ],
12
+ columns: ['status', 'message'],
13
+ func: async (page, kwargs) => {
14
+ if (!page)
15
+ throw new Error('Requires browser');
16
+ await page.goto(kwargs.url);
17
+ await page.wait(5);
18
+ const result = await page.evaluate(`(async () => {
19
+ try {
20
+ let attempts = 0;
21
+ let removeBtn = null;
22
+
23
+ while (attempts < 20) {
24
+ // Check if not bookmarked
25
+ const bookmarkBtn = document.querySelector('[data-testid="bookmark"]');
26
+ if (bookmarkBtn) {
27
+ return { ok: true, message: 'Tweet is not bookmarked (already removed).' };
28
+ }
29
+
30
+ removeBtn = document.querySelector('[data-testid="removeBookmark"]');
31
+ if (removeBtn) break;
32
+
33
+ await new Promise(r => setTimeout(r, 500));
34
+ attempts++;
35
+ }
36
+
37
+ if (!removeBtn) {
38
+ return { ok: false, message: 'Could not find Remove Bookmark button. Are you logged in?' };
39
+ }
40
+
41
+ removeBtn.click();
42
+ await new Promise(r => setTimeout(r, 1000));
43
+
44
+ // Verify
45
+ const verify = document.querySelector('[data-testid="bookmark"]');
46
+ if (verify) {
47
+ return { ok: true, message: 'Tweet successfully removed from bookmarks.' };
48
+ } else {
49
+ return { ok: false, message: 'Unbookmark action initiated but UI did not update.' };
50
+ }
51
+ } catch (e) {
52
+ return { ok: false, message: e.toString() };
53
+ }
54
+ })()`);
55
+ if (result.ok)
56
+ await page.wait(2);
57
+ return [{
58
+ status: result.ok ? 'success' : 'failed',
59
+ message: result.message
60
+ }];
61
+ }
62
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,71 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ cli({
3
+ site: 'twitter',
4
+ name: 'unfollow',
5
+ description: 'Unfollow a Twitter user',
6
+ domain: 'x.com',
7
+ strategy: Strategy.UI,
8
+ browser: true,
9
+ args: [
10
+ { name: 'username', type: 'string', positional: true, required: true, help: 'Twitter screen name (without @)' },
11
+ ],
12
+ columns: ['status', 'message'],
13
+ func: async (page, kwargs) => {
14
+ if (!page)
15
+ throw new Error('Requires browser');
16
+ const username = kwargs.username.replace(/^@/, '');
17
+ await page.goto(`https://x.com/${username}`);
18
+ await page.wait(5);
19
+ const result = await page.evaluate(`(async () => {
20
+ try {
21
+ let attempts = 0;
22
+ let unfollowBtn = null;
23
+
24
+ while (attempts < 20) {
25
+ // Check if already not following
26
+ const followBtn = document.querySelector('[data-testid$="-follow"]');
27
+ if (followBtn) {
28
+ return { ok: true, message: 'Not following @${username} (already unfollowed).' };
29
+ }
30
+
31
+ unfollowBtn = document.querySelector('[data-testid$="-unfollow"]');
32
+ if (unfollowBtn) break;
33
+
34
+ await new Promise(r => setTimeout(r, 500));
35
+ attempts++;
36
+ }
37
+
38
+ if (!unfollowBtn) {
39
+ return { ok: false, message: 'Could not find Unfollow button. Are you logged in?' };
40
+ }
41
+
42
+ // Click the unfollow button — this opens a confirmation dialog
43
+ unfollowBtn.click();
44
+ await new Promise(r => setTimeout(r, 1000));
45
+
46
+ // Confirm the unfollow in the dialog
47
+ const confirmBtn = document.querySelector('[data-testid="confirmationSheetConfirm"]');
48
+ if (confirmBtn) {
49
+ confirmBtn.click();
50
+ await new Promise(r => setTimeout(r, 1000));
51
+ }
52
+
53
+ // Verify
54
+ const verify = document.querySelector('[data-testid$="-follow"]');
55
+ if (verify) {
56
+ return { ok: true, message: 'Successfully unfollowed @${username}.' };
57
+ } else {
58
+ return { ok: false, message: 'Unfollow action initiated but UI did not update.' };
59
+ }
60
+ } catch (e) {
61
+ return { ok: false, message: e.toString() };
62
+ }
63
+ })()`);
64
+ if (result.ok)
65
+ await page.wait(2);
66
+ return [{
67
+ status: result.ok ? 'success' : 'failed',
68
+ message: result.message
69
+ }];
70
+ }
71
+ });
package/dist/main.js CHANGED
@@ -131,20 +131,43 @@ for (const [, cmd] of registry) {
131
131
  siteGroups.set(cmd.site, siteCmd);
132
132
  }
133
133
  const subCmd = siteCmd.command(cmd.name).description(cmd.description);
134
+ // Register positional args first, then named options
135
+ const positionalArgs = [];
134
136
  for (const arg of cmd.args) {
135
- const flag = arg.required ? `--${arg.name} <value>` : `--${arg.name} [value]`;
136
- if (arg.required)
137
- subCmd.requiredOption(flag, arg.help ?? '');
138
- else if (arg.default != null)
139
- subCmd.option(flag, arg.help ?? '', String(arg.default));
140
- else
141
- subCmd.option(flag, arg.help ?? '');
137
+ if (arg.positional) {
138
+ const bracket = arg.required ? `<${arg.name}>` : `[${arg.name}]`;
139
+ subCmd.argument(bracket, arg.help ?? '');
140
+ positionalArgs.push(arg);
141
+ }
142
+ else {
143
+ const flag = arg.required ? `--${arg.name} <value>` : `--${arg.name} [value]`;
144
+ if (arg.required)
145
+ subCmd.requiredOption(flag, arg.help ?? '');
146
+ else if (arg.default != null)
147
+ subCmd.option(flag, arg.help ?? '', String(arg.default));
148
+ else
149
+ subCmd.option(flag, arg.help ?? '');
150
+ }
142
151
  }
143
152
  subCmd.option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table').option('-v, --verbose', 'Debug output', false);
144
- subCmd.action(async (actionOpts) => {
153
+ subCmd.action(async (...actionArgs) => {
154
+ // Commander passes positional args first, then options object, then the Command
155
+ const actionOpts = actionArgs[positionalArgs.length] ?? {};
145
156
  const startTime = Date.now();
146
157
  const kwargs = {};
158
+ // Collect positional args
159
+ for (let i = 0; i < positionalArgs.length; i++) {
160
+ const arg = positionalArgs[i];
161
+ const v = actionArgs[i];
162
+ if (v !== undefined)
163
+ kwargs[arg.name] = coerce(v, arg.type ?? 'str');
164
+ else if (arg.default != null)
165
+ kwargs[arg.name] = arg.default;
166
+ }
167
+ // Collect named options
147
168
  for (const arg of cmd.args) {
169
+ if (arg.positional)
170
+ continue;
148
171
  const v = actionOpts[arg.name];
149
172
  if (v !== undefined)
150
173
  kwargs[arg.name] = coerce(v, arg.type ?? 'str');
@@ -14,6 +14,7 @@ export interface Arg {
14
14
  type?: string;
15
15
  default?: any;
16
16
  required?: boolean;
17
+ positional?: boolean;
17
18
  help?: string;
18
19
  choices?: string[];
19
20
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "0.6.3",
3
+ "version": "0.7.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -30,6 +30,7 @@ interface ManifestEntry {
30
30
  type?: string;
31
31
  default?: any;
32
32
  required?: boolean;
33
+ positional?: boolean;
33
34
  help?: string;
34
35
  choices?: string[];
35
36
  }>;
@@ -140,6 +141,7 @@ function scanTs(filePath: string, site: string): ManifestEntry {
140
141
  const defaultMatch = body.match(/default\s*:\s*([^,}]+)/);
141
142
  const requiredMatch = body.match(/required\s*:\s*(true|false)/);
142
143
  const helpMatch = body.match(/help\s*:\s*['"`]([^'"`]*)['"`]/);
144
+ const positionalMatch = body.match(/positional\s*:\s*(true|false)/);
143
145
 
144
146
  let defaultVal: any = undefined;
145
147
  if (defaultMatch) {
@@ -156,6 +158,7 @@ function scanTs(filePath: string, site: string): ManifestEntry {
156
158
  type: typeMatch?.[1] ?? 'str',
157
159
  default: defaultVal,
158
160
  required: requiredMatch?.[1] === 'true',
161
+ positional: positionalMatch?.[1] === 'true' || undefined,
159
162
  help: helpMatch?.[1] ?? '',
160
163
  });
161
164
  }
@@ -0,0 +1,60 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+
3
+ cli({
4
+ site: 'reddit',
5
+ name: 'comment',
6
+ description: 'Post a comment on a Reddit post',
7
+ domain: 'reddit.com',
8
+ strategy: Strategy.COOKIE,
9
+ browser: true,
10
+ args: [
11
+ { name: 'post_id', type: 'string', required: true, help: 'Post ID (e.g. 1abc123) or fullname (t3_xxx)' },
12
+ { name: 'text', type: 'string', required: true, help: 'Comment text' },
13
+ ],
14
+ columns: ['status', 'message'],
15
+ func: async (page, kwargs) => {
16
+ if (!page) throw new Error('Requires browser');
17
+
18
+ await page.goto('https://www.reddit.com');
19
+ await page.wait(3);
20
+
21
+ const result = await page.evaluate(`(async () => {
22
+ try {
23
+ let postId = ${JSON.stringify(kwargs.post_id)};
24
+ const urlMatch = postId.match(/comments\\/([a-z0-9]+)/);
25
+ if (urlMatch) postId = urlMatch[1];
26
+ const fullname = postId.startsWith('t3_') || postId.startsWith('t1_')
27
+ ? postId : 't3_' + postId;
28
+
29
+ const text = ${JSON.stringify(kwargs.text)};
30
+
31
+ // Get modhash
32
+ const meRes = await fetch('/api/me.json', { credentials: 'include' });
33
+ const me = await meRes.json();
34
+ const modhash = me?.data?.modhash || '';
35
+
36
+ const res = await fetch('/api/comment', {
37
+ method: 'POST',
38
+ credentials: 'include',
39
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
40
+ body: 'parent=' + encodeURIComponent(fullname)
41
+ + '&text=' + encodeURIComponent(text)
42
+ + '&api_type=json'
43
+ + (modhash ? '&uh=' + encodeURIComponent(modhash) : ''),
44
+ });
45
+
46
+ if (!res.ok) return { ok: false, message: 'HTTP ' + res.status };
47
+ const data = await res.json();
48
+ const errors = data?.json?.errors;
49
+ if (errors && errors.length > 0) {
50
+ return { ok: false, message: errors.map(e => e.join(': ')).join('; ') };
51
+ }
52
+ return { ok: true, message: 'Comment posted on ' + fullname };
53
+ } catch (e) {
54
+ return { ok: false, message: e.toString() };
55
+ }
56
+ })()`);
57
+
58
+ return [{ status: result.ok ? 'success' : 'failed', message: result.message }];
59
+ }
60
+ });
@@ -0,0 +1,40 @@
1
+ site: reddit
2
+ name: popular
3
+ description: Reddit Popular posts (/r/popular)
4
+ domain: reddit.com
5
+ strategy: cookie
6
+ browser: true
7
+
8
+ args:
9
+ limit:
10
+ type: int
11
+ default: 20
12
+
13
+ columns: [rank, title, subreddit, score, comments, url]
14
+
15
+ pipeline:
16
+ - navigate: https://www.reddit.com
17
+ - evaluate: |
18
+ (async () => {
19
+ const limit = ${{ args.limit }};
20
+ const res = await fetch('/r/popular.json?limit=' + limit + '&raw_json=1', {
21
+ credentials: 'include'
22
+ });
23
+ const d = await res.json();
24
+ return (d?.data?.children || []).map(c => ({
25
+ title: c.data.title,
26
+ subreddit: c.data.subreddit_name_prefixed,
27
+ score: c.data.score,
28
+ comments: c.data.num_comments,
29
+ author: c.data.author,
30
+ url: 'https://www.reddit.com' + c.data.permalink,
31
+ }));
32
+ })()
33
+ - map:
34
+ rank: ${{ index + 1 }}
35
+ title: ${{ item.title }}
36
+ subreddit: ${{ item.subreddit }}
37
+ score: ${{ item.score }}
38
+ comments: ${{ item.comments }}
39
+ url: ${{ item.url }}
40
+ - limit: ${{ args.limit }}
@@ -0,0 +1,76 @@
1
+ site: reddit
2
+ name: read
3
+ description: Read a Reddit post and its comments
4
+ domain: reddit.com
5
+ strategy: cookie
6
+ browser: true
7
+
8
+ args:
9
+ post_id:
10
+ type: string
11
+ required: true
12
+ description: "Post ID (e.g. 1abc123) or full URL"
13
+ sort:
14
+ type: string
15
+ default: best
16
+ description: "Comment sort: best, top, new, controversial, old, qa"
17
+ limit:
18
+ type: int
19
+ default: 25
20
+ description: Number of top-level comments to fetch
21
+
22
+ columns: [type, author, score, text]
23
+
24
+ pipeline:
25
+ - navigate: https://www.reddit.com
26
+ - evaluate: |
27
+ (async () => {
28
+ let postId = ${{ args.post_id | json }};
29
+ const urlMatch = postId.match(/comments\/([a-z0-9]+)/);
30
+ if (urlMatch) postId = urlMatch[1];
31
+
32
+ const sort = ${{ args.sort | json }};
33
+ const limit = ${{ args.limit }};
34
+ const res = await fetch('/comments/' + postId + '.json?sort=' + sort + '&limit=' + limit + '&raw_json=1', {
35
+ credentials: 'include'
36
+ });
37
+ const data = await res.json();
38
+ if (!Array.isArray(data) || data.length < 1) return [];
39
+
40
+ const results = [];
41
+
42
+ // First element: post itself
43
+ const post = data[0]?.data?.children?.[0]?.data;
44
+ if (post) {
45
+ let body = post.selftext || '';
46
+ if (body.length > 2000) body = body.slice(0, 2000) + '\n... [truncated]';
47
+ results.push({
48
+ type: '📰 POST',
49
+ author: post.author,
50
+ score: post.score,
51
+ text: post.title + (body ? '\n\n' + body : '') + (post.url && !post.is_self ? '\n🔗 ' + post.url : ''),
52
+ });
53
+ }
54
+
55
+ // Second element: comments
56
+ const comments = data[1]?.data?.children || [];
57
+ for (const c of comments) {
58
+ if (c.kind !== 't1') continue;
59
+ const d = c.data;
60
+ let body = d.body || '';
61
+ if (body.length > 500) body = body.slice(0, 500) + '...';
62
+ results.push({
63
+ type: '💬 COMMENT',
64
+ author: d.author || '[deleted]',
65
+ score: d.score || 0,
66
+ text: body,
67
+ });
68
+ }
69
+
70
+ return results;
71
+ })()
72
+ - map:
73
+ type: ${{ item.type }}
74
+ author: ${{ item.author }}
75
+ score: ${{ item.score }}
76
+ text: ${{ item.text }}
@@ -0,0 +1,54 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+
3
+ cli({
4
+ site: 'reddit',
5
+ name: 'save',
6
+ description: 'Save or unsave a Reddit post',
7
+ domain: 'reddit.com',
8
+ strategy: Strategy.COOKIE,
9
+ browser: true,
10
+ args: [
11
+ { name: 'post_id', type: 'string', required: true, help: 'Post ID (e.g. 1abc123) or fullname (t3_xxx)' },
12
+ { name: 'undo', type: 'boolean', default: false, help: 'Unsave instead of save' },
13
+ ],
14
+ columns: ['status', 'message'],
15
+ func: async (page, kwargs) => {
16
+ if (!page) throw new Error('Requires browser');
17
+
18
+ await page.goto('https://www.reddit.com');
19
+ await page.wait(3);
20
+
21
+ const result = await page.evaluate(`(async () => {
22
+ try {
23
+ let postId = ${JSON.stringify(kwargs.post_id)};
24
+ const urlMatch = postId.match(/comments\\/([a-z0-9]+)/);
25
+ if (urlMatch) postId = urlMatch[1];
26
+ const fullname = postId.startsWith('t3_') || postId.startsWith('t1_')
27
+ ? postId : 't3_' + postId;
28
+
29
+ const undo = ${kwargs.undo ? 'true' : 'false'};
30
+ const endpoint = undo ? '/api/unsave' : '/api/save';
31
+
32
+ // Get modhash
33
+ const meRes = await fetch('/api/me.json', { credentials: 'include' });
34
+ const me = await meRes.json();
35
+ const modhash = me?.data?.modhash || '';
36
+
37
+ const res = await fetch(endpoint, {
38
+ method: 'POST',
39
+ credentials: 'include',
40
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
41
+ body: 'id=' + encodeURIComponent(fullname)
42
+ + (modhash ? '&uh=' + encodeURIComponent(modhash) : ''),
43
+ });
44
+
45
+ if (!res.ok) return { ok: false, message: 'HTTP ' + res.status };
46
+ return { ok: true, message: (undo ? 'Unsaved' : 'Saved') + ' ' + fullname };
47
+ } catch (e) {
48
+ return { ok: false, message: e.toString() };
49
+ }
50
+ })()`);
51
+
52
+ return [{ status: result.ok ? 'success' : 'failed', message: result.message }];
53
+ }
54
+ });
@@ -0,0 +1,48 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+
3
+ cli({
4
+ site: 'reddit',
5
+ name: 'saved',
6
+ description: 'Browse your saved Reddit posts',
7
+ domain: 'reddit.com',
8
+ strategy: Strategy.COOKIE,
9
+ browser: true,
10
+ args: [
11
+ { name: 'limit', type: 'int', default: 15 },
12
+ ],
13
+ columns: ['title', 'subreddit', 'score', 'comments', 'url'],
14
+ func: async (page, kwargs) => {
15
+ if (!page) throw new Error('Requires browser');
16
+
17
+ await page.goto('https://www.reddit.com');
18
+ await page.wait(3);
19
+
20
+ const result = await page.evaluate(`(async () => {
21
+ try {
22
+ // Get current username
23
+ const meRes = await fetch('/api/me.json?raw_json=1', { credentials: 'include' });
24
+ const me = await meRes.json();
25
+ const username = me?.name || me?.data?.name;
26
+ if (!username) return { error: 'Not logged in — cannot determine username' };
27
+
28
+ const limit = ${kwargs.limit};
29
+ const res = await fetch('/user/' + username + '/saved.json?limit=' + limit + '&raw_json=1', {
30
+ credentials: 'include'
31
+ });
32
+ const d = await res.json();
33
+ return (d?.data?.children || []).map(c => ({
34
+ title: c.data.title || c.data.body?.slice(0, 100) || '-',
35
+ subreddit: c.data.subreddit_name_prefixed || 'r/' + (c.data.subreddit || '?'),
36
+ score: c.data.score || 0,
37
+ comments: c.data.num_comments || 0,
38
+ url: 'https://www.reddit.com' + (c.data.permalink || ''),
39
+ }));
40
+ } catch (e) {
41
+ return { error: e.toString() };
42
+ }
43
+ })()`);
44
+
45
+ if (result?.error) throw new Error(result.error);
46
+ return (result || []).slice(0, kwargs.limit);
47
+ }
48
+ });
@@ -9,26 +9,52 @@ args:
9
9
  query:
10
10
  type: string
11
11
  required: true
12
+ subreddit:
13
+ type: string
14
+ default: ""
15
+ description: "Search within a specific subreddit"
16
+ sort:
17
+ type: string
18
+ default: relevance
19
+ description: "Sort order: relevance, hot, top, new, comments"
20
+ time:
21
+ type: string
22
+ default: all
23
+ description: "Time filter: hour, day, week, month, year, all"
12
24
  limit:
13
25
  type: int
14
26
  default: 15
15
27
 
16
- columns: [title, subreddit, author, upvotes, comments, url]
28
+ columns: [title, subreddit, author, score, comments, url]
17
29
 
18
30
  pipeline:
19
31
  - navigate: https://www.reddit.com
20
32
  - evaluate: |
21
33
  (async () => {
22
- const q = encodeURIComponent('${{ args.query }}');
23
- const res = await fetch('/search.json?q=' + q + '&limit=${{ args.limit }}', { credentials: 'include' });
24
- const j = await res.json();
25
- return j?.data?.children || [];
34
+ const q = encodeURIComponent(${{ args.query | json }});
35
+ const sub = ${{ args.subreddit | json }};
36
+ const sort = ${{ args.sort | json }};
37
+ const time = ${{ args.time | json }};
38
+ const limit = ${{ args.limit }};
39
+ const basePath = sub ? '/r/' + sub + '/search.json' : '/search.json';
40
+ const params = 'q=' + q + '&sort=' + sort + '&t=' + time + '&limit=' + limit
41
+ + '&restrict_sr=' + (sub ? 'on' : 'off') + '&raw_json=1';
42
+ const res = await fetch(basePath + '?' + params, { credentials: 'include' });
43
+ const d = await res.json();
44
+ return (d?.data?.children || []).map(c => ({
45
+ title: c.data.title,
46
+ subreddit: c.data.subreddit_name_prefixed,
47
+ author: c.data.author,
48
+ score: c.data.score,
49
+ comments: c.data.num_comments,
50
+ url: 'https://www.reddit.com' + c.data.permalink,
51
+ }));
26
52
  })()
27
53
  - map:
28
- title: ${{ item.data.title }}
29
- subreddit: ${{ item.data.subreddit_name_prefixed }}
30
- author: ${{ item.data.author }}
31
- upvotes: ${{ item.data.score }}
32
- comments: ${{ item.data.num_comments }}
33
- url: https://www.reddit.com${{ item.data.permalink }}
54
+ title: ${{ item.title }}
55
+ subreddit: ${{ item.subreddit }}
56
+ author: ${{ item.author }}
57
+ score: ${{ item.score }}
58
+ comments: ${{ item.comments }}
59
+ url: ${{ item.url }}
34
60
  - limit: ${{ args.limit }}
@@ -12,7 +12,11 @@ args:
12
12
  sort:
13
13
  type: string
14
14
  default: hot
15
- description: "Sorting method: hot, new, top, rising"
15
+ description: "Sorting method: hot, new, top, rising, controversial"
16
+ time:
17
+ type: string
18
+ default: all
19
+ description: "Time filter for top/controversial: hour, day, week, month, year, all"
16
20
  limit:
17
21
  type: int
18
22
  default: 15
@@ -23,10 +27,16 @@ pipeline:
23
27
  - navigate: https://www.reddit.com
24
28
  - evaluate: |
25
29
  (async () => {
26
- let sub = '${{ args.name }}';
30
+ let sub = ${{ args.name | json }};
27
31
  if (sub.startsWith('r/')) sub = sub.slice(2);
28
- const sort = '${{ args.sort }}';
29
- const res = await fetch('/r/' + sub + '/' + sort + '.json?limit=${{ args.limit }}', { credentials: 'include' });
32
+ const sort = ${{ args.sort | json }};
33
+ const time = ${{ args.time | json }};
34
+ const limit = ${{ args.limit }};
35
+ let url = '/r/' + sub + '/' + sort + '.json?limit=' + limit + '&raw_json=1';
36
+ if ((sort === 'top' || sort === 'controversial') && time) {
37
+ url += '&t=' + time;
38
+ }
39
+ const res = await fetch(url, { credentials: 'include' });
30
40
  const j = await res.json();
31
41
  return j?.data?.children || [];
32
42
  })()
@@ -0,0 +1,53 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+
3
+ cli({
4
+ site: 'reddit',
5
+ name: 'subscribe',
6
+ description: 'Subscribe or unsubscribe to a subreddit',
7
+ domain: 'reddit.com',
8
+ strategy: Strategy.COOKIE,
9
+ browser: true,
10
+ args: [
11
+ { name: 'subreddit', type: 'string', required: true, help: 'Subreddit name (e.g. python)' },
12
+ { name: 'undo', type: 'boolean', default: false, help: 'Unsubscribe instead of subscribe' },
13
+ ],
14
+ columns: ['status', 'message'],
15
+ func: async (page, kwargs) => {
16
+ if (!page) throw new Error('Requires browser');
17
+
18
+ await page.goto('https://www.reddit.com');
19
+ await page.wait(3);
20
+
21
+ const result = await page.evaluate(`(async () => {
22
+ try {
23
+ let sub = ${JSON.stringify(kwargs.subreddit)};
24
+ if (sub.startsWith('r/')) sub = sub.slice(2);
25
+
26
+ const undo = ${kwargs.undo ? 'true' : 'false'};
27
+ const action = undo ? 'unsub' : 'sub';
28
+
29
+ // Get modhash
30
+ const meRes = await fetch('/api/me.json', { credentials: 'include' });
31
+ const me = await meRes.json();
32
+ const modhash = me?.data?.modhash || '';
33
+
34
+ const res = await fetch('/api/subscribe', {
35
+ method: 'POST',
36
+ credentials: 'include',
37
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
38
+ body: 'sr_name=' + encodeURIComponent(sub)
39
+ + '&action=' + action
40
+ + (modhash ? '&uh=' + encodeURIComponent(modhash) : ''),
41
+ });
42
+
43
+ if (!res.ok) return { ok: false, message: 'HTTP ' + res.status };
44
+ const label = undo ? 'Unsubscribed from' : 'Subscribed to';
45
+ return { ok: true, message: label + ' r/' + sub };
46
+ } catch (e) {
47
+ return { ok: false, message: e.toString() };
48
+ }
49
+ })()`);
50
+
51
+ return [{ status: result.ok ? 'success' : 'failed', message: result.message }];
52
+ }
53
+ });