@jackwener/opencli 0.6.2 → 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 +2 -2
- package/README.zh-CN.md +2 -2
- package/SKILL.md +7 -2
- package/dist/build-manifest.js +2 -0
- package/dist/cli-manifest.json +723 -104
- package/dist/clis/boss/detail.d.ts +1 -0
- package/dist/clis/boss/detail.js +104 -0
- package/dist/clis/boss/search.js +2 -1
- 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/boss/detail.ts +115 -0
- package/src/clis/boss/search.ts +2 -1
- 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 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BOSS直聘 job detail — fetch full job posting details via browser cookie API.
|
|
3
|
+
*
|
|
4
|
+
* Uses securityId from search results to call the detail API.
|
|
5
|
+
* Returns: job description, skills, welfare, boss info, company info, address.
|
|
6
|
+
*/
|
|
7
|
+
import { cli, Strategy } from '../../registry.js';
|
|
8
|
+
cli({
|
|
9
|
+
site: 'boss',
|
|
10
|
+
name: 'detail',
|
|
11
|
+
description: 'BOSS直聘查看职位详情',
|
|
12
|
+
domain: 'www.zhipin.com',
|
|
13
|
+
strategy: Strategy.COOKIE,
|
|
14
|
+
browser: true,
|
|
15
|
+
args: [
|
|
16
|
+
{ name: 'security_id', required: true, help: 'Security ID from search results (securityId field)' },
|
|
17
|
+
],
|
|
18
|
+
columns: [
|
|
19
|
+
'name', 'salary', 'experience', 'degree', 'city', 'district',
|
|
20
|
+
'description', 'skills', 'welfare',
|
|
21
|
+
'boss_name', 'boss_title', 'active_time',
|
|
22
|
+
'company', 'industry', 'scale', 'stage',
|
|
23
|
+
'address', 'url',
|
|
24
|
+
],
|
|
25
|
+
func: async (page, kwargs) => {
|
|
26
|
+
if (!page)
|
|
27
|
+
throw new Error('Browser page required');
|
|
28
|
+
const securityId = kwargs.security_id;
|
|
29
|
+
// Navigate to zhipin.com first to establish cookie context (referrer + cookies)
|
|
30
|
+
await page.goto('https://www.zhipin.com/web/geek/job');
|
|
31
|
+
await page.wait({ time: 1 });
|
|
32
|
+
const targetUrl = `https://www.zhipin.com/wapi/zpgeek/job/detail.json?securityId=${encodeURIComponent(securityId)}`;
|
|
33
|
+
if (process.env.OPENCLI_VERBOSE || process.env.DEBUG?.includes('opencli')) {
|
|
34
|
+
console.error(`[opencli:boss] Fetching job detail...`);
|
|
35
|
+
}
|
|
36
|
+
const evaluateScript = `
|
|
37
|
+
async () => {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const xhr = new window.XMLHttpRequest();
|
|
40
|
+
xhr.open('GET', ${JSON.stringify(targetUrl)}, true);
|
|
41
|
+
xhr.withCredentials = true;
|
|
42
|
+
xhr.timeout = 15000;
|
|
43
|
+
xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
|
|
44
|
+
xhr.onload = () => {
|
|
45
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
46
|
+
try {
|
|
47
|
+
resolve(JSON.parse(xhr.responseText));
|
|
48
|
+
} catch (e) {
|
|
49
|
+
reject(new Error('Failed to parse JSON. Raw (200 chars): ' + xhr.responseText.substring(0, 200)));
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
reject(new Error('XHR HTTP Status: ' + xhr.status));
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
xhr.onerror = () => reject(new Error('XHR Network Error'));
|
|
56
|
+
xhr.ontimeout = () => reject(new Error('XHR Timeout'));
|
|
57
|
+
xhr.send();
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
`;
|
|
61
|
+
let data;
|
|
62
|
+
try {
|
|
63
|
+
data = await page.evaluate(evaluateScript);
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
throw new Error('API evaluate failed: ' + e.message);
|
|
67
|
+
}
|
|
68
|
+
if (data.code !== 0) {
|
|
69
|
+
if (data.code === 37) {
|
|
70
|
+
throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。');
|
|
71
|
+
}
|
|
72
|
+
throw new Error(`BOSS API error: ${data.message || 'Unknown'} (code=${data.code})`);
|
|
73
|
+
}
|
|
74
|
+
const zpData = data.zpData || {};
|
|
75
|
+
const jobInfo = zpData.jobInfo || {};
|
|
76
|
+
const bossInfo = zpData.bossInfo || {};
|
|
77
|
+
const brandComInfo = zpData.brandComInfo || {};
|
|
78
|
+
if (!jobInfo.jobName) {
|
|
79
|
+
throw new Error('该职位信息不存在或已下架');
|
|
80
|
+
}
|
|
81
|
+
return [{
|
|
82
|
+
name: jobInfo.jobName || '',
|
|
83
|
+
salary: jobInfo.salaryDesc || '',
|
|
84
|
+
experience: jobInfo.experienceName || '',
|
|
85
|
+
degree: jobInfo.degreeName || '',
|
|
86
|
+
city: jobInfo.locationName || '',
|
|
87
|
+
district: [jobInfo.areaDistrict, jobInfo.businessDistrict].filter(Boolean).join('·'),
|
|
88
|
+
description: jobInfo.postDescription || '',
|
|
89
|
+
skills: (jobInfo.showSkills || []).join(', '),
|
|
90
|
+
welfare: (brandComInfo.labels || []).join(', '),
|
|
91
|
+
boss_name: bossInfo.name || '',
|
|
92
|
+
boss_title: bossInfo.title || '',
|
|
93
|
+
active_time: bossInfo.activeTimeDesc || '',
|
|
94
|
+
company: brandComInfo.brandName || bossInfo.brandName || '',
|
|
95
|
+
industry: brandComInfo.industryName || '',
|
|
96
|
+
scale: brandComInfo.scaleName || '',
|
|
97
|
+
stage: brandComInfo.stageName || '',
|
|
98
|
+
address: jobInfo.address || '',
|
|
99
|
+
url: jobInfo.encryptId
|
|
100
|
+
? 'https://www.zhipin.com/job_detail/' + jobInfo.encryptId + '.html'
|
|
101
|
+
: '',
|
|
102
|
+
}];
|
|
103
|
+
},
|
|
104
|
+
});
|
package/dist/clis/boss/search.js
CHANGED
|
@@ -78,7 +78,7 @@ cli({
|
|
|
78
78
|
{ name: 'page', type: 'int', default: 1, help: 'Page number' },
|
|
79
79
|
{ name: 'limit', type: 'int', default: 15, help: 'Number of results' },
|
|
80
80
|
],
|
|
81
|
-
columns: ['name', 'salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss', 'url'],
|
|
81
|
+
columns: ['name', 'salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss', 'security_id', 'url'],
|
|
82
82
|
func: async (page, kwargs) => {
|
|
83
83
|
if (!page)
|
|
84
84
|
throw new Error('Browser page required');
|
|
@@ -180,6 +180,7 @@ cli({
|
|
|
180
180
|
degree: j.jobDegree,
|
|
181
181
|
skills: (j.skills || []).join(','),
|
|
182
182
|
boss: j.bossName + ' · ' + j.bossTitle,
|
|
183
|
+
security_id: j.securityId || '',
|
|
183
184
|
url: 'https://www.zhipin.com/job_detail/' + j.encryptJobId + '.html',
|
|
184
185
|
});
|
|
185
186
|
addedInBatch++;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
cli({
|
|
3
|
+
site: 'reddit',
|
|
4
|
+
name: 'comment',
|
|
5
|
+
description: 'Post a comment on a Reddit post',
|
|
6
|
+
domain: 'reddit.com',
|
|
7
|
+
strategy: Strategy.COOKIE,
|
|
8
|
+
browser: true,
|
|
9
|
+
args: [
|
|
10
|
+
{ name: 'post_id', type: 'string', required: true, help: 'Post ID (e.g. 1abc123) or fullname (t3_xxx)' },
|
|
11
|
+
{ name: 'text', type: 'string', required: true, help: 'Comment text' },
|
|
12
|
+
],
|
|
13
|
+
columns: ['status', 'message'],
|
|
14
|
+
func: async (page, kwargs) => {
|
|
15
|
+
if (!page)
|
|
16
|
+
throw new Error('Requires browser');
|
|
17
|
+
await page.goto('https://www.reddit.com');
|
|
18
|
+
await page.wait(3);
|
|
19
|
+
const result = await page.evaluate(`(async () => {
|
|
20
|
+
try {
|
|
21
|
+
let postId = ${JSON.stringify(kwargs.post_id)};
|
|
22
|
+
const urlMatch = postId.match(/comments\\/([a-z0-9]+)/);
|
|
23
|
+
if (urlMatch) postId = urlMatch[1];
|
|
24
|
+
const fullname = postId.startsWith('t3_') || postId.startsWith('t1_')
|
|
25
|
+
? postId : 't3_' + postId;
|
|
26
|
+
|
|
27
|
+
const text = ${JSON.stringify(kwargs.text)};
|
|
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/comment', {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
credentials: 'include',
|
|
37
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
38
|
+
body: 'parent=' + encodeURIComponent(fullname)
|
|
39
|
+
+ '&text=' + encodeURIComponent(text)
|
|
40
|
+
+ '&api_type=json'
|
|
41
|
+
+ (modhash ? '&uh=' + encodeURIComponent(modhash) : ''),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (!res.ok) return { ok: false, message: 'HTTP ' + res.status };
|
|
45
|
+
const data = await res.json();
|
|
46
|
+
const errors = data?.json?.errors;
|
|
47
|
+
if (errors && errors.length > 0) {
|
|
48
|
+
return { ok: false, message: errors.map(e => e.join(': ')).join('; ') };
|
|
49
|
+
}
|
|
50
|
+
return { ok: true, message: 'Comment posted on ' + fullname };
|
|
51
|
+
} catch (e) {
|
|
52
|
+
return { ok: false, message: e.toString() };
|
|
53
|
+
}
|
|
54
|
+
})()`);
|
|
55
|
+
return [{ status: result.ok ? 'success' : 'failed', message: result.message }];
|
|
56
|
+
}
|
|
57
|
+
});
|
|
@@ -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 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
cli({
|
|
3
|
+
site: 'reddit',
|
|
4
|
+
name: 'save',
|
|
5
|
+
description: 'Save or unsave a Reddit post',
|
|
6
|
+
domain: 'reddit.com',
|
|
7
|
+
strategy: Strategy.COOKIE,
|
|
8
|
+
browser: true,
|
|
9
|
+
args: [
|
|
10
|
+
{ name: 'post_id', type: 'string', required: true, help: 'Post ID (e.g. 1abc123) or fullname (t3_xxx)' },
|
|
11
|
+
{ name: 'undo', type: 'boolean', default: false, help: 'Unsave instead of save' },
|
|
12
|
+
],
|
|
13
|
+
columns: ['status', 'message'],
|
|
14
|
+
func: async (page, kwargs) => {
|
|
15
|
+
if (!page)
|
|
16
|
+
throw new Error('Requires browser');
|
|
17
|
+
await page.goto('https://www.reddit.com');
|
|
18
|
+
await page.wait(3);
|
|
19
|
+
const result = await page.evaluate(`(async () => {
|
|
20
|
+
try {
|
|
21
|
+
let postId = ${JSON.stringify(kwargs.post_id)};
|
|
22
|
+
const urlMatch = postId.match(/comments\\/([a-z0-9]+)/);
|
|
23
|
+
if (urlMatch) postId = urlMatch[1];
|
|
24
|
+
const fullname = postId.startsWith('t3_') || postId.startsWith('t1_')
|
|
25
|
+
? postId : 't3_' + postId;
|
|
26
|
+
|
|
27
|
+
const undo = ${kwargs.undo ? 'true' : 'false'};
|
|
28
|
+
const endpoint = undo ? '/api/unsave' : '/api/save';
|
|
29
|
+
|
|
30
|
+
// Get modhash
|
|
31
|
+
const meRes = await fetch('/api/me.json', { credentials: 'include' });
|
|
32
|
+
const me = await meRes.json();
|
|
33
|
+
const modhash = me?.data?.modhash || '';
|
|
34
|
+
|
|
35
|
+
const res = await fetch(endpoint, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
credentials: 'include',
|
|
38
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
39
|
+
body: 'id=' + encodeURIComponent(fullname)
|
|
40
|
+
+ (modhash ? '&uh=' + encodeURIComponent(modhash) : ''),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (!res.ok) return { ok: false, message: 'HTTP ' + res.status };
|
|
44
|
+
return { ok: true, message: (undo ? 'Unsaved' : 'Saved') + ' ' + fullname };
|
|
45
|
+
} catch (e) {
|
|
46
|
+
return { ok: false, message: e.toString() };
|
|
47
|
+
}
|
|
48
|
+
})()`);
|
|
49
|
+
return [{ status: result.ok ? 'success' : 'failed', message: result.message }];
|
|
50
|
+
}
|
|
51
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
cli({
|
|
3
|
+
site: 'reddit',
|
|
4
|
+
name: 'saved',
|
|
5
|
+
description: 'Browse your saved Reddit posts',
|
|
6
|
+
domain: 'reddit.com',
|
|
7
|
+
strategy: Strategy.COOKIE,
|
|
8
|
+
browser: true,
|
|
9
|
+
args: [
|
|
10
|
+
{ name: 'limit', type: 'int', default: 15 },
|
|
11
|
+
],
|
|
12
|
+
columns: ['title', 'subreddit', 'score', 'comments', 'url'],
|
|
13
|
+
func: async (page, kwargs) => {
|
|
14
|
+
if (!page)
|
|
15
|
+
throw new Error('Requires browser');
|
|
16
|
+
await page.goto('https://www.reddit.com');
|
|
17
|
+
await page.wait(3);
|
|
18
|
+
const result = await page.evaluate(`(async () => {
|
|
19
|
+
try {
|
|
20
|
+
// Get current username
|
|
21
|
+
const meRes = await fetch('/api/me.json?raw_json=1', { credentials: 'include' });
|
|
22
|
+
const me = await meRes.json();
|
|
23
|
+
const username = me?.name || me?.data?.name;
|
|
24
|
+
if (!username) return { error: 'Not logged in — cannot determine username' };
|
|
25
|
+
|
|
26
|
+
const limit = ${kwargs.limit};
|
|
27
|
+
const res = await fetch('/user/' + username + '/saved.json?limit=' + limit + '&raw_json=1', {
|
|
28
|
+
credentials: 'include'
|
|
29
|
+
});
|
|
30
|
+
const d = await res.json();
|
|
31
|
+
return (d?.data?.children || []).map(c => ({
|
|
32
|
+
title: c.data.title || c.data.body?.slice(0, 100) || '-',
|
|
33
|
+
subreddit: c.data.subreddit_name_prefixed || 'r/' + (c.data.subreddit || '?'),
|
|
34
|
+
score: c.data.score || 0,
|
|
35
|
+
comments: c.data.num_comments || 0,
|
|
36
|
+
url: 'https://www.reddit.com' + (c.data.permalink || ''),
|
|
37
|
+
}));
|
|
38
|
+
} catch (e) {
|
|
39
|
+
return { error: e.toString() };
|
|
40
|
+
}
|
|
41
|
+
})()`);
|
|
42
|
+
if (result?.error)
|
|
43
|
+
throw new Error(result.error);
|
|
44
|
+
return (result || []).slice(0, kwargs.limit);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
@@ -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 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
cli({
|
|
3
|
+
site: 'reddit',
|
|
4
|
+
name: 'subscribe',
|
|
5
|
+
description: 'Subscribe or unsubscribe to a subreddit',
|
|
6
|
+
domain: 'reddit.com',
|
|
7
|
+
strategy: Strategy.COOKIE,
|
|
8
|
+
browser: true,
|
|
9
|
+
args: [
|
|
10
|
+
{ name: 'subreddit', type: 'string', required: true, help: 'Subreddit name (e.g. python)' },
|
|
11
|
+
{ name: 'undo', type: 'boolean', default: false, help: 'Unsubscribe instead of subscribe' },
|
|
12
|
+
],
|
|
13
|
+
columns: ['status', 'message'],
|
|
14
|
+
func: async (page, kwargs) => {
|
|
15
|
+
if (!page)
|
|
16
|
+
throw new Error('Requires browser');
|
|
17
|
+
await page.goto('https://www.reddit.com');
|
|
18
|
+
await page.wait(3);
|
|
19
|
+
const result = await page.evaluate(`(async () => {
|
|
20
|
+
try {
|
|
21
|
+
let sub = ${JSON.stringify(kwargs.subreddit)};
|
|
22
|
+
if (sub.startsWith('r/')) sub = sub.slice(2);
|
|
23
|
+
|
|
24
|
+
const undo = ${kwargs.undo ? 'true' : 'false'};
|
|
25
|
+
const action = undo ? 'unsub' : 'sub';
|
|
26
|
+
|
|
27
|
+
// Get modhash
|
|
28
|
+
const meRes = await fetch('/api/me.json', { credentials: 'include' });
|
|
29
|
+
const me = await meRes.json();
|
|
30
|
+
const modhash = me?.data?.modhash || '';
|
|
31
|
+
|
|
32
|
+
const res = await fetch('/api/subscribe', {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
credentials: 'include',
|
|
35
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
36
|
+
body: 'sr_name=' + encodeURIComponent(sub)
|
|
37
|
+
+ '&action=' + action
|
|
38
|
+
+ (modhash ? '&uh=' + encodeURIComponent(modhash) : ''),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (!res.ok) return { ok: false, message: 'HTTP ' + res.status };
|
|
42
|
+
const label = undo ? 'Unsubscribed from' : 'Subscribed to';
|
|
43
|
+
return { ok: true, message: label + ' r/' + sub };
|
|
44
|
+
} catch (e) {
|
|
45
|
+
return { ok: false, message: e.toString() };
|
|
46
|
+
}
|
|
47
|
+
})()`);
|
|
48
|
+
return [{ status: result.ok ? 'success' : 'failed', message: result.message }];
|
|
49
|
+
}
|
|
50
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
cli({
|
|
3
|
+
site: 'reddit',
|
|
4
|
+
name: 'upvote',
|
|
5
|
+
description: 'Upvote or downvote a Reddit post',
|
|
6
|
+
domain: 'reddit.com',
|
|
7
|
+
strategy: Strategy.COOKIE,
|
|
8
|
+
browser: true,
|
|
9
|
+
args: [
|
|
10
|
+
{ name: 'post_id', type: 'string', required: true, help: 'Post ID (e.g. 1abc123) or fullname (t3_xxx)' },
|
|
11
|
+
{ name: 'direction', type: 'string', default: 'up', help: 'Vote direction: up, down, none' },
|
|
12
|
+
],
|
|
13
|
+
columns: ['status', 'message'],
|
|
14
|
+
func: async (page, kwargs) => {
|
|
15
|
+
if (!page)
|
|
16
|
+
throw new Error('Requires browser');
|
|
17
|
+
await page.goto('https://www.reddit.com');
|
|
18
|
+
await page.wait(3);
|
|
19
|
+
const result = await page.evaluate(`(async () => {
|
|
20
|
+
try {
|
|
21
|
+
let postId = ${JSON.stringify(kwargs.post_id)};
|
|
22
|
+
// Extract ID from URL if needed
|
|
23
|
+
const urlMatch = postId.match(/comments\\/([a-z0-9]+)/);
|
|
24
|
+
if (urlMatch) postId = urlMatch[1];
|
|
25
|
+
// Build fullname
|
|
26
|
+
const fullname = postId.startsWith('t3_') || postId.startsWith('t1_')
|
|
27
|
+
? postId : 't3_' + postId;
|
|
28
|
+
|
|
29
|
+
const dir = ${JSON.stringify(kwargs.direction)};
|
|
30
|
+
const direction = dir === 'down' ? -1 : dir === 'none' ? 0 : 1;
|
|
31
|
+
|
|
32
|
+
// Get modhash from Reddit config
|
|
33
|
+
const configEl = document.getElementById('config');
|
|
34
|
+
let modhash = '';
|
|
35
|
+
if (configEl) {
|
|
36
|
+
modhash = configEl.querySelector('[name="uh"]')?.getAttribute('content') || '';
|
|
37
|
+
}
|
|
38
|
+
if (!modhash) {
|
|
39
|
+
// Try fetching from /api/me.json
|
|
40
|
+
const meRes = await fetch('/api/me.json', { credentials: 'include' });
|
|
41
|
+
const me = await meRes.json();
|
|
42
|
+
modhash = me?.data?.modhash || '';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const res = await fetch('/api/vote', {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
credentials: 'include',
|
|
48
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
49
|
+
body: 'id=' + encodeURIComponent(fullname)
|
|
50
|
+
+ '&dir=' + direction
|
|
51
|
+
+ (modhash ? '&uh=' + encodeURIComponent(modhash) : ''),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (!res.ok) return { ok: false, message: 'HTTP ' + res.status };
|
|
55
|
+
|
|
56
|
+
const labels = { '1': 'Upvoted', '-1': 'Downvoted', '0': 'Vote removed' };
|
|
57
|
+
return { ok: true, message: (labels[String(direction)] || 'Voted') + ' ' + fullname };
|
|
58
|
+
} catch (e) {
|
|
59
|
+
return { ok: false, message: e.toString() };
|
|
60
|
+
}
|
|
61
|
+
})()`);
|
|
62
|
+
return [{ status: result.ok ? 'success' : 'failed', message: result.message }];
|
|
63
|
+
}
|
|
64
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|