@jackwener/opencli 1.0.5 → 1.1.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 (97) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +36 -10
  3. package/README.zh-CN.md +3 -0
  4. package/SKILL.md +7 -2
  5. package/dist/bilibili.js +4 -2
  6. package/dist/cli-manifest.json +506 -6
  7. package/dist/cli.js +51 -1
  8. package/dist/clis/antigravity/serve.js +296 -47
  9. package/dist/clis/arxiv/paper.d.ts +1 -0
  10. package/dist/clis/arxiv/paper.js +21 -0
  11. package/dist/clis/arxiv/search.d.ts +1 -0
  12. package/dist/clis/arxiv/search.js +24 -0
  13. package/dist/clis/arxiv/utils.d.ts +18 -0
  14. package/dist/clis/arxiv/utils.js +49 -0
  15. package/dist/clis/boss/batchgreet.d.ts +1 -0
  16. package/dist/clis/boss/batchgreet.js +147 -0
  17. package/dist/clis/boss/exchange.d.ts +1 -0
  18. package/dist/clis/boss/exchange.js +111 -0
  19. package/dist/clis/boss/greet.d.ts +1 -0
  20. package/dist/clis/boss/greet.js +175 -0
  21. package/dist/clis/boss/invite.d.ts +1 -0
  22. package/dist/clis/boss/invite.js +158 -0
  23. package/dist/clis/boss/joblist.d.ts +1 -0
  24. package/dist/clis/boss/joblist.js +55 -0
  25. package/dist/clis/boss/mark.d.ts +1 -0
  26. package/dist/clis/boss/mark.js +141 -0
  27. package/dist/clis/boss/recommend.d.ts +1 -0
  28. package/dist/clis/boss/recommend.js +83 -0
  29. package/dist/clis/boss/stats.d.ts +1 -0
  30. package/dist/clis/boss/stats.js +116 -0
  31. package/dist/clis/sinafinance/news.d.ts +7 -0
  32. package/dist/clis/sinafinance/news.js +61 -0
  33. package/dist/clis/wikipedia/search.d.ts +1 -0
  34. package/dist/clis/wikipedia/search.js +30 -0
  35. package/dist/clis/wikipedia/summary.d.ts +1 -0
  36. package/dist/clis/wikipedia/summary.js +28 -0
  37. package/dist/clis/wikipedia/utils.d.ts +8 -0
  38. package/dist/clis/wikipedia/utils.js +18 -0
  39. package/dist/clis/xiaohongshu/creator-note-detail.d.ts +64 -5
  40. package/dist/clis/xiaohongshu/creator-note-detail.js +258 -69
  41. package/dist/clis/xiaohongshu/creator-note-detail.test.d.ts +1 -0
  42. package/dist/clis/xiaohongshu/creator-note-detail.test.js +211 -0
  43. package/dist/clis/xiaohongshu/creator-notes-summary.d.ts +28 -0
  44. package/dist/clis/xiaohongshu/creator-notes-summary.js +92 -0
  45. package/dist/clis/xiaohongshu/creator-notes-summary.test.d.ts +1 -0
  46. package/dist/clis/xiaohongshu/creator-notes-summary.test.js +49 -0
  47. package/dist/clis/xiaohongshu/creator-notes.d.ts +18 -5
  48. package/dist/clis/xiaohongshu/creator-notes.js +159 -71
  49. package/dist/clis/xiaohongshu/creator-notes.test.d.ts +1 -0
  50. package/dist/clis/xiaohongshu/creator-notes.test.js +162 -0
  51. package/dist/external.d.ts +20 -0
  52. package/dist/external.js +159 -0
  53. package/docs/.vitepress/config.mts +1 -0
  54. package/docs/public/CNAME +1 -0
  55. package/package.json +1 -1
  56. package/src/bilibili.ts +4 -2
  57. package/src/browser/cdp.ts +3 -3
  58. package/src/cli.ts +56 -1
  59. package/src/clis/antigravity/README.md +3 -46
  60. package/src/clis/antigravity/serve.ts +323 -50
  61. package/src/clis/arxiv/paper.ts +21 -0
  62. package/src/clis/arxiv/search.ts +24 -0
  63. package/src/clis/arxiv/utils.ts +63 -0
  64. package/src/clis/boss/batchgreet.ts +167 -0
  65. package/src/clis/boss/exchange.ts +126 -0
  66. package/src/clis/boss/greet.ts +198 -0
  67. package/src/clis/boss/invite.ts +177 -0
  68. package/src/clis/boss/joblist.ts +63 -0
  69. package/src/clis/boss/mark.ts +155 -0
  70. package/src/clis/boss/recommend.ts +94 -0
  71. package/src/clis/boss/stats.ts +130 -0
  72. package/src/clis/chaoxing/README.md +2 -24
  73. package/src/clis/chatgpt/README.md +3 -42
  74. package/src/clis/chatwise/README.md +3 -36
  75. package/src/clis/codex/README.md +3 -32
  76. package/src/clis/cursor/README.md +3 -31
  77. package/src/clis/discord-app/README.md +2 -25
  78. package/src/clis/feishu/README.md +2 -17
  79. package/src/clis/neteasemusic/README.md +3 -29
  80. package/src/clis/notion/README.md +2 -26
  81. package/src/clis/sinafinance/news.ts +76 -0
  82. package/src/clis/wechat/README.md +2 -25
  83. package/src/clis/wikipedia/search.ts +32 -0
  84. package/src/clis/wikipedia/summary.ts +28 -0
  85. package/src/clis/wikipedia/utils.ts +20 -0
  86. package/src/clis/xiaohongshu/creator-note-detail.test.ts +223 -0
  87. package/src/clis/xiaohongshu/creator-note-detail.ts +340 -72
  88. package/src/clis/xiaohongshu/creator-notes-summary.test.ts +54 -0
  89. package/src/clis/xiaohongshu/creator-notes-summary.ts +120 -0
  90. package/src/clis/xiaohongshu/creator-notes.test.ts +178 -0
  91. package/src/clis/xiaohongshu/creator-notes.ts +215 -75
  92. package/src/daemon.ts +3 -3
  93. package/src/external-clis.yaml +39 -0
  94. package/src/external.ts +182 -0
  95. package/CDP.md +0 -103
  96. package/CDP.zh-CN.md +0 -103
  97. package/CLI-ELECTRON.md +0 -125
