@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,150 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
// ── Twitter GraphQL constants ──────────────────────────────────────────
|
|
3
|
+
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
4
|
+
const TWEET_DETAIL_QUERY_ID = 'nBS-WpgA6ZG0CyNHD517JQ';
|
|
5
|
+
const FEATURES = {
|
|
6
|
+
responsive_web_graphql_exclude_directive_enabled: true,
|
|
7
|
+
verified_phone_label_enabled: false,
|
|
8
|
+
creator_subscriptions_tweet_preview_api_enabled: true,
|
|
9
|
+
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
10
|
+
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
|
11
|
+
longform_notetweets_consumption_enabled: true,
|
|
12
|
+
longform_notetweets_rich_text_read_enabled: true,
|
|
13
|
+
longform_notetweets_inline_media_enabled: true,
|
|
14
|
+
freedom_of_speech_not_reach_fetch_enabled: true,
|
|
15
|
+
};
|
|
16
|
+
const FIELD_TOGGLES = { withArticleRichContentState: true, withArticlePlainText: false };
|
|
17
|
+
function buildTweetDetailUrl(tweetId, cursor) {
|
|
18
|
+
const vars = {
|
|
19
|
+
focalTweetId: tweetId,
|
|
20
|
+
referrer: 'tweet',
|
|
21
|
+
with_rux_injections: false,
|
|
22
|
+
includePromotedContent: false,
|
|
23
|
+
rankingMode: 'Recency',
|
|
24
|
+
withCommunity: true,
|
|
25
|
+
withQuickPromoteEligibilityTweetFields: true,
|
|
26
|
+
withBirdwatchNotes: true,
|
|
27
|
+
withVoice: true,
|
|
28
|
+
};
|
|
29
|
+
if (cursor)
|
|
30
|
+
vars.cursor = cursor;
|
|
31
|
+
return `/i/api/graphql/${TWEET_DETAIL_QUERY_ID}/TweetDetail`
|
|
32
|
+
+ `?variables=${encodeURIComponent(JSON.stringify(vars))}`
|
|
33
|
+
+ `&features=${encodeURIComponent(JSON.stringify(FEATURES))}`
|
|
34
|
+
+ `&fieldToggles=${encodeURIComponent(JSON.stringify(FIELD_TOGGLES))}`;
|
|
35
|
+
}
|
|
36
|
+
function extractTweet(r, seen) {
|
|
37
|
+
if (!r)
|
|
38
|
+
return null;
|
|
39
|
+
const tw = r.tweet || r;
|
|
40
|
+
const l = tw.legacy || {};
|
|
41
|
+
if (!tw.rest_id || seen.has(tw.rest_id))
|
|
42
|
+
return null;
|
|
43
|
+
seen.add(tw.rest_id);
|
|
44
|
+
const u = tw.core?.user_results?.result;
|
|
45
|
+
const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
|
|
46
|
+
const screenName = u?.legacy?.screen_name || u?.core?.screen_name || 'unknown';
|
|
47
|
+
return {
|
|
48
|
+
id: tw.rest_id,
|
|
49
|
+
author: screenName,
|
|
50
|
+
text: noteText || l.full_text || '',
|
|
51
|
+
likes: l.favorite_count || 0,
|
|
52
|
+
retweets: l.retweet_count || 0,
|
|
53
|
+
in_reply_to: l.in_reply_to_status_id_str || undefined,
|
|
54
|
+
created_at: l.created_at,
|
|
55
|
+
url: `https://x.com/${screenName}/status/${tw.rest_id}`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function parseTweetDetail(data, seen) {
|
|
59
|
+
const tweets = [];
|
|
60
|
+
let nextCursor = null;
|
|
61
|
+
const instructions = data?.data?.threaded_conversation_with_injections_v2?.instructions
|
|
62
|
+
|| data?.data?.tweetResult?.result?.timeline?.instructions
|
|
63
|
+
|| [];
|
|
64
|
+
for (const inst of instructions) {
|
|
65
|
+
for (const entry of inst.entries || []) {
|
|
66
|
+
// Cursor entries
|
|
67
|
+
const c = entry.content;
|
|
68
|
+
if (c?.entryType === 'TimelineTimelineCursor' || c?.__typename === 'TimelineTimelineCursor') {
|
|
69
|
+
if (c.cursorType === 'Bottom' || c.cursorType === 'ShowMore')
|
|
70
|
+
nextCursor = c.value;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (entry.entryId?.startsWith('cursor-bottom-') || entry.entryId?.startsWith('cursor-showMore-')) {
|
|
74
|
+
nextCursor = c?.itemContent?.value || c?.value || nextCursor;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// Direct tweet entry
|
|
78
|
+
const tw = extractTweet(c?.itemContent?.tweet_results?.result, seen);
|
|
79
|
+
if (tw)
|
|
80
|
+
tweets.push(tw);
|
|
81
|
+
// Conversation module (nested replies)
|
|
82
|
+
for (const item of c?.items || []) {
|
|
83
|
+
const nested = extractTweet(item.item?.itemContent?.tweet_results?.result, seen);
|
|
84
|
+
if (nested)
|
|
85
|
+
tweets.push(nested);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { tweets, nextCursor };
|
|
90
|
+
}
|
|
91
|
+
// ── CLI definition ────────────────────────────────────────────────────
|
|
92
|
+
cli({
|
|
93
|
+
site: 'twitter',
|
|
94
|
+
name: 'thread',
|
|
95
|
+
description: 'Get a tweet thread (original + all replies)',
|
|
96
|
+
domain: 'x.com',
|
|
97
|
+
strategy: Strategy.COOKIE,
|
|
98
|
+
browser: true,
|
|
99
|
+
args: [
|
|
100
|
+
{ name: 'tweet_id', type: 'string', required: true },
|
|
101
|
+
{ name: 'limit', type: 'int', default: 50 },
|
|
102
|
+
],
|
|
103
|
+
columns: ['id', 'author', 'text', 'likes', 'retweets', 'url'],
|
|
104
|
+
func: async (page, kwargs) => {
|
|
105
|
+
let tweetId = kwargs.tweet_id;
|
|
106
|
+
const urlMatch = tweetId.match(/\/status\/(\d+)/);
|
|
107
|
+
if (urlMatch)
|
|
108
|
+
tweetId = urlMatch[1];
|
|
109
|
+
// Navigate to x.com for cookie context
|
|
110
|
+
await page.goto('https://x.com');
|
|
111
|
+
await page.wait(3);
|
|
112
|
+
// Extract CSRF token — the only thing we need from the browser
|
|
113
|
+
const ct0 = await page.evaluate(`() => {
|
|
114
|
+
return document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || null;
|
|
115
|
+
}`);
|
|
116
|
+
if (!ct0)
|
|
117
|
+
throw new Error('Not logged into x.com (no ct0 cookie)');
|
|
118
|
+
// Build auth headers in TypeScript
|
|
119
|
+
const headers = JSON.stringify({
|
|
120
|
+
'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
|
|
121
|
+
'X-Csrf-Token': ct0,
|
|
122
|
+
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
123
|
+
'X-Twitter-Active-User': 'yes',
|
|
124
|
+
});
|
|
125
|
+
// Paginate — fetch in browser, parse in TypeScript
|
|
126
|
+
const allTweets = [];
|
|
127
|
+
const seen = new Set();
|
|
128
|
+
let cursor = null;
|
|
129
|
+
for (let i = 0; i < 5; i++) {
|
|
130
|
+
const apiUrl = buildTweetDetailUrl(tweetId, cursor);
|
|
131
|
+
// Browser-side: just fetch + return JSON (3 lines)
|
|
132
|
+
const data = await page.evaluate(`async () => {
|
|
133
|
+
const r = await fetch("${apiUrl}", { headers: ${headers}, credentials: 'include' });
|
|
134
|
+
return r.ok ? await r.json() : { error: r.status };
|
|
135
|
+
}`);
|
|
136
|
+
if (data?.error) {
|
|
137
|
+
if (allTweets.length === 0)
|
|
138
|
+
throw new Error(`HTTP ${data.error}: Tweet not found or queryId expired`);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
// TypeScript-side: type-safe parsing + cursor extraction
|
|
142
|
+
const { tweets, nextCursor } = parseTweetDetail(data, seen);
|
|
143
|
+
allTweets.push(...tweets);
|
|
144
|
+
if (!nextCursor || nextCursor === cursor)
|
|
145
|
+
break;
|
|
146
|
+
cursor = nextCursor;
|
|
147
|
+
}
|
|
148
|
+
return allTweets.slice(0, kwargs.limit);
|
|
149
|
+
},
|
|
150
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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,115 @@
|
|
|
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
|
+
import type { IPage } from '../../types.js';
|
|
9
|
+
|
|
10
|
+
cli({
|
|
11
|
+
site: 'boss',
|
|
12
|
+
name: 'detail',
|
|
13
|
+
description: 'BOSS直聘查看职位详情',
|
|
14
|
+
domain: 'www.zhipin.com',
|
|
15
|
+
strategy: Strategy.COOKIE,
|
|
16
|
+
|
|
17
|
+
browser: true,
|
|
18
|
+
args: [
|
|
19
|
+
{ name: 'security_id', required: true, help: 'Security ID from search results (securityId field)' },
|
|
20
|
+
],
|
|
21
|
+
columns: [
|
|
22
|
+
'name', 'salary', 'experience', 'degree', 'city', 'district',
|
|
23
|
+
'description', 'skills', 'welfare',
|
|
24
|
+
'boss_name', 'boss_title', 'active_time',
|
|
25
|
+
'company', 'industry', 'scale', 'stage',
|
|
26
|
+
'address', 'url',
|
|
27
|
+
],
|
|
28
|
+
func: async (page: IPage | null, kwargs) => {
|
|
29
|
+
if (!page) throw new Error('Browser page required');
|
|
30
|
+
|
|
31
|
+
const securityId = kwargs.security_id;
|
|
32
|
+
|
|
33
|
+
// Navigate to zhipin.com first to establish cookie context (referrer + cookies)
|
|
34
|
+
await page.goto('https://www.zhipin.com/web/geek/job');
|
|
35
|
+
await page.wait({ time: 1 });
|
|
36
|
+
|
|
37
|
+
const targetUrl = `https://www.zhipin.com/wapi/zpgeek/job/detail.json?securityId=${encodeURIComponent(securityId)}`;
|
|
38
|
+
|
|
39
|
+
if (process.env.OPENCLI_VERBOSE || process.env.DEBUG?.includes('opencli')) {
|
|
40
|
+
console.error(`[opencli:boss] Fetching job detail...`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const evaluateScript = `
|
|
44
|
+
async () => {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const xhr = new window.XMLHttpRequest();
|
|
47
|
+
xhr.open('GET', ${JSON.stringify(targetUrl)}, true);
|
|
48
|
+
xhr.withCredentials = true;
|
|
49
|
+
xhr.timeout = 15000;
|
|
50
|
+
xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
|
|
51
|
+
xhr.onload = () => {
|
|
52
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
53
|
+
try {
|
|
54
|
+
resolve(JSON.parse(xhr.responseText));
|
|
55
|
+
} catch (e) {
|
|
56
|
+
reject(new Error('Failed to parse JSON. Raw (200 chars): ' + xhr.responseText.substring(0, 200)));
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
reject(new Error('XHR HTTP Status: ' + xhr.status));
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
xhr.onerror = () => reject(new Error('XHR Network Error'));
|
|
63
|
+
xhr.ontimeout = () => reject(new Error('XHR Timeout'));
|
|
64
|
+
xhr.send();
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
`;
|
|
68
|
+
|
|
69
|
+
let data: any;
|
|
70
|
+
try {
|
|
71
|
+
data = await page.evaluate(evaluateScript);
|
|
72
|
+
} catch (e: any) {
|
|
73
|
+
throw new Error('API evaluate failed: ' + e.message);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (data.code !== 0) {
|
|
77
|
+
if (data.code === 37) {
|
|
78
|
+
throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。');
|
|
79
|
+
}
|
|
80
|
+
throw new Error(`BOSS API error: ${data.message || 'Unknown'} (code=${data.code})`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const zpData = data.zpData || {};
|
|
84
|
+
const jobInfo = zpData.jobInfo || {};
|
|
85
|
+
const bossInfo = zpData.bossInfo || {};
|
|
86
|
+
const brandComInfo = zpData.brandComInfo || {};
|
|
87
|
+
|
|
88
|
+
if (!jobInfo.jobName) {
|
|
89
|
+
throw new Error('该职位信息不存在或已下架');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return [{
|
|
93
|
+
name: jobInfo.jobName || '',
|
|
94
|
+
salary: jobInfo.salaryDesc || '',
|
|
95
|
+
experience: jobInfo.experienceName || '',
|
|
96
|
+
degree: jobInfo.degreeName || '',
|
|
97
|
+
city: jobInfo.locationName || '',
|
|
98
|
+
district: [jobInfo.areaDistrict, jobInfo.businessDistrict].filter(Boolean).join('·'),
|
|
99
|
+
description: jobInfo.postDescription || '',
|
|
100
|
+
skills: (jobInfo.showSkills || []).join(', '),
|
|
101
|
+
welfare: (brandComInfo.labels || []).join(', '),
|
|
102
|
+
boss_name: bossInfo.name || '',
|
|
103
|
+
boss_title: bossInfo.title || '',
|
|
104
|
+
active_time: bossInfo.activeTimeDesc || '',
|
|
105
|
+
company: brandComInfo.brandName || bossInfo.brandName || '',
|
|
106
|
+
industry: brandComInfo.industryName || '',
|
|
107
|
+
scale: brandComInfo.scaleName || '',
|
|
108
|
+
stage: brandComInfo.stageName || '',
|
|
109
|
+
address: jobInfo.address || '',
|
|
110
|
+
url: jobInfo.encryptId
|
|
111
|
+
? 'https://www.zhipin.com/job_detail/' + jobInfo.encryptId + '.html'
|
|
112
|
+
: '',
|
|
113
|
+
}];
|
|
114
|
+
},
|
|
115
|
+
});
|
package/src/clis/boss/search.ts
CHANGED
|
@@ -81,7 +81,7 @@ cli({
|
|
|
81
81
|
{ name: 'page', type: 'int', default: 1, help: 'Page number' },
|
|
82
82
|
{ name: 'limit', type: 'int', default: 15, help: 'Number of results' },
|
|
83
83
|
],
|
|
84
|
-
columns: ['name', 'salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss', 'url'],
|
|
84
|
+
columns: ['name', 'salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss', 'security_id', 'url'],
|
|
85
85
|
func: async (page: IPage | null, kwargs) => {
|
|
86
86
|
if (!page) throw new Error('Browser page required');
|
|
87
87
|
|
|
@@ -191,6 +191,7 @@ cli({
|
|
|
191
191
|
degree: j.jobDegree,
|
|
192
192
|
skills: (j.skills || []).join(','),
|
|
193
193
|
boss: j.bossName + ' · ' + j.bossTitle,
|
|
194
|
+
security_id: j.securityId || '',
|
|
194
195
|
url: 'https://www.zhipin.com/job_detail/' + j.encryptJobId + '.html',
|
|
195
196
|
});
|
|
196
197
|
addedInBatch++;
|
|
@@ -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 }}
|