@harness.farm/social-cli 0.1.3 → 0.1.6
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 +29 -42
- package/adapters/bilibili.yaml +4 -7
- package/adapters/xhs.yaml +19 -0
- package/adapters/zhipin.yaml +94 -0
- package/dist/cli.js +6 -0
- package/dist/scripts/zhipin-download-resumes.js +191 -0
- package/dist/scripts/zhipin-screen-resumes.js +165 -0
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -18,36 +18,31 @@ social-cli
|
|
|
18
18
|
|
|
19
19
|
## 快速开始
|
|
20
20
|
|
|
21
|
-
### 1.
|
|
21
|
+
### 1. 安装
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install -g social-cli
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### 2. 启动 Chrome(开启远程调试)
|
|
22
28
|
|
|
23
29
|
```bash
|
|
24
30
|
# macOS
|
|
25
31
|
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
|
|
26
32
|
--remote-debugging-port=9222 \
|
|
27
|
-
--user-data-dir=$HOME/.
|
|
33
|
+
--user-data-dir=$HOME/.social-cli/chrome-profile
|
|
28
34
|
|
|
29
35
|
# Windows
|
|
30
|
-
chrome.exe --remote-debugging-port=9222 --user-data-dir=%USERPROFILE%\.
|
|
36
|
+
chrome.exe --remote-debugging-port=9222 --user-data-dir=%USERPROFILE%\.social-cli\chrome-profile
|
|
31
37
|
|
|
32
38
|
# Linux
|
|
33
|
-
google-chrome --remote-debugging-port=9222 --user-data-dir=~/.
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
### 2. 安装依赖
|
|
37
|
-
|
|
38
|
-
```bash
|
|
39
|
-
npm install
|
|
39
|
+
google-chrome --remote-debugging-port=9222 --user-data-dir=~/.social-cli/chrome-profile
|
|
40
40
|
```
|
|
41
41
|
|
|
42
42
|
### 3. 运行命令
|
|
43
43
|
|
|
44
44
|
```bash
|
|
45
|
-
|
|
46
|
-
tsx src/cli.ts <platform> <command> [args...]
|
|
47
|
-
|
|
48
|
-
# 或使用 package.json 快捷脚本
|
|
49
|
-
npm run xhs -- search 法律ai
|
|
50
|
-
npm run x -- search "claude ai"
|
|
45
|
+
social-cli <platform> <command> [args...]
|
|
51
46
|
```
|
|
52
47
|
|
|
53
48
|
## 支持的平台和命令
|
|
@@ -63,11 +58,11 @@ npm run x -- search "claude ai"
|
|
|
63
58
|
| `retweet` | `url` | 转推 |
|
|
64
59
|
|
|
65
60
|
```bash
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
61
|
+
social-cli x search "claude ai"
|
|
62
|
+
social-cli x like "https://x.com/user/status/123"
|
|
63
|
+
social-cli x reply "https://x.com/user/status/123" "很有意思!"
|
|
64
|
+
social-cli x post "Hello from social-cli!"
|
|
65
|
+
social-cli x retweet "https://x.com/user/status/123"
|
|
71
66
|
```
|
|
72
67
|
|
|
73
68
|
### 小红书(xhs / xiaohongshu)
|
|
@@ -81,11 +76,11 @@ tsx src/cli.ts x retweet "https://x.com/user/status/123"
|
|
|
81
76
|
| `post` | `title content` | 发布图文笔记 |
|
|
82
77
|
|
|
83
78
|
```bash
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
79
|
+
social-cli xhs search 法律ai
|
|
80
|
+
social-cli xhs hot
|
|
81
|
+
social-cli xhs like "https://www.xiaohongshu.com/explore/..."
|
|
82
|
+
social-cli xhs comment "https://..." "太棒了!"
|
|
83
|
+
social-cli xhs post --title "我的标题" --content "正文内容"
|
|
89
84
|
```
|
|
90
85
|
|
|
91
86
|
### 抖音(douyin)
|
|
@@ -99,10 +94,10 @@ tsx src/cli.ts xhs post --title "我的标题" --content "正文内容"
|
|
|
99
94
|
| `post` | `video title desc` | 发布视频 |
|
|
100
95
|
|
|
101
96
|
```bash
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
97
|
+
social-cli douyin search 猫咪
|
|
98
|
+
social-cli douyin like "https://www.douyin.com/video/..."
|
|
99
|
+
social-cli douyin comment "https://..." "哈哈哈"
|
|
100
|
+
social-cli douyin post /path/to/video.mp4 "标题" "描述"
|
|
106
101
|
```
|
|
107
102
|
|
|
108
103
|
### B站(bilibili)
|
|
@@ -117,10 +112,10 @@ tsx src/cli.ts douyin post /path/to/video.mp4 "标题" "描述"
|
|
|
117
112
|
| `post` | `video title desc` | 投稿视频 |
|
|
118
113
|
|
|
119
114
|
```bash
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
115
|
+
social-cli bilibili search TypeScript教程
|
|
116
|
+
social-cli bilibili like "https://www.bilibili.com/video/BV..."
|
|
117
|
+
social-cli bilibili comment "https://..." "讲得很好!"
|
|
118
|
+
social-cli bilibili post /path/to/video.mp4 "视频标题" "视频描述"
|
|
124
119
|
```
|
|
125
120
|
|
|
126
121
|
## YAML 适配器格式
|
|
@@ -172,14 +167,6 @@ commands:
|
|
|
172
167
|
|
|
173
168
|
模板变量使用 `{{varName}}` 语法,也支持 JS 表达式:`{{a === 'true' ? '是' : '否'}}`
|
|
174
169
|
|
|
175
|
-
## 编译发布
|
|
176
|
-
|
|
177
|
-
```bash
|
|
178
|
-
npm run build # 编译 TypeScript → dist/
|
|
179
|
-
```
|
|
180
|
-
|
|
181
|
-
编译后可作为 `social-cli` 命令使用(需全局安装或 `npx`)。
|
|
182
|
-
|
|
183
170
|
## 项目结构
|
|
184
171
|
|
|
185
172
|
```
|
package/adapters/bilibili.yaml
CHANGED
|
@@ -267,13 +267,10 @@ commands:
|
|
|
267
267
|
return el.textContent.trim().slice(0,30);
|
|
268
268
|
})()
|
|
269
269
|
- wait: 500
|
|
270
|
-
# 点击"立即投稿"
|
|
271
|
-
- eval:
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
if(btn) { btn.click(); return true; }
|
|
275
|
-
return false;
|
|
276
|
-
})()
|
|
270
|
+
# 点击"立即投稿"(用 agent-browser click 发送真实鼠标事件,JS .click() 在此无效)
|
|
271
|
+
- eval: "document.querySelector('span.submit-add')?.scrollIntoView({block:'center'})"
|
|
272
|
+
- wait: 500
|
|
273
|
+
- click: "span.submit-add"
|
|
277
274
|
- wait: 5000
|
|
278
275
|
- capture:
|
|
279
276
|
name: result_url
|
package/adapters/xhs.yaml
CHANGED
|
@@ -89,6 +89,9 @@ commands:
|
|
|
89
89
|
steps:
|
|
90
90
|
- open: "https://creator.xiaohongshu.com/publish/publish?source=official"
|
|
91
91
|
- wait: 3000
|
|
92
|
+
# 等待上传按钮渲染完成
|
|
93
|
+
- wait:
|
|
94
|
+
selector: "input.upload-input"
|
|
92
95
|
# 默认就在「上传视频」tab,直接上传
|
|
93
96
|
- upload:
|
|
94
97
|
selector: "input.upload-input"
|
|
@@ -96,6 +99,22 @@ commands:
|
|
|
96
99
|
# 等待上传完成(标题输入框出现)
|
|
97
100
|
- wait:
|
|
98
101
|
selector: "input.d-text"
|
|
102
|
+
# 等待视频真正上传完成(进度条消失 / 发布按钮可点击)
|
|
103
|
+
- wait_until:
|
|
104
|
+
eval: >-
|
|
105
|
+
(function(){
|
|
106
|
+
var body = document.body.innerText;
|
|
107
|
+
if (body.includes('上传完成') || body.includes('转码完成')) return true;
|
|
108
|
+
var progress = document.querySelector('[class*=progress],[class*=uploading],[class*=upload-progress]');
|
|
109
|
+
if (progress) {
|
|
110
|
+
var text = progress.textContent || '';
|
|
111
|
+
if (text.includes('100%')) return true;
|
|
112
|
+
}
|
|
113
|
+
var btn = [...document.querySelectorAll('button')].filter(function(b){ return b.textContent.trim() === '发布'; }).pop();
|
|
114
|
+
return !!(btn && !btn.disabled);
|
|
115
|
+
})()
|
|
116
|
+
timeout: 300000
|
|
117
|
+
interval: 3000
|
|
99
118
|
- wait: 1000
|
|
100
119
|
# 填写标题
|
|
101
120
|
- fill:
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
platform: zhipin
|
|
2
|
+
login_url: https://www.zhipin.com
|
|
3
|
+
login_check:
|
|
4
|
+
cookie: wt2
|
|
5
|
+
|
|
6
|
+
commands:
|
|
7
|
+
|
|
8
|
+
# ── 统计发起对话人数 ────────────────────────────────────────────────────────
|
|
9
|
+
# 打开聊天列表,滚动加载所有会话,统计总人数及未读数量
|
|
10
|
+
chat_stats:
|
|
11
|
+
args: []
|
|
12
|
+
steps:
|
|
13
|
+
- open: "https://www.zhipin.com/web/geek/chat"
|
|
14
|
+
- wait: 4000
|
|
15
|
+
# 滚动加载所有聊天记录
|
|
16
|
+
- eval: >-
|
|
17
|
+
(function(){
|
|
18
|
+
var list = document.querySelector(".user-list");
|
|
19
|
+
if(!list) return;
|
|
20
|
+
var last = 0;
|
|
21
|
+
var tries = 0;
|
|
22
|
+
function scroll(){
|
|
23
|
+
list.scrollTop = list.scrollHeight;
|
|
24
|
+
}
|
|
25
|
+
scroll();
|
|
26
|
+
})()
|
|
27
|
+
- wait: 2000
|
|
28
|
+
- eval: "document.querySelector('.user-list') && (document.querySelector('.user-list').scrollTop = 99999)"
|
|
29
|
+
- wait: 1500
|
|
30
|
+
- eval: "document.querySelector('.user-list') && (document.querySelector('.user-list').scrollTop = 99999)"
|
|
31
|
+
- wait: 1500
|
|
32
|
+
- eval: "document.querySelector('.user-list') && (document.querySelector('.user-list').scrollTop = 99999)"
|
|
33
|
+
- wait: 1500
|
|
34
|
+
# 抓取所有聊天条目
|
|
35
|
+
- capture:
|
|
36
|
+
name: stats
|
|
37
|
+
eval: >-
|
|
38
|
+
(function(){
|
|
39
|
+
var items = document.querySelectorAll(".geek-item");
|
|
40
|
+
var total = items.length;
|
|
41
|
+
var unreadCount = 0;
|
|
42
|
+
var unreadList = [];
|
|
43
|
+
Array.from(items).forEach(function(item){
|
|
44
|
+
var badge = item.querySelector(".badge-count span");
|
|
45
|
+
var unread = badge ? parseInt(badge.textContent.trim()) || 0 : 0;
|
|
46
|
+
if(unread > 0){
|
|
47
|
+
unreadCount++;
|
|
48
|
+
var name = item.querySelector(".geek-name");
|
|
49
|
+
var job = item.querySelector(".source-job");
|
|
50
|
+
var msg = item.querySelector(".gray");
|
|
51
|
+
unreadList.push({
|
|
52
|
+
name: name ? name.textContent.trim() : "",
|
|
53
|
+
job: job ? job.textContent.trim() : "",
|
|
54
|
+
unread: unread,
|
|
55
|
+
lastMsg: msg ? msg.textContent.trim().slice(0,40) : ""
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
return JSON.stringify({
|
|
60
|
+
total: total,
|
|
61
|
+
unread_count: unreadCount,
|
|
62
|
+
unread_list: unreadList
|
|
63
|
+
});
|
|
64
|
+
})()
|
|
65
|
+
- return:
|
|
66
|
+
- field: 总会话数
|
|
67
|
+
value: "{{JSON.parse(stats).total}}"
|
|
68
|
+
- field: 未读会话数
|
|
69
|
+
value: "{{JSON.parse(stats).unread_count}}"
|
|
70
|
+
- field: 未读明细
|
|
71
|
+
value: "{{JSON.parse(stats).unread_list.map(function(x){return x.name+'('+x.job+') 未读'+x.unread+'条: '+x.lastMsg}).join(' | ')}}"
|
|
72
|
+
|
|
73
|
+
# ── 获取所有候选人列表 ──────────────────────────────────────────────────────
|
|
74
|
+
# 返回所有发起对话的候选人信息(姓名、应聘职位、最新消息摘要)
|
|
75
|
+
candidates:
|
|
76
|
+
args: []
|
|
77
|
+
steps:
|
|
78
|
+
- open: "https://www.zhipin.com/web/geek/chat"
|
|
79
|
+
- wait: 4000
|
|
80
|
+
# 多次滚动加载更多
|
|
81
|
+
- eval: "document.querySelector('.user-list') && (document.querySelector('.user-list').scrollTop = 99999)"
|
|
82
|
+
- wait: 1500
|
|
83
|
+
- eval: "document.querySelector('.user-list') && (document.querySelector('.user-list').scrollTop = 99999)"
|
|
84
|
+
- wait: 1500
|
|
85
|
+
- eval: "document.querySelector('.user-list') && (document.querySelector('.user-list').scrollTop = 99999)"
|
|
86
|
+
- wait: 1500
|
|
87
|
+
- extract:
|
|
88
|
+
selector: ".geek-item"
|
|
89
|
+
fields:
|
|
90
|
+
姓名: ".geek-name"
|
|
91
|
+
应聘职位: ".source-job"
|
|
92
|
+
最新消息:
|
|
93
|
+
selector: ".gray"
|
|
94
|
+
attr: textContent
|
package/dist/cli.js
CHANGED
|
@@ -42,6 +42,12 @@ if (!hasYaml && !tsAdapter) {
|
|
|
42
42
|
// Support both positional args and --key value flags
|
|
43
43
|
const args = parseArgs(rest);
|
|
44
44
|
// ── Run ───────────────────────────────────────────────────────────────────
|
|
45
|
+
// TypeScript sub-commands that bypass YAML/TS adapter dispatch
|
|
46
|
+
if (platform === 'zhipin' && command === 'resumes') {
|
|
47
|
+
const { downloadResumes } = await import('./scripts/zhipin-download-resumes.js');
|
|
48
|
+
await downloadResumes(args.positional[0]);
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
45
51
|
if (hasYaml) {
|
|
46
52
|
// YAML adapter: pass positional args in order
|
|
47
53
|
runYamlCommand(yamlPath, command, args.positional).catch(err => {
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boss直聘简历批量下载脚本
|
|
3
|
+
*
|
|
4
|
+
* 机制说明:
|
|
5
|
+
* - Boss直聘简历用 WebAssembly 解密 + Canvas 渲染,无法提取 DOM 文字
|
|
6
|
+
* - 本脚本通过 CDP Page.captureScreenshot + clip 截取弹窗区域
|
|
7
|
+
* - 每份简历滚动截多张图,保存为 PNG 序列
|
|
8
|
+
*
|
|
9
|
+
* 用法:
|
|
10
|
+
* social-cli zhipin resumes [outputDir]
|
|
11
|
+
* npx tsx src/scripts/zhipin-download-resumes.ts [outputDir]
|
|
12
|
+
*/
|
|
13
|
+
import { mkdirSync, writeFileSync } from 'fs';
|
|
14
|
+
import { resolve } from 'path';
|
|
15
|
+
import { connectTab, sleep } from '../browser/cdp.js';
|
|
16
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
17
|
+
async function scrollLoadAll(client) {
|
|
18
|
+
for (let i = 0; i < 8; i++) {
|
|
19
|
+
await client.eval(`(function(){ var l=document.querySelector(".user-list"); if(l) l.scrollTop=999999; })()`);
|
|
20
|
+
await sleep(1200);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async function getCandidates(client) {
|
|
24
|
+
const json = await client.eval(`
|
|
25
|
+
(function(){
|
|
26
|
+
var items = document.querySelectorAll(".geek-item");
|
|
27
|
+
return JSON.stringify(Array.from(items).map(function(item, i){
|
|
28
|
+
var name = item.querySelector(".geek-name");
|
|
29
|
+
var job = item.querySelector(".source-job");
|
|
30
|
+
var dataId = item.getAttribute("data-id") || "";
|
|
31
|
+
return { index: i, name: name?name.textContent.trim():"", job: job?job.textContent.trim():"", uid: dataId.split("-")[0], dataId: dataId };
|
|
32
|
+
}));
|
|
33
|
+
})()
|
|
34
|
+
`);
|
|
35
|
+
return JSON.parse(json ?? '[]');
|
|
36
|
+
}
|
|
37
|
+
/** 截取弹窗内简历区域,滚动分段截图,返回保存的文件路径列表 */
|
|
38
|
+
async function screenshotResume(client, dir, prefix) {
|
|
39
|
+
const paths = [];
|
|
40
|
+
// 获取弹窗和滚动容器信息
|
|
41
|
+
const info = await client.eval(`
|
|
42
|
+
(function(){
|
|
43
|
+
var dialog = document.querySelector(".dialog-wrap.active");
|
|
44
|
+
if(!dialog) return JSON.stringify(null);
|
|
45
|
+
var rect = dialog.getBoundingClientRect();
|
|
46
|
+
var scrollEl = dialog.querySelector(".resume-detail");
|
|
47
|
+
return JSON.stringify({
|
|
48
|
+
dx: rect.x, dy: rect.y, dw: rect.width, dh: rect.height,
|
|
49
|
+
scrollHeight: scrollEl ? scrollEl.scrollHeight : rect.height,
|
|
50
|
+
scrollTop: scrollEl ? scrollEl.scrollTop : 0
|
|
51
|
+
});
|
|
52
|
+
})()
|
|
53
|
+
`);
|
|
54
|
+
const d = JSON.parse(info ?? 'null');
|
|
55
|
+
if (!d) {
|
|
56
|
+
// Fallback: full page screenshot
|
|
57
|
+
const ss = await client.send('Page.captureScreenshot', { format: 'png' });
|
|
58
|
+
const p = resolve(dir, `${prefix}_01.png`);
|
|
59
|
+
writeFileSync(p, Buffer.from(ss.data, 'base64'));
|
|
60
|
+
return [p];
|
|
61
|
+
}
|
|
62
|
+
// 先滚回顶部
|
|
63
|
+
await client.eval(`(function(){ var el=document.querySelector(".dialog-wrap.active .resume-detail"); if(el) el.scrollTop=0; })()`);
|
|
64
|
+
await sleep(600);
|
|
65
|
+
const viewH = d.dh; // 可视高度
|
|
66
|
+
const totalH = d.scrollHeight; // 总高度
|
|
67
|
+
const steps = Math.ceil(totalH / viewH);
|
|
68
|
+
for (let i = 0; i < steps; i++) {
|
|
69
|
+
// 截弹窗区域(clip 到对话框 bounds)
|
|
70
|
+
const dpr = await client.eval('window.devicePixelRatio || 1');
|
|
71
|
+
const ss = await client.send('Page.captureScreenshot', {
|
|
72
|
+
format: 'png',
|
|
73
|
+
clip: {
|
|
74
|
+
x: d.dx,
|
|
75
|
+
y: d.dy,
|
|
76
|
+
width: d.dw,
|
|
77
|
+
height: d.dh,
|
|
78
|
+
scale: dpr,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
const p = resolve(dir, `${prefix}_${String(i + 1).padStart(2, '0')}.png`);
|
|
82
|
+
writeFileSync(p, Buffer.from(ss.data, 'base64'));
|
|
83
|
+
paths.push(p);
|
|
84
|
+
// 滚动到下一段
|
|
85
|
+
if (i < steps - 1) {
|
|
86
|
+
await client.eval(`
|
|
87
|
+
(function(){
|
|
88
|
+
var el = document.querySelector(".dialog-wrap.active .resume-detail");
|
|
89
|
+
if(el) el.scrollTop = ${(i + 1) * viewH};
|
|
90
|
+
})()
|
|
91
|
+
`);
|
|
92
|
+
await sleep(800); // 等 Canvas 重绘
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return paths;
|
|
96
|
+
}
|
|
97
|
+
/** 关闭简历弹窗 */
|
|
98
|
+
async function closeDialog(client) {
|
|
99
|
+
await client.eval(`
|
|
100
|
+
(function(){
|
|
101
|
+
var btn = document.querySelector(".boss-popup__close, .boss-dialog__close, [class*=dialog__close], [class*=popup__close]");
|
|
102
|
+
if(btn) { btn.click(); return; }
|
|
103
|
+
document.dispatchEvent(new KeyboardEvent("keydown", {key:"Escape", keyCode:27, bubbles:true}));
|
|
104
|
+
})()
|
|
105
|
+
`);
|
|
106
|
+
await sleep(600);
|
|
107
|
+
}
|
|
108
|
+
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
109
|
+
export async function downloadResumes(outDir) {
|
|
110
|
+
const OUTPUT_DIR = resolve(outDir ?? './resumes');
|
|
111
|
+
console.log(`\n📁 输出目录: ${OUTPUT_DIR}`);
|
|
112
|
+
mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
113
|
+
const chatClient = await connectTab(9222, 0);
|
|
114
|
+
await chatClient.navigate('https://www.zhipin.com/web/chat/index', 4500);
|
|
115
|
+
console.log('\n⏳ 加载候选人列表...');
|
|
116
|
+
await scrollLoadAll(chatClient);
|
|
117
|
+
const candidates = await getCandidates(chatClient);
|
|
118
|
+
console.log(`✅ 找到 ${candidates.length} 位候选人\n`);
|
|
119
|
+
const summary = [];
|
|
120
|
+
for (const c of candidates) {
|
|
121
|
+
const label = `[${c.index + 1}/${candidates.length}] ${c.name} (${c.job})`;
|
|
122
|
+
console.log(`📄 ${label}`);
|
|
123
|
+
const dir = resolve(OUTPUT_DIR, `${String(c.index + 1).padStart(3, '0')}_${c.name}`);
|
|
124
|
+
mkdirSync(dir, { recursive: true });
|
|
125
|
+
try {
|
|
126
|
+
// 点击候选人
|
|
127
|
+
await chatClient.eval(`
|
|
128
|
+
(function(){
|
|
129
|
+
var item = document.querySelector('[data-id="${c.dataId}"]');
|
|
130
|
+
if(item) item.click();
|
|
131
|
+
})()
|
|
132
|
+
`);
|
|
133
|
+
await sleep(1800);
|
|
134
|
+
// 等待在线简历按钮出现
|
|
135
|
+
let btnReady = false;
|
|
136
|
+
for (let t = 0; t < 10; t++) {
|
|
137
|
+
const has = await chatClient.eval(`!!document.querySelector("a.resume-btn-online")`);
|
|
138
|
+
if (has) {
|
|
139
|
+
btnReady = true;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
await sleep(500);
|
|
143
|
+
}
|
|
144
|
+
if (!btnReady)
|
|
145
|
+
throw new Error('在线简历按钮未出现');
|
|
146
|
+
// 点击在线简历
|
|
147
|
+
await chatClient.eval(`document.querySelector("a.resume-btn-online").click()`);
|
|
148
|
+
await sleep(3500); // 等待 WASM 加载并渲染 Canvas
|
|
149
|
+
// 截图
|
|
150
|
+
const prefix = `resume`;
|
|
151
|
+
const screenshots = await screenshotResume(chatClient, dir, prefix);
|
|
152
|
+
summary.push({ name: c.name, job: c.job, uid: c.uid, screenshots });
|
|
153
|
+
console.log(` ✅ 截图 ${screenshots.length} 张 → ${dir}`);
|
|
154
|
+
// 关闭弹窗
|
|
155
|
+
await closeDialog(chatClient);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
159
|
+
console.log(` ❌ 失败: ${msg}`);
|
|
160
|
+
summary.push({ name: c.name, job: c.job, uid: c.uid, screenshots: [], error: msg });
|
|
161
|
+
// 尝试关闭弹窗后继续
|
|
162
|
+
await closeDialog(chatClient).catch(() => { });
|
|
163
|
+
}
|
|
164
|
+
await sleep(500);
|
|
165
|
+
}
|
|
166
|
+
// 保存汇总
|
|
167
|
+
const summaryPath = resolve(OUTPUT_DIR, '_summary.json');
|
|
168
|
+
writeFileSync(summaryPath, JSON.stringify({
|
|
169
|
+
total: candidates.length,
|
|
170
|
+
downloaded: summary.filter(s => s.screenshots.length > 0).length,
|
|
171
|
+
failed: summary.filter(s => !!s.error).length,
|
|
172
|
+
candidates: summary,
|
|
173
|
+
}, null, 2), 'utf-8');
|
|
174
|
+
console.log(`\n${'─'.repeat(50)}`);
|
|
175
|
+
console.log(`✅ 完成: ${summary.filter(s => s.screenshots.length > 0).length}/${candidates.length} 份简历`);
|
|
176
|
+
if (summary.some(s => s.error)) {
|
|
177
|
+
console.log(`❌ 失败: ${summary.filter(s => s.error).length} 份`);
|
|
178
|
+
summary.filter(s => s.error).forEach(s => console.log(` - ${s.name}: ${s.error}`));
|
|
179
|
+
}
|
|
180
|
+
console.log(`📄 汇总: ${summaryPath}`);
|
|
181
|
+
console.log(`📁 目录: ${OUTPUT_DIR}`);
|
|
182
|
+
chatClient.close();
|
|
183
|
+
}
|
|
184
|
+
// 直接执行时运行
|
|
185
|
+
const isDirectRun = process.argv[1]?.includes('zhipin-download-resumes');
|
|
186
|
+
if (isDirectRun) {
|
|
187
|
+
downloadResumes(process.argv[2]).catch(err => {
|
|
188
|
+
console.error('❌', err.message ?? err);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boss直聘简历 AI 筛选脚本
|
|
3
|
+
*
|
|
4
|
+
* 读取 zhipin-download-resumes.ts 生成的截图目录,
|
|
5
|
+
* 用 Claude Vision 逐个分析简历,按招聘标准打分并输出结果。
|
|
6
|
+
*
|
|
7
|
+
* 用法:
|
|
8
|
+
* npx tsx src/scripts/zhipin-screen-resumes.ts <resumeDir> "<标准描述>"
|
|
9
|
+
* npm run zhipin:screen -- ./resumes "要求3年以上Python经验,熟悉大模型训练,本科及以上学历"
|
|
10
|
+
*
|
|
11
|
+
* 环境变量:
|
|
12
|
+
* ANTHROPIC_API_KEY Claude API Key(必填)
|
|
13
|
+
*/
|
|
14
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
15
|
+
import { readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
|
|
16
|
+
import { resolve, join } from 'path';
|
|
17
|
+
// ── 参数解析 ─────────────────────────────────────────────────────────────────
|
|
18
|
+
const RESUME_DIR = process.argv[2];
|
|
19
|
+
const CRITERIA = process.argv[3];
|
|
20
|
+
if (!RESUME_DIR || !CRITERIA) {
|
|
21
|
+
console.error('用法: npx tsx src/scripts/zhipin-screen-resumes.ts <resumeDir> "<招聘标准>"');
|
|
22
|
+
console.error('示例: npm run zhipin:screen -- ./resumes "要求3年以上Python经验,熟悉大模型训练"');
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
// 支持 ZenMux 代理:优先用 ANTHROPIC_AUTH_TOKEN,其次 ANTHROPIC_API_KEY
|
|
26
|
+
const apiKey = process.env.ANTHROPIC_AUTH_TOKEN ?? process.env.ANTHROPIC_API_KEY ?? 'placeholder';
|
|
27
|
+
const baseURL = process.env.ANTHROPIC_BASE_URL; // ZenMux 等代理端点
|
|
28
|
+
// ── 读取简历目录 ─────────────────────────────────────────────────────────────
|
|
29
|
+
function loadCandidates(dir) {
|
|
30
|
+
const summaryPath = join(dir, '_summary.json');
|
|
31
|
+
if (!statSync(summaryPath, { throwIfNoEntry: false })) {
|
|
32
|
+
// 没有 _summary.json,自动扫描子目录
|
|
33
|
+
const subdirs = readdirSync(dir).filter(d => statSync(join(dir, d)).isDirectory());
|
|
34
|
+
return subdirs.map(d => {
|
|
35
|
+
const pngs = readdirSync(join(dir, d)).filter(f => f.endsWith('.png')).map(f => join(dir, d, f)).sort();
|
|
36
|
+
const namePart = d.replace(/^\d+_/, '');
|
|
37
|
+
return { name: namePart, job: '', uid: d, screenshots: pngs };
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
const summary = JSON.parse(readFileSync(summaryPath, 'utf-8'));
|
|
41
|
+
return summary.candidates.filter(c => c.screenshots.length > 0);
|
|
42
|
+
}
|
|
43
|
+
// ── Claude Vision 分析 ───────────────────────────────────────────────────────
|
|
44
|
+
const clientOpts = { apiKey };
|
|
45
|
+
if (baseURL)
|
|
46
|
+
clientOpts.baseURL = baseURL;
|
|
47
|
+
const client = new Anthropic(clientOpts);
|
|
48
|
+
async function analyzeResume(candidate, criteria) {
|
|
49
|
+
// 读取所有截图,转 base64
|
|
50
|
+
const imageBlocks = candidate.screenshots.map(p => ({
|
|
51
|
+
type: 'image',
|
|
52
|
+
source: {
|
|
53
|
+
type: 'base64',
|
|
54
|
+
media_type: 'image/png',
|
|
55
|
+
data: readFileSync(p).toString('base64'),
|
|
56
|
+
},
|
|
57
|
+
}));
|
|
58
|
+
const prompt = `你是一位专业的招聘顾问。请根据以下招聘标准,评估这份简历是否符合要求。
|
|
59
|
+
|
|
60
|
+
招聘标准:
|
|
61
|
+
${criteria}
|
|
62
|
+
|
|
63
|
+
请严格按照以下 JSON 格式输出,不要有任何其他文字:
|
|
64
|
+
{
|
|
65
|
+
"pass": true或false,
|
|
66
|
+
"score": 0到10的整数(10分完全符合,0分完全不符合),
|
|
67
|
+
"summary": "一句话概括候选人背景(20字以内)",
|
|
68
|
+
"pros": ["符合标准的亮点1", "亮点2"],
|
|
69
|
+
"cons": ["不符合或欠缺的地方1", "欠缺2"],
|
|
70
|
+
"reason": "详细说明通过或不通过的原因(100字以内)"
|
|
71
|
+
}`;
|
|
72
|
+
const model = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? 'claude-sonnet-4-6';
|
|
73
|
+
const msg = await client.messages.create({
|
|
74
|
+
model,
|
|
75
|
+
max_tokens: 1024,
|
|
76
|
+
messages: [{
|
|
77
|
+
role: 'user',
|
|
78
|
+
content: [...imageBlocks, { type: 'text', text: prompt }],
|
|
79
|
+
}],
|
|
80
|
+
});
|
|
81
|
+
const raw = msg.content[0].text.trim();
|
|
82
|
+
// 提取 JSON(防止模型在 JSON 外输出多余文字)
|
|
83
|
+
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
|
84
|
+
if (!jsonMatch)
|
|
85
|
+
throw new Error(`模型未返回 JSON: ${raw.slice(0, 100)}`);
|
|
86
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
87
|
+
return {
|
|
88
|
+
name: candidate.name,
|
|
89
|
+
job: candidate.job,
|
|
90
|
+
uid: candidate.uid,
|
|
91
|
+
pass: parsed.pass,
|
|
92
|
+
score: parsed.score,
|
|
93
|
+
summary: parsed.summary,
|
|
94
|
+
pros: parsed.pros ?? [],
|
|
95
|
+
cons: parsed.cons ?? [],
|
|
96
|
+
rawReason: parsed.reason,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
// ── 输出格式 ─────────────────────────────────────────────────────────────────
|
|
100
|
+
function renderResult(r, index, total) {
|
|
101
|
+
const icon = r.pass ? '✅' : '❌';
|
|
102
|
+
const bar = '█'.repeat(r.score) + '░'.repeat(10 - r.score);
|
|
103
|
+
console.log(`\n${icon} [${index}/${total}] ${r.name} ${r.job}`);
|
|
104
|
+
console.log(` 评分: ${bar} ${r.score}/10`);
|
|
105
|
+
console.log(` 摘要: ${r.summary}`);
|
|
106
|
+
if (r.pros.length)
|
|
107
|
+
console.log(` 亮点: ${r.pros.join(' · ')}`);
|
|
108
|
+
if (r.cons.length)
|
|
109
|
+
console.log(` 欠缺: ${r.cons.join(' · ')}`);
|
|
110
|
+
console.log(` 理由: ${r.rawReason}`);
|
|
111
|
+
}
|
|
112
|
+
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
113
|
+
const dir = resolve(RESUME_DIR);
|
|
114
|
+
const candidates = loadCandidates(dir);
|
|
115
|
+
console.log(`\n🔍 招聘标准:${CRITERIA}`);
|
|
116
|
+
console.log(`👥 候选人数:${candidates.length}`);
|
|
117
|
+
console.log(`${'─'.repeat(60)}`);
|
|
118
|
+
const results = [];
|
|
119
|
+
const errors = [];
|
|
120
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
121
|
+
const c = candidates[i];
|
|
122
|
+
process.stdout.write(`\n⏳ 分析 [${i + 1}/${candidates.length}] ${c.name}...`);
|
|
123
|
+
try {
|
|
124
|
+
const result = await analyzeResume(c, CRITERIA);
|
|
125
|
+
results.push(result);
|
|
126
|
+
renderResult(result, i + 1, candidates.length);
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
130
|
+
process.stdout.write(` ❌ ${msg}\n`);
|
|
131
|
+
errors.push({ name: c.name, error: msg });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// ── 汇总统计 ─────────────────────────────────────────────────────────────────
|
|
135
|
+
const passed = results.filter(r => r.pass);
|
|
136
|
+
const failed = results.filter(r => !r.pass);
|
|
137
|
+
const avgScore = results.length ? (results.reduce((s, r) => s + r.score, 0) / results.length).toFixed(1) : '0';
|
|
138
|
+
console.log(`\n${'═'.repeat(60)}`);
|
|
139
|
+
console.log(`📊 筛选结果汇总`);
|
|
140
|
+
console.log(`${'─'.repeat(60)}`);
|
|
141
|
+
console.log(`总候选人:${candidates.length} | 通过:${passed.length} | 未通过:${failed.length} | 平均分:${avgScore}`);
|
|
142
|
+
if (passed.length) {
|
|
143
|
+
console.log(`\n✅ 通过名单(${passed.length} 人):`);
|
|
144
|
+
passed
|
|
145
|
+
.sort((a, b) => b.score - a.score)
|
|
146
|
+
.forEach(r => console.log(` ${r.score}/10 ${r.name} — ${r.summary}`));
|
|
147
|
+
}
|
|
148
|
+
if (failed.length) {
|
|
149
|
+
console.log(`\n❌ 未通过(${failed.length} 人):`);
|
|
150
|
+
failed
|
|
151
|
+
.sort((a, b) => b.score - a.score)
|
|
152
|
+
.forEach(r => console.log(` ${r.score}/10 ${r.name} — ${r.cons[0] ?? r.rawReason.slice(0, 30)}`));
|
|
153
|
+
}
|
|
154
|
+
// ── 保存结果 ─────────────────────────────────────────────────────────────────
|
|
155
|
+
const outputPath = join(dir, '_screen_result.json');
|
|
156
|
+
writeFileSync(outputPath, JSON.stringify({
|
|
157
|
+
criteria: CRITERIA,
|
|
158
|
+
total: candidates.length,
|
|
159
|
+
passed: passed.length,
|
|
160
|
+
failed: failed.length,
|
|
161
|
+
avgScore: parseFloat(avgScore),
|
|
162
|
+
results: results.sort((a, b) => b.score - a.score),
|
|
163
|
+
errors,
|
|
164
|
+
}, null, 2), 'utf-8');
|
|
165
|
+
console.log(`\n📄 详细结果已保存:${outputPath}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harness.farm/social-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "CDP-based social media automation CLI — X, 小红书, 抖音, B站, Temu",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
"xhs": "tsx src/cli.ts xhs",
|
|
22
22
|
"douyin": "tsx src/cli.ts douyin",
|
|
23
23
|
"bilibili": "tsx src/cli.ts bilibili",
|
|
24
|
-
"temu": "tsx src/cli.ts temu"
|
|
24
|
+
"temu": "tsx src/cli.ts temu",
|
|
25
|
+
"zhipin": "tsx src/cli.ts zhipin",
|
|
26
|
+
"zhipin:resumes": "tsx src/cli.ts zhipin resumes"
|
|
25
27
|
},
|
|
26
28
|
"keywords": [
|
|
27
29
|
"social-media",
|
|
@@ -39,6 +41,7 @@
|
|
|
39
41
|
"url": "https://github.com/harness-farm/social-cli.git"
|
|
40
42
|
},
|
|
41
43
|
"dependencies": {
|
|
44
|
+
"@anthropic-ai/sdk": "^0.80.0",
|
|
42
45
|
"ws": "^8.18.0",
|
|
43
46
|
"yaml": "^2.8.3"
|
|
44
47
|
},
|