@@ -0,0 +1,21 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { CliError } from '../../errors.js';
3
+ import { arxivFetch, parseEntries } from './utils.js';
4
+
5
+ cli({
6
+ site: 'arxiv',
7
+ name: 'paper',
8
+ description: 'Get arXiv paper details by ID',
9
+ strategy: Strategy.PUBLIC,
10
+ browser: false,
11
+ args: [
12
+ { name: 'id', positional: true, required: true, help: 'arXiv paper ID (e.g. 1706.03762)' },
13
+ ],
14
+ columns: ['id', 'title', 'authors', 'published', 'abstract', 'url'],
15
+ func: async (_page, args) => {
16
+ const xml = await arxivFetch(`id_list=${encodeURIComponent(args.id)}`);
17
+ const entries = parseEntries(xml);
18
+ if (!entries.length) throw new CliError('NOT_FOUND', `Paper ${args.id} not found`, 'Check the arXiv ID format, e.g. 1706.03762');
19
+ return entries;
20
+ },
21
+ });
@@ -0,0 +1,24 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { CliError } from '../../errors.js';
3
+ import { arxivFetch, parseEntries } from './utils.js';
4
+
5
+ cli({
6
+ site: 'arxiv',
7
+ name: 'search',
8
+ description: 'Search arXiv papers',
9
+ strategy: Strategy.PUBLIC,
10
+ browser: false,
11
+ args: [
12
+ { name: 'keyword', positional: true, required: true, help: 'Search keyword (e.g. "attention is all you need")' },
13
+ { name: 'limit', type: 'int', default: 10, help: 'Max results (max 25)' },
14
+ ],
15
+ columns: ['id', 'title', 'authors', 'published'],
16
+ func: async (_page, args) => {
17
+ const limit = Math.max(1, Math.min(Number(args.limit), 25));
18
+ const query = encodeURIComponent(`all:${args.keyword}`);
19
+ const xml = await arxivFetch(`search_query=${query}&max_results=${limit}&sortBy=relevance`);
20
+ const entries = parseEntries(xml);
21
+ if (!entries.length) throw new CliError('NOT_FOUND', 'No papers found', 'Try a different keyword');
22
+ return entries.map(e => ({ id: e.id, title: e.title, authors: e.authors, published: e.published }));
23
+ },
24
+ });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * arXiv adapter utilities.
3
+ *
4
+ * arXiv exposes a public Atom/XML API — no key required.
5
+ * https://info.arxiv.org/help/api/index.html
6
+ */
7
+
8
+ import { CliError } from '../../errors.js';
9
+
10
+ export const ARXIV_BASE = 'https://export.arxiv.org/api/query';
11
+
12
+ export async function arxivFetch(params: string): Promise<string> {
13
+ const resp = await fetch(`${ARXIV_BASE}?${params}`);
14
+ if (!resp.ok) {
15
+ throw new CliError('FETCH_ERROR', `arXiv API HTTP ${resp.status}`, 'Check your search term or paper ID');
16
+ }
17
+ return resp.text();
18
+ }
19
+
20
+ /** Extract the text content of the first matching XML tag. */
21
+ function extract(xml: string, tag: string): string {
22
+ const m = xml.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`));
23
+ return m ? m[1].trim() : '';
24
+ }
25
+
26
+ /** Extract all text contents of a repeated XML tag. */
27
+ function extractAll(xml: string, tag: string): string[] {
28
+ const re = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'g');
29
+ const results: string[] = [];
30
+ let m: RegExpExecArray | null;
31
+ while ((m = re.exec(xml)) !== null) results.push(m[1].trim());
32
+ return results;
33
+ }
34
+
35
+ export interface ArxivEntry {
36
+ id: string;
37
+ title: string;
38
+ authors: string;
39
+ abstract: string;
40
+ published: string;
41
+ url: string;
42
+ }
43
+
44
+ /** Parse Atom XML feed into structured entries. */
45
+ export function parseEntries(xml: string): ArxivEntry[] {
46
+ const entryRe = /<entry>([\s\S]*?)<\/entry>/g;
47
+ const entries: ArxivEntry[] = [];
48
+ let m: RegExpExecArray | null;
49
+ while ((m = entryRe.exec(xml)) !== null) {
50
+ const e = m[1];
51
+ const rawId = extract(e, 'id');
52
+ const arxivId = rawId.replace(/^https?:\/\/arxiv\.org\/abs\//, '').replace(/v\d+$/, '');
53
+ entries.push({
54
+ id: arxivId,
55
+ title: extract(e, 'title').replace(/\s+/g, ' '),
56
+ authors: extractAll(e, 'name').slice(0, 3).join(', '),
57
+ abstract: (() => { const s = extract(e, 'summary').replace(/\s+/g, ' '); return s.length > 200 ? s.slice(0, 200) + '...' : s; })(),
58
+ published: extract(e, 'published').slice(0, 10),
59
+ url: `https://arxiv.org/abs/${arxivId}`,
60
+ });
61
+ }
62
+ return entries;
63
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * BOSS直聘 batchgreet — batch greet recommended candidates.
3
+ *
4
+ * Combines recommend (greetRecSortList) + greet (UI automation).
5
+ * Sends greeting messages to multiple candidates sequentially.
6
+ */
7
+ import { cli, Strategy } from '../../registry.js';
8
+ import type { IPage } from '../../types.js';
9
+
10
+ cli({
11
+ site: 'boss',
12
+ name: 'batchgreet',
13
+ description: 'BOSS直聘批量向推荐候选人发送招呼',
14
+ domain: 'www.zhipin.com',
15
+ strategy: Strategy.COOKIE,
16
+ browser: true,
17
+ args: [
18
+ { name: 'job_id', default: '', help: 'Filter by encrypted job ID (greet all jobs if empty)' },
19
+ { name: 'limit', type: 'int', default: 5, help: 'Max candidates to greet' },
20
+ { name: 'text', default: '', help: 'Custom greeting message (uses default if empty)' },
21
+ ],
22
+ columns: ['name', 'status', 'detail'],
23
+ func: async (page: IPage | null, kwargs) => {
24
+ if (!page) throw new Error('Browser page required');
25
+
26
+ const filterJobId = kwargs.job_id || '';
27
+ const limit = kwargs.limit || 5;
28
+ const text = kwargs.text || '你好,请问您对这个职位感兴趣吗?';
29
+
30
+ if (process.env.OPENCLI_VERBOSE) {
31
+ console.error(`[opencli:boss] Batch greeting up to ${limit} candidates...`);
32
+ }
33
+
34
+ await page.goto('https://www.zhipin.com/web/chat/index');
35
+ await page.wait({ time: 3 });
36
+
37
+ // Get recommended candidates
38
+ const listData: any = await page.evaluate(`
39
+ async () => {
40
+ return new Promise((resolve, reject) => {
41
+ const xhr = new XMLHttpRequest();
42
+ xhr.open('GET', 'https://www.zhipin.com/wapi/zprelation/friend/greetRecSortList', true);
43
+ xhr.withCredentials = true;
44
+ xhr.timeout = 15000;
45
+ xhr.setRequestHeader('Accept', 'application/json');
46
+ xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(e); } };
47
+ xhr.onerror = () => reject(new Error('Network Error'));
48
+ xhr.send();
49
+ });
50
+ }
51
+ `);
52
+
53
+ if (listData.code !== 0) {
54
+ if (listData.code === 7 || listData.code === 37) {
55
+ throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。');
56
+ }
57
+ throw new Error(`获取推荐列表失败: ${listData.message}`);
58
+ }
59
+
60
+ let candidates = listData.zpData?.friendList || [];
61
+ if (filterJobId) {
62
+ candidates = candidates.filter((f: any) => f.encryptJobId === filterJobId);
63
+ }
64
+ candidates = candidates.slice(0, limit);
65
+
66
+ if (candidates.length === 0) {
67
+ return [{ name: '-', status: '⚠️ 无候选人', detail: '当前没有待招呼的推荐候选人' }];
68
+ }
69
+
70
+ const results: any[] = [];
71
+
72
+ for (const candidate of candidates) {
73
+ const numericUid = candidate.uid;
74
+ const friendName = candidate.name || '候选人';
75
+
76
+ try {
77
+ // Click on candidate
78
+ const clicked: any = await page.evaluate(`
79
+ async () => {
80
+ const item = document.querySelector('#_${numericUid}-0') || document.querySelector('[id^="_${numericUid}"]');
81
+ if (item) {
82
+ item.click();
83
+ return { clicked: true };
84
+ }
85
+ const items = document.querySelectorAll('.geek-item');
86
+ for (const el of items) {
87
+ if (el.id && el.id.startsWith('_${numericUid}')) {
88
+ el.click();
89
+ return { clicked: true };
90
+ }
91
+ }
92
+ return { clicked: false };
93
+ }
94
+ `);
95
+
96
+ if (!clicked.clicked) {
97
+ results.push({ name: friendName, status: '❌ 跳过', detail: '在聊天列表中未找到' });
98
+ continue;
99
+ }
100
+
101
+ await page.wait({ time: 2 });
102
+
103
+ // Type message
104
+ const typed: any = await page.evaluate(`
105
+ async () => {
106
+ const selectors = [
107
+ '.chat-editor [contenteditable="true"]',
108
+ '.chat-input [contenteditable="true"]',
109
+ '[contenteditable="true"]',
110
+ 'textarea',
111
+ ];
112
+ for (const sel of selectors) {
113
+ const el = document.querySelector(sel);
114
+ if (el && el.offsetParent !== null) {
115
+ el.focus();
116
+ if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
117
+ el.value = ${JSON.stringify(text)};
118
+ el.dispatchEvent(new Event('input', { bubbles: true }));
119
+ } else {
120
+ el.textContent = '';
121
+ el.focus();
122
+ document.execCommand('insertText', false, ${JSON.stringify(text)});
123
+ el.dispatchEvent(new Event('input', { bubbles: true }));
124
+ }
125
+ return { found: true };
126
+ }
127
+ }
128
+ return { found: false };
129
+ }
130
+ `);
131
+
132
+ if (!typed.found) {
133
+ results.push({ name: friendName, status: '❌ 失败', detail: '找不到消息输入框' });
134
+ continue;
135
+ }
136
+
137
+ await page.wait({ time: 0.5 });
138
+
139
+ // Click send
140
+ const sent: any = await page.evaluate(`
141
+ async () => {
142
+ const btn = document.querySelector('.conversation-editor .submit')
143
+ || document.querySelector('.submit-content .submit')
144
+ || document.querySelector('.conversation-operate .submit');
145
+ if (btn) {
146
+ btn.click();
147
+ return { clicked: true };
148
+ }
149
+ return { clicked: false };
150
+ }
151
+ `);
152
+
153
+ if (!sent.clicked) {
154
+ await page.pressKey('Enter');
155
+ }
156
+
157
+ await page.wait({ time: 1.5 });
158
+
159
+ results.push({ name: friendName, status: '✅ 已发送', detail: text });
160
+ } catch (e: any) {
161
+ results.push({ name: friendName, status: '❌ 失败', detail: e.message?.substring(0, 80) || '未知错误' });
162
+ }
163
+ }
164
+
165
+ return results;
166
+ },
167
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * BOSS直聘 exchange — request phone/wechat exchange with a candidate.
3
+ *
4
+ * Uses POST /wapi/zpchat/exchange/request to send an exchange request.
5
+ */
6
+ import { cli, Strategy } from '../../registry.js';
7
+ import type { IPage } from '../../types.js';
8
+
9
+ cli({
10
+ site: 'boss',
11
+ name: 'exchange',
12
+ description: 'BOSS直聘交换联系方式(请求手机/微信)',
13
+ domain: 'www.zhipin.com',
14
+ strategy: Strategy.COOKIE,
15
+ browser: true,
16
+ args: [
17
+ { name: 'uid', required: true, help: 'Encrypted UID of the candidate' },
18
+ { name: 'type', default: 'phone', choices: ['phone', 'wechat'], help: 'Exchange type: phone or wechat' },
19
+ ],
20
+ columns: ['status', 'detail'],
21
+ func: async (page: IPage | null, kwargs) => {
22
+ if (!page) throw new Error('Browser page required');
23
+
24
+ const uid = kwargs.uid;
25
+ const exchangeType = kwargs.type || 'phone';
26
+
27
+ if (process.env.OPENCLI_VERBOSE) {
28
+ console.error(`[opencli:boss] Requesting ${exchangeType} exchange for ${uid}...`);
29
+ }
30
+
31
+ await page.goto('https://www.zhipin.com/web/chat/index');
32
+ await page.wait({ time: 2 });
33
+
34
+ // Find candidate
35
+ let friend: any = null;
36
+
37
+ // Check greet list
38
+ const greetData: any = await page.evaluate(`
39
+ async () => {
40
+ return new Promise((resolve, reject) => {
41
+ const xhr = new XMLHttpRequest();
42
+ xhr.open('GET', 'https://www.zhipin.com/wapi/zprelation/friend/greetRecSortList', true);
43
+ xhr.withCredentials = true;
44
+ xhr.timeout = 15000;
45
+ xhr.setRequestHeader('Accept', 'application/json');
46
+ xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(e); } };
47
+ xhr.onerror = () => reject(new Error('Network Error'));
48
+ xhr.send();
49
+ });
50
+ }
51
+ `);
52
+
53
+ if (greetData.code === 0) {
54
+ friend = (greetData.zpData?.friendList || []).find((f: any) => f.encryptUid === uid);
55
+ }
56
+
57
+ if (!friend) {
58
+ const friendData: any = await page.evaluate(`
59
+ async () => {
60
+ return new Promise((resolve, reject) => {
61
+ const xhr = new XMLHttpRequest();
62
+ xhr.open('GET', 'https://www.zhipin.com/wapi/zprelation/friend/getBossFriendListV2.json?page=1&status=0&jobId=0', true);
63
+ xhr.withCredentials = true;
64
+ xhr.timeout = 15000;
65
+ xhr.setRequestHeader('Accept', 'application/json');
66
+ xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(e); } };
67
+ xhr.onerror = () => reject(new Error('Network Error'));
68
+ xhr.send();
69
+ });
70
+ }
71
+ `);
72
+ if (friendData.code === 0) {
73
+ friend = (friendData.zpData?.friendList || []).find((f: any) => f.encryptUid === uid);
74
+ }
75
+ }
76
+
77
+ if (!friend) {
78
+ throw new Error('未找到该候选人');
79
+ }
80
+
81
+ const numericUid = friend.uid;
82
+ const friendName = friend.name || '候选人';
83
+ const securityId = friend.securityId || '';
84
+
85
+ // type mapping from JS source: 1=phone, 2=wechat, 4=resume
86
+ const typeId = exchangeType === 'wechat' ? 2 : 1;
87
+
88
+ // Params from JS: {type, securityId, uniqueId, name}
89
+ const params = new URLSearchParams({
90
+ type: String(typeId),
91
+ securityId: securityId,
92
+ uniqueId: String(numericUid),
93
+ name: friendName,
94
+ });
95
+
96
+ // POST with form-urlencoded (discovered from 336.js bundle)
97
+ const data: any = await page.evaluate(`
98
+ async () => {
99
+ return new Promise((resolve, reject) => {
100
+ const xhr = new XMLHttpRequest();
101
+ xhr.open('POST', 'https://www.zhipin.com/wapi/zpchat/exchange/request', true);
102
+ xhr.withCredentials = true;
103
+ xhr.timeout = 15000;
104
+ xhr.setRequestHeader('Accept', 'application/json');
105
+ xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
106
+ xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(new Error('JSON parse failed')); } };
107
+ xhr.onerror = () => reject(new Error('Network Error'));
108
+ xhr.send(${JSON.stringify(params.toString())});
109
+ });
110
+ }
111
+ `);
112
+
113
+ if (data.code !== 0) {
114
+ if (data.code === 7 || data.code === 37) {
115
+ throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。');
116
+ }
117
+ throw new Error(`交换请求失败: ${data.message} (code=${data.code})`);
118
+ }
119
+
120
+ const typeLabel = exchangeType === 'wechat' ? '微信' : '手机号';
121
+ return [{
122
+ status: '✅ 交换请求已发送',
123
+ detail: `已向 ${friendName} 发送${typeLabel}交换请求`,
124
+ }];
125
+ },
126
+ });
@@ -0,0 +1,198 @@
1
+ /**
2
+ * BOSS直聘 greet — send greeting to a new candidate (initiate chat).
3
+ *
4
+ * This is different from send.ts which messages existing contacts.
5
+ * For new candidates (from recommend list), we navigate to their chat page
6
+ * and use UI automation to send the greeting message.
7
+ *
8
+ * The greetRecSortList provides candidates who have applied or been recommended.
9
+ * We click on them in the list and send a greeting.
10
+ */
11
+ import { cli, Strategy } from '../../registry.js';
12
+ import type { IPage } from '../../types.js';
13
+
14
+ cli({
15
+ site: 'boss',
16
+ name: 'greet',
17
+ description: 'BOSS直聘向新候选人发送招呼(开始聊天)',
18
+ domain: 'www.zhipin.com',
19
+ strategy: Strategy.COOKIE,
20
+ browser: true,
21
+ args: [
22
+ { name: 'uid', required: true, help: 'Encrypted UID of the candidate (from recommend)' },
23
+ { name: 'security_id', required: true, help: 'Security ID of the candidate' },
24
+ { name: 'job_id', required: true, help: 'Encrypted job ID' },
25
+ { name: 'text', default: '', help: 'Custom greeting message (uses default template if empty)' },
26
+ ],
27
+ columns: ['status', 'detail'],
28
+ func: async (page: IPage | null, kwargs) => {
29
+ if (!page) throw new Error('Browser page required');
30
+
31
+ const uid = kwargs.uid;
32
+ const securityId = kwargs.security_id;
33
+ const jobId = kwargs.job_id;
34
+ const text = kwargs.text;
35
+
36
+ if (process.env.OPENCLI_VERBOSE) {
37
+ console.error(`[opencli:boss] Greeting candidate ${uid}...`);
38
+ }
39
+
40
+ // Navigate to chat page
41
+ await page.goto('https://www.zhipin.com/web/chat/index');
42
+ await page.wait({ time: 3 });
43
+
44
+ // Find the candidate in the greet list by encryptUid
45
+ const listData: any = await page.evaluate(`
46
+ async () => {
47
+ return new Promise((resolve, reject) => {
48
+ const xhr = new XMLHttpRequest();
49
+ xhr.open('GET', 'https://www.zhipin.com/wapi/zprelation/friend/greetRecSortList', true);
50
+ xhr.withCredentials = true;
51
+ xhr.timeout = 15000;
52
+ xhr.setRequestHeader('Accept', 'application/json');
53
+ xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(e); } };
54
+ xhr.onerror = () => reject(new Error('Network Error'));
55
+ xhr.send();
56
+ });
57
+ }
58
+ `);
59
+
60
+ if (listData.code !== 0) {
61
+ if (listData.code === 7 || listData.code === 37) {
62
+ throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。');
63
+ }
64
+ throw new Error(`获取候选人列表失败: ${listData.message}`);
65
+ }
66
+
67
+ // Also check the regular friend list
68
+ let target: any = null;
69
+ const greetList = listData.zpData?.friendList || [];
70
+ target = greetList.find((f: any) => f.encryptUid === uid);
71
+
72
+ let numericUid: string | null = null;
73
+ let friendName = '候选人';
74
+
75
+ if (target) {
76
+ numericUid = target.uid;
77
+ friendName = target.name || friendName;
78
+ }
79
+
80
+ if (!numericUid) {
81
+ // Try to find in friend list
82
+ const friendData: any = await page.evaluate(`
83
+ async () => {
84
+ return new Promise((resolve, reject) => {
85
+ const xhr = new XMLHttpRequest();
86
+ xhr.open('GET', 'https://www.zhipin.com/wapi/zprelation/friend/getBossFriendListV2.json?page=1&status=0&jobId=0', true);
87
+ xhr.withCredentials = true;
88
+ xhr.timeout = 15000;
89
+ xhr.setRequestHeader('Accept', 'application/json');
90
+ xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(e); } };
91
+ xhr.onerror = () => reject(new Error('Network Error'));
92
+ xhr.send();
93
+ });
94
+ }
95
+ `);
96
+
97
+ if (friendData.code === 0) {
98
+ const allFriends = friendData.zpData?.friendList || [];
99
+ const found = allFriends.find((f: any) => f.encryptUid === uid);
100
+ if (found) {
101
+ numericUid = found.uid;
102
+ friendName = found.name || friendName;
103
+ }
104
+ }
105
+ }
106
+
107
+ if (!numericUid) {
108
+ throw new Error('未找到该候选人,请确认 uid 是否正确(可从 recommend 命令获取)');
109
+ }
110
+
111
+ // Click on the candidate in the chat list
112
+ const clicked: any = await page.evaluate(`
113
+ async () => {
114
+ const item = document.querySelector('#_${numericUid}-0') || document.querySelector('[id^="_${numericUid}"]');
115
+ if (item) {
116
+ item.click();
117
+ return { clicked: true, id: item.id };
118
+ }
119
+ const items = document.querySelectorAll('.geek-item');
120
+ for (const el of items) {
121
+ if (el.id && el.id.startsWith('_${numericUid}')) {
122
+ el.click();
123
+ return { clicked: true, id: el.id };
124
+ }
125
+ }
126
+ return { clicked: false };
127
+ }
128
+ `);
129
+
130
+ if (!clicked.clicked) {
131
+ throw new Error('无法在聊天列表中找到该用户,候选人可能不在当前列表中');
132
+ }
133
+
134
+ await page.wait({ time: 2 });
135
+
136
+ // Type the message
137
+ const msgText = text || '你好,请问您对这个职位感兴趣吗?';
138
+
139
+ const typed: any = await page.evaluate(`
140
+ async () => {
141
+ const selectors = [
142
+ '.chat-editor [contenteditable="true"]',
143
+ '.chat-input [contenteditable="true"]',
144
+ '.message-editor [contenteditable="true"]',
145
+ '.chat-conversation [contenteditable="true"]',
146
+ '[contenteditable="true"]',
147
+ 'textarea',
148
+ ];
149
+
150
+ for (const sel of selectors) {
151
+ const el = document.querySelector(sel);
152
+ if (el && el.offsetParent !== null) {
153
+ el.focus();
154
+ if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
155
+ el.value = ${JSON.stringify(msgText)};
156
+ el.dispatchEvent(new Event('input', { bubbles: true }));
157
+ } else {
158
+ el.textContent = '';
159
+ el.focus();
160
+ document.execCommand('insertText', false, ${JSON.stringify(msgText)});
161
+ el.dispatchEvent(new Event('input', { bubbles: true }));
162
+ }
163
+ return { found: true, selector: sel };
164
+ }
165
+ }
166
+ return { found: false };
167
+ }
168
+ `);
169
+
170
+ if (!typed.found) {
171
+ throw new Error('找不到消息输入框');
172
+ }
173
+
174
+ await page.wait({ time: 0.5 });
175
+
176
+ // Click send button
177
+ const sent: any = await page.evaluate(`
178
+ async () => {
179
+ const btn = document.querySelector('.conversation-editor .submit')
180
+ || document.querySelector('.submit-content .submit')
181
+ || document.querySelector('.conversation-operate .submit');
182
+ if (btn) {
183
+ btn.click();
184
+ return { clicked: true };
185
+ }
186
+ return { clicked: false };
187
+ }
188
+ `);
189
+
190
+ if (!sent.clicked) {
191
+ await page.pressKey('Enter');
192
+ }
193
+
194
+ await page.wait({ time: 1 });
195
+
196
+ return [{ status: '✅ 招呼已发送', detail: `已向 ${friendName} 发送: ${msgText}` }];
197
+ },
198
+ });