@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.
Files changed (68) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/SKILL.md +7 -2
  4. package/dist/build-manifest.js +2 -0
  5. package/dist/cli-manifest.json +723 -104
  6. package/dist/clis/boss/detail.d.ts +1 -0
  7. package/dist/clis/boss/detail.js +104 -0
  8. package/dist/clis/boss/search.js +2 -1
  9. package/dist/clis/reddit/comment.d.ts +1 -0
  10. package/dist/clis/reddit/comment.js +57 -0
  11. package/dist/clis/reddit/popular.yaml +40 -0
  12. package/dist/clis/reddit/read.yaml +76 -0
  13. package/dist/clis/reddit/save.d.ts +1 -0
  14. package/dist/clis/reddit/save.js +51 -0
  15. package/dist/clis/reddit/saved.d.ts +1 -0
  16. package/dist/clis/reddit/saved.js +46 -0
  17. package/dist/clis/reddit/search.yaml +37 -11
  18. package/dist/clis/reddit/subreddit.yaml +14 -4
  19. package/dist/clis/reddit/subscribe.d.ts +1 -0
  20. package/dist/clis/reddit/subscribe.js +50 -0
  21. package/dist/clis/reddit/upvote.d.ts +1 -0
  22. package/dist/clis/reddit/upvote.js +64 -0
  23. package/dist/clis/reddit/upvoted.d.ts +1 -0
  24. package/dist/clis/reddit/upvoted.js +46 -0
  25. package/dist/clis/reddit/user-comments.yaml +45 -0
  26. package/dist/clis/reddit/user-posts.yaml +43 -0
  27. package/dist/clis/reddit/user.yaml +39 -0
  28. package/dist/clis/twitter/article.d.ts +1 -0
  29. package/dist/clis/twitter/article.js +157 -0
  30. package/dist/clis/twitter/bookmark.d.ts +1 -0
  31. package/dist/clis/twitter/bookmark.js +63 -0
  32. package/dist/clis/twitter/follow.d.ts +1 -0
  33. package/dist/clis/twitter/follow.js +65 -0
  34. package/dist/clis/twitter/profile.js +110 -42
  35. package/dist/clis/twitter/thread.d.ts +1 -0
  36. package/dist/clis/twitter/thread.js +150 -0
  37. package/dist/clis/twitter/unbookmark.d.ts +1 -0
  38. package/dist/clis/twitter/unbookmark.js +62 -0
  39. package/dist/clis/twitter/unfollow.d.ts +1 -0
  40. package/dist/clis/twitter/unfollow.js +71 -0
  41. package/dist/main.js +31 -8
  42. package/dist/registry.d.ts +1 -0
  43. package/package.json +1 -1
  44. package/src/build-manifest.ts +3 -0
  45. package/src/clis/boss/detail.ts +115 -0
  46. package/src/clis/boss/search.ts +2 -1
  47. package/src/clis/reddit/comment.ts +60 -0
  48. package/src/clis/reddit/popular.yaml +40 -0
  49. package/src/clis/reddit/read.yaml +76 -0
  50. package/src/clis/reddit/save.ts +54 -0
  51. package/src/clis/reddit/saved.ts +48 -0
  52. package/src/clis/reddit/search.yaml +37 -11
  53. package/src/clis/reddit/subreddit.yaml +14 -4
  54. package/src/clis/reddit/subscribe.ts +53 -0
  55. package/src/clis/reddit/upvote.ts +67 -0
  56. package/src/clis/reddit/upvoted.ts +48 -0
  57. package/src/clis/reddit/user-comments.yaml +45 -0
  58. package/src/clis/reddit/user-posts.yaml +43 -0
  59. package/src/clis/reddit/user.yaml +39 -0
  60. package/src/clis/twitter/article.ts +161 -0
  61. package/src/clis/twitter/bookmark.ts +67 -0
  62. package/src/clis/twitter/follow.ts +69 -0
  63. package/src/clis/twitter/profile.ts +113 -45
  64. package/src/clis/twitter/thread.ts +181 -0
  65. package/src/clis/twitter/unbookmark.ts +66 -0
  66. package/src/clis/twitter/unfollow.ts +75 -0
  67. package/src/main.ts +24 -5
  68. 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
- 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.2",
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,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
+ });
@@ -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 }}