@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.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/SKILL.md +7 -2
- package/dist/build-manifest.js +2 -0
- package/dist/cli-manifest.json +604 -24
- package/dist/clis/reddit/comment.d.ts +1 -0
- package/dist/clis/reddit/comment.js +57 -0
- package/dist/clis/reddit/popular.yaml +40 -0
- package/dist/clis/reddit/read.yaml +76 -0
- package/dist/clis/reddit/save.d.ts +1 -0
- package/dist/clis/reddit/save.js +51 -0
- package/dist/clis/reddit/saved.d.ts +1 -0
- package/dist/clis/reddit/saved.js +46 -0
- package/dist/clis/reddit/search.yaml +37 -11
- package/dist/clis/reddit/subreddit.yaml +14 -4
- package/dist/clis/reddit/subscribe.d.ts +1 -0
- package/dist/clis/reddit/subscribe.js +50 -0
- package/dist/clis/reddit/upvote.d.ts +1 -0
- package/dist/clis/reddit/upvote.js +64 -0
- package/dist/clis/reddit/upvoted.d.ts +1 -0
- package/dist/clis/reddit/upvoted.js +46 -0
- package/dist/clis/reddit/user-comments.yaml +45 -0
- package/dist/clis/reddit/user-posts.yaml +43 -0
- package/dist/clis/reddit/user.yaml +39 -0
- package/dist/clis/twitter/article.d.ts +1 -0
- package/dist/clis/twitter/article.js +157 -0
- package/dist/clis/twitter/bookmark.d.ts +1 -0
- package/dist/clis/twitter/bookmark.js +63 -0
- package/dist/clis/twitter/follow.d.ts +1 -0
- package/dist/clis/twitter/follow.js +65 -0
- package/dist/clis/twitter/profile.js +110 -42
- package/dist/clis/twitter/thread.d.ts +1 -0
- package/dist/clis/twitter/thread.js +150 -0
- package/dist/clis/twitter/unbookmark.d.ts +1 -0
- package/dist/clis/twitter/unbookmark.js +62 -0
- package/dist/clis/twitter/unfollow.d.ts +1 -0
- package/dist/clis/twitter/unfollow.js +71 -0
- package/dist/main.js +31 -8
- package/dist/registry.d.ts +1 -0
- package/package.json +1 -1
- package/src/build-manifest.ts +3 -0
- package/src/clis/reddit/comment.ts +60 -0
- package/src/clis/reddit/popular.yaml +40 -0
- package/src/clis/reddit/read.yaml +76 -0
- package/src/clis/reddit/save.ts +54 -0
- package/src/clis/reddit/saved.ts +48 -0
- package/src/clis/reddit/search.yaml +37 -11
- package/src/clis/reddit/subreddit.yaml +14 -4
- package/src/clis/reddit/subscribe.ts +53 -0
- package/src/clis/reddit/upvote.ts +67 -0
- package/src/clis/reddit/upvoted.ts +48 -0
- package/src/clis/reddit/user-comments.yaml +45 -0
- package/src/clis/reddit/user-posts.yaml +43 -0
- package/src/clis/reddit/user.yaml +39 -0
- package/src/clis/twitter/article.ts +161 -0
- package/src/clis/twitter/bookmark.ts +67 -0
- package/src/clis/twitter/follow.ts +69 -0
- package/src/clis/twitter/profile.ts +113 -45
- package/src/clis/twitter/thread.ts +181 -0
- package/src/clis/twitter/unbookmark.ts +66 -0
- package/src/clis/twitter/unfollow.ts +75 -0
- package/src/main.ts +24 -5
- 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
|
-
|
|
136
|
-
|
|
137
|
-
subCmd.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
else
|
|
141
|
-
|
|
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 (
|
|
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');
|
package/dist/registry.d.ts
CHANGED
package/package.json
CHANGED
package/src/build-manifest.ts
CHANGED
|
@@ -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,
|
|
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(
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
|
|
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.
|
|
29
|
-
subreddit: ${{ item.
|
|
30
|
-
author: ${{ item.
|
|
31
|
-
|
|
32
|
-
comments: ${{ item.
|
|
33
|
-
url:
|
|
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 =
|
|
30
|
+
let sub = ${{ args.name | json }};
|
|
27
31
|
if (sub.startsWith('r/')) sub = sub.slice(2);
|
|
28
|
-
const sort =
|
|
29
|
-
const
|
|
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
|
+
});
